From 73532266032d0cbe0424025631d60afdec52a168 Mon Sep 17 00:00:00 2001 From: kswdev0 Date: Thu, 26 Mar 2026 11:17:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AIPlanner=EC=9D=98=20GLM=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content가 비어있거나 JSON 형태가 아닐 경우 reasoning_content를 우선 사용하도록 로직 개선 - JSON 유사성을 판단하는 헬퍼 함수 추가 및 기존 동작 유지 - 관련 단위 테스트 추가 및 README.md에 변경 사항 반영 --- README.md | 2 +- ai_planner.py | 33 +++++++++++++++++++++++------ tests/test_ai_planner_parse_json.py | 15 +++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3b755b7..8a84713 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`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비는 경우에는 `message.reasoning_content`를 대신 사용합니다. +- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비거나(또는 JSON 형태가 아니면) `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 053c816..9e88e56 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -370,14 +370,35 @@ class AIPlanner: 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 "" + if not isinstance(content, str): + content = "" + if not isinstance(reasoning, str): + reasoning = "" + + # Heuristic: + # - 모델이 "JSON만 반환"을 어기면 content에 분석/설명 텍스트가 들어올 수 있음. + # - 그 경우 reasoning_content 쪽에 실제 JSON이 들어있는 패턴을 우선 복구한다. + def looks_like_json(s: str) -> bool: + if not s: + return False + if '"actions"' in s or '"current_goal"' in s: + return True + # 최소 토큰 기반 (finish_reason=length에서 특히 reasoning에 JSON이 들어오는 케이스) + return ("{" in s) or ("[" in s) + + content_stripped = content.strip() + reasoning_stripped = reasoning.strip() + + if looks_like_json(content_stripped): + return content_stripped + if looks_like_json(reasoning_stripped): + return reasoning_stripped + # 둘 다 JSON처럼 보이지 않더라도, content가 있으면 먼저 반환(기존 동작 유지) + if content_stripped: + return content_stripped + return reasoning_stripped def _parse_json(self, raw: str) -> dict: text = raw.strip() diff --git a/tests/test_ai_planner_parse_json.py b/tests/test_ai_planner_parse_json.py index 1e9018b..d2f064f 100644 --- a/tests/test_ai_planner_parse_json.py +++ b/tests/test_ai_planner_parse_json.py @@ -54,3 +54,18 @@ class TestAIPlannerParseJson(unittest.TestCase): extracted = self.planner._extract_glm_assistant_text(fake) self.assertIn('"current_goal":"g"', extracted) + def test_extract_glm_text_uses_reasoning_when_content_has_no_json(self): + fake = { + "choices": [ + { + "finish_reason": "length", + "message": { + "content": "1. **Current State Analysis:**\n- Location: (0, 0)\n- Inventory: {...}", + "reasoning_content": '{"thinking":"t","current_goal":"g","actions":[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}],"after_this":"a"}', + }, + } + ] + } + extracted = self.planner._extract_glm_assistant_text(fake) + self.assertIn('"current_goal":"g"', extracted) +