From ace5d634801c3def91a77500979e096cbc88f821 Mon Sep 17 00:00:00 2001 From: kswdev0 Date: Thu, 26 Mar 2026 11:36:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AIPlanner=20JSON=20=ED=8C=8C=EC=8B=B1?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20JSON=20=EA=B0=9D=EC=B2=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_parse_json()` 메서드에서 응답 내 여러 JSON 객체 중 `actions`를 포함한 계획 객체를 우선 선택하도록 로직 개선 - JSON 파싱 실패 시 잘림 복구 로직을 추가하여 안정성 향상 - 관련 단위 테스트 추가 및 README.md에 변경 사항 반영 --- README.md | 2 +- ai_planner.py | 83 +++++++++++++---------------- tests/test_ai_planner_parse_json.py | 16 ++++++ 3 files changed, 54 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 8a84713..d84df9a 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`가 비거나(또는 JSON 형태가 아니면) `message.reasoning_content`를 우선 사용합니다. +- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비거나(또는 JSON 형태가 아니면) `message.reasoning_content`를 우선 사용합니다. 그리고 응답 안에 여러 개의 `{...}`가 섞여 있어도 그중 `actions`를 포함한 계획 객체를 우선 선택합니다. - `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 5b53fb2..a48b356 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -451,55 +451,46 @@ class AIPlanner: + "\n" + raw[:300] ) - brace_depth = 0 - bracket_depth = 0 - in_string = False - escape = False - end = start - for i in range(start, len(text)): - c = text[i] - if escape: - escape = False - continue - if c == '\\' and in_string: - escape = True - continue - if c == '"' and not escape: - in_string = not in_string - continue - if in_string: - continue - if c == '{': - brace_depth += 1 - elif c == '}': - brace_depth -= 1 - elif c == '[': - bracket_depth += 1 - elif c == ']': - bracket_depth -= 1 + # 응답 안에 { ... }가 여러 개(예: 인벤토리 하위 객체, 분석용 JSON)가 섞일 수 있다. + # decide() 파이프라인은 "actions" 스키마를 가진 계획 객체가 필요하므로, + # candidates 중 actions를 포함하는 것을 우선 선택한다. + search_pos = start + for _ in range(10): + next_start = text.find("{", search_pos) + if next_start == -1: + break + candidate = self._extract_balanced_json_candidate(text, next_start) + if candidate: + try: + loaded = json.loads(candidate) + if ( + isinstance(loaded, dict) + and isinstance(loaded.get("actions"), list) + ): + return loaded + except json.JSONDecodeError: + # candidate가 중간에 잘려 실패한 경우 다음 '{'를 시도 + pass + search_pos = next_start + 1 - if brace_depth == 0 and bracket_depth == 0: - # 최상위 JSON 객체가 종료된 지점으로 추정 - if i > start: - end = i + 1 - break - if brace_depth != 0 or bracket_depth != 0: - partial = text[start:] - partial = self._repair_truncated_json(partial) - try: - return json.loads(partial) - except json.JSONDecodeError: - raise ValueError(f"JSON 파싱 실패 (잘린 응답 복구 불가):\n{raw[:400]}") - candidate = text[start:end] + # 그래도 actions 스키마를 찾지 못하면, 잘림 복구 로직으로 마지막 수습을 시도한다. + partial = text[start:] + repaired = self._repair_truncated_json(partial) try: - return json.loads(candidate) + loaded = json.loads(repaired) + if isinstance(loaded, dict): + return loaded except json.JSONDecodeError: - # 중괄호는 맞지만 배열/후행 속성이 잘려 파싱 실패하는 케이스 복구 - repaired = self._repair_truncated_json(candidate) - try: - return json.loads(repaired) - except json.JSONDecodeError: - raise ValueError(f"JSON 파싱 실패:\n{raw[:400]}") + pass + + first_non_ws = next((c for c in text if not c.isspace()), "") + raise ValueError( + "JSON 파싱 실패: actions 스키마를 포함한 후보를 찾지 못함 " + "(first_non_ws=" + + repr(first_non_ws) + + ")\n" + + raw[:400] + ) @staticmethod def _extract_balanced_json_candidate(text: str, start: int) -> str: diff --git a/tests/test_ai_planner_parse_json.py b/tests/test_ai_planner_parse_json.py index d2f064f..9634c74 100644 --- a/tests/test_ai_planner_parse_json.py +++ b/tests/test_ai_planner_parse_json.py @@ -69,3 +69,19 @@ class TestAIPlannerParseJson(unittest.TestCase): extracted = self.planner._extract_glm_assistant_text(fake) self.assertIn('"current_goal":"g"', extracted) + def test_parse_json_selects_actions_object_when_multiple_json_objects_exist(self): + # 분석 텍스트 안에 먼저 나오는 하위 JSON({ "foo": 1 })이 있고, + # 뒤에 실제 계획 JSON({ "actions": [...] })이 있는 경우를 검증한다. + raw = ( + "1. Analyze...\n" + '{"foo": 1}\n' + "2. Continue...\n" + '{"thinking":"t","current_goal":"g",' + '"actions":[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}],' + '"after_this":"a"}' + ) + plan = self.planner._parse_json(raw) + self.assertEqual(plan["current_goal"], "g") + self.assertEqual(len(plan["actions"]), 1) + self.assertEqual(plan["actions"][0]["action"], "explore") +