diff --git a/README.md b/README.md index 6a3a0bb..0352826 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ planner.set_goal( - 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다 - AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다 +- `ai_planner.py`의 Ollama/LM Studio 호출 payload는 `messages`와 `input`을 함께 전송해, 서버가 `input` 필드를 요구하는 OpenAI Responses 호환 모드에서도 400(`'input' is required`)을 피하도록 했습니다. - `ai_planner.py`는 LLM이 move 순서를 놓쳐 `place_entity`/`insert_to_entity`/`set_recipe`가 건설 거리 제한으로 실패하는 경우를 줄이기 위해, 해당 액션 직전에 최근 `move`가 같은 좌표가 아니면 자동으로 `move`를 끼워 넣습니다 - `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다 - `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비거나(또는 JSON 형태가 아니면) `message.reasoning_content`를 우선 사용합니다. 그리고 응답 안에 여러 개의 `{...}`가 섞여 있어도 그중 `actions`를 포함한 계획 객체를 우선 선택합니다. 또한 JSON 파싱 실패가 감지되면 다음 재시도에는 `JSON-only repair` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다. diff --git a/ai_planner.py b/ai_planner.py index b1c2991..56a01e8 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -196,17 +196,7 @@ class AIPlanner: spinner.start() try: - payload = { - "model": OLLAMA_MODEL, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": user_message}, - ], - "format": "json", - "think": False, - "stream": False, - "options": {"temperature": 0.3, "num_ctx": 8192}, - } + payload = self._build_chat_payload(user_message) resp = httpx.post( f"{OLLAMA_HOST}/api/v1/chat", json=payload, @@ -219,12 +209,68 @@ class AIPlanner: spinner.join() dt = time.perf_counter() - t0 - content = data["message"]["content"] + content = self._extract_response_content(data) print(f"[AI] 응답 수신 ({dt:.2f}s, {len(content)}자)") if _debug_enabled(): print(f"[AI][디버그] raw={content[:300]}") return json.loads(content) + @staticmethod + def _build_chat_payload(user_message: str) -> dict: + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message}, + ] + return { + "model": OLLAMA_MODEL, + "messages": messages, + # LM Studio(OpenAI Responses 호환)에서는 input 필드가 필수인 경우가 있어 함께 보낸다. + "input": user_message, + "format": "json", + "think": False, + "stream": False, + "options": {"temperature": 0.3, "num_ctx": 8192}, + } + + @staticmethod + def _extract_response_content(data: dict) -> str: + message = data.get("message") + if isinstance(message, dict): + content = message.get("content") + if isinstance(content, str) and content.strip(): + return content + + output_text = data.get("output_text") + if isinstance(output_text, str) and output_text.strip(): + return output_text + + choices = data.get("choices") + if isinstance(choices, list) and choices: + first = choices[0] + if isinstance(first, dict): + msg = first.get("message") + if isinstance(msg, dict): + content = msg.get("content") + if isinstance(content, str) and content.strip(): + return content + + output = data.get("output") + if isinstance(output, list): + for item in output: + if not isinstance(item, dict): + continue + content_items = item.get("content") + if not isinstance(content_items, list): + continue + for c in content_items: + if not isinstance(c, dict): + continue + text = c.get("text") + if isinstance(text, str) and text.strip(): + return text + + raise ValueError(f"응답에서 텍스트 콘텐츠를 찾지 못했습니다. keys={list(data.keys())}") + @staticmethod def _ensure_move_before_build_actions(actions: list[dict]) -> list[dict]: """ diff --git a/docs/plan.md b/docs/plan.md index 6822c61..13f796b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,3 +1,21 @@ +## 2026-03-27 LM Studio `input required` 오류 수정 계획 + +### 문제 요약 +- LM Studio 서버로 `POST /api/v1/chat` 호출 시 `{"error":{"message":"'input' is required"}}` 400이 발생. +- 현재 `ai_planner.py`는 `messages` 기반 payload만 전송하고 있어, `input` 기반 스키마를 요구하는 서버와 비호환. + +### 구현 계획 +1. `ai_planner.py` 요청 payload를 함수로 분리해 `messages` + `input`을 함께 포함하도록 수정한다. +2. 응답 파싱을 LM Studio/OpenAI 호환 형태(`message.content` 우선, `output_text` 대안)까지 처리한다. +3. 테스트를 먼저 추가해 payload에 `input`이 포함되는지, 응답 추출이 대체 경로에서 동작하는지 검증한다. +4. 문서(`README.md`)에 LM Studio 호환 동작을 반영한다. + +### 검증 계획 +- `pytest tests/test_ai_planner_lmstudio_compat.py -q` +- 필요 시 전체 관련 테스트 재실행 + +--- + ## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지) ### 문제 diff --git a/tests/test_ai_planner_lmstudio_compat.py b/tests/test_ai_planner_lmstudio_compat.py new file mode 100644 index 0000000..624f20e --- /dev/null +++ b/tests/test_ai_planner_lmstudio_compat.py @@ -0,0 +1,37 @@ +import unittest + +from ai_planner import AIPlanner + + +class TestAIPlannerLMStudioCompat(unittest.TestCase): + def test_build_chat_payload_includes_input_for_responses_compat(self): + payload = AIPlanner._build_chat_payload("hello") + + self.assertIn("messages", payload) + self.assertIn("input", payload) + self.assertEqual(payload["input"], "hello") + self.assertEqual(payload["messages"][1]["content"], "hello") + + def test_extract_response_content_supports_message_content(self): + data = { + "message": { + "content": ( + '{"thinking":"t","current_goal":"g","actions":[],"after_this":"a"}' + ) + } + } + content = AIPlanner._extract_response_content(data) + self.assertIn('"current_goal":"g"', content) + + def test_extract_response_content_supports_output_text(self): + data = { + "output_text": ( + '{"thinking":"t","current_goal":"g","actions":[],"after_this":"a"}' + ) + } + content = AIPlanner._extract_response_content(data) + self.assertIn('"after_this":"a"', content) + + +if __name__ == "__main__": + unittest.main()