diff --git a/README.md b/README.md index a629b1c..3b755b7 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ planner.set_goal( - 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다 - AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다 - `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다 -- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. +- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비는 경우에는 `message.reasoning_content`를 대신 사용합니다. - `ai_planner.py`의 `AIPlanner.decide()`는 `TimeoutError`/`ConnectionError`/`urllib.error.URLError` 같은 GLM HTTP 지연/연결 오류도 3회 재시도한 뒤, **상태 요약에 나온 광맥(앵커) 좌표가 있으면 `mine_resource`(먼 경우 `move` 후 채굴)로 폴백**하고, 광맥 정보가 없을 때만 `explore` 방향을 순환하며 탐색합니다(동일 방향 탐색 루프 완화). - GLM HTTP 읽기 제한 시간은 기본 120초이며, `GLM_HTTP_TIMEOUT_SECONDS`로 조정할 수 있습니다. 광맥은 플레이어와 200타일 이상 떨어진 경우에만 폴백에서 `move`를 끼우며, 임계값은 `GLM_FALLBACK_MOVE_THRESHOLD`(기본 200)로 바꿀 수 있습니다. - `ai_planner.py`의 `_call_glm()`에서 GLM 지연 원인 분석을 위한 타이밍 로그가 출력됩니다. diff --git a/ai_planner.py b/ai_planner.py index ef8a6fc..053c816 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -312,19 +312,15 @@ class AIPlanner: t_json0 = time.perf_counter() data = json.loads(raw_text) - content = data["choices"][0]["message"]["content"].strip() - if not content: - # content가 비어있으면 이후 JSON 파서가 원인 파악이 어렵다. - # GLM_DEBUG=1에서 raw 응답 일부를 함께 보여준다. - if _glm_debug_enabled(): - finish_reason = data.get("choices", [{}])[0].get("finish_reason") - print( - "[경고] GLM 응답 content 비어있음 | " - f"finish_reason={finish_reason!r} | " - f"raw_preview={raw_text[:600]!r}" - ) - else: - print("[경고] GLM 응답 content가 비어있습니다. (GLM_DEBUG=1 시 raw_preview 출력)") + content = self._extract_glm_assistant_text(data).strip() + if not content and _glm_debug_enabled(): + # content가 비어있으면 아래 파서에서 원인 추적이 어려워지므로 raw 일부를 남긴다. + finish_reason = data.get("choices", [{}])[0].get("finish_reason") + print( + "[경고] GLM 응답 assistant text 비어있음 | " + f"finish_reason={finish_reason!r} | " + f"raw_preview={raw_text[:600]!r}" + ) t_json_done = time.perf_counter() dt_total = time.perf_counter() - t_total0 @@ -354,6 +350,35 @@ class AIPlanner: f"GLM API HTTP {e.code}: {body[:1200]}" ) from e + @staticmethod + def _extract_glm_assistant_text(data: dict) -> str: + """ + GLM 응답에서 사용자가 기대하는 assistant 텍스트를 뽑는다. + + 관찰 케이스: + - finish_reason='length' 인데 message.content가 ''로 오고, + message.reasoning_content에 실제 출력(JSON)이 포함되는 패턴이 있다. + """ + choices = data.get("choices") if isinstance(data, dict) else None + if not choices or not isinstance(choices, list): + return "" + choice0 = choices[0] if choices else {} + if not isinstance(choice0, dict): + return "" + msg = choice0.get("message", {}) + if not isinstance(msg, dict): + return "" + + content = msg.get("content") or "" + if isinstance(content, str) and content.strip(): + return content + + reasoning = msg.get("reasoning_content") or "" + if isinstance(reasoning, str): + return reasoning + + return "" + def _parse_json(self, raw: str) -> dict: text = raw.strip() if "" in text: diff --git a/tests/test_ai_planner_parse_json.py b/tests/test_ai_planner_parse_json.py index bc2a9fa..1e9018b 100644 --- a/tests/test_ai_planner_parse_json.py +++ b/tests/test_ai_planner_parse_json.py @@ -38,3 +38,19 @@ class TestAIPlannerParseJson(unittest.TestCase): self.assertEqual(len(plan["actions"]), 1) self.assertEqual(plan["actions"][0]["action"], "explore") + def test_extract_glm_text_prefers_content_then_reasoning(self): + # content가 비어있고 reasoning_content에 JSON이 들어있는 케이스 + fake = { + "choices": [ + { + "finish_reason": "length", + "message": { + "content": "", + "reasoning_content": '{"thinking":"t","current_goal":"g","actions":[],"after_this":"a"}', + }, + } + ] + } + extracted = self.planner._extract_glm_assistant_text(fake) + self.assertIn('"current_goal":"g"', extracted) +