From 0a46e16e5b13d3e68c34fa5bdd4b6e00ec9fa256 Mon Sep 17 00:00:00 2001 From: kswdev0 Date: Thu, 26 Mar 2026 10:57:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20JSON=20=EB=B0=B0=EC=97=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_parse_json()` 메서드에서 최상위 JSON 배열을 허용하고, 이를 `actions` 리스트로 래핑하여 정상적인 파이프라인으로 이어지도록 개선 - JSON 파싱 실패 시 원인 파악을 돕기 위해 오류 메시지에 첫 비공백 문자를 포함 - 새로운 단위 테스트를 추가하여 JSON 객체 및 배열 파서의 회귀 방지 - README.md 및 문서에 변경 사항 반영 --- README.md | 2 +- ai_planner.py | 83 ++++++++++++++++++++++++++++- docs/plan.md | 19 +++++++ tests/test_ai_planner_parse_json.py | 40 ++++++++++++++ 4 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 tests/test_ai_planner_parse_json.py diff --git a/README.md b/README.md index b4595d2..a629b1c 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ planner.set_goal( - 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다 - AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다 - `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다 -- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. +- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `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 bc03e9a..c67cc99 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -352,12 +352,34 @@ class AIPlanner: if not l.strip().startswith("```") ).strip() try: - return json.loads(text) + loaded = json.loads(text) + if isinstance(loaded, dict): + return loaded + if isinstance(loaded, list): + return self._plan_from_actions_array(loaded) except json.JSONDecodeError: pass start = text.find("{") if start == -1: - raise ValueError("JSON 파싱 실패 ('{' 없음):\n" + raw[:300]) + # GLM이 상단 레벨에서 JSON 배열(`[ ... ]`)로 응답하는 경우를 허용 + arr_start = text.find("[") + if arr_start != -1: + candidate = self._extract_balanced_json_candidate(text, arr_start) + try: + loaded = json.loads(candidate) + if isinstance(loaded, list): + return self._plan_from_actions_array(loaded) + except json.JSONDecodeError: + # 아래 공통 오류 메시지로 떨어져서 fallback 사용 + pass + + first_non_ws = next((c for c in text if not c.isspace()), "") + raise ValueError( + "JSON 파싱 실패 ('{' 없음): first_non_ws=" + + repr(first_non_ws) + + "\n" + + raw[:300] + ) brace_depth = 0 bracket_depth = 0 in_string = False @@ -408,6 +430,63 @@ class AIPlanner: except json.JSONDecodeError: raise ValueError(f"JSON 파싱 실패:\n{raw[:400]}") + @staticmethod + def _extract_balanced_json_candidate(text: str, start: int) -> str: + """ + text[start:]에서 중괄호/대괄호 균형이 맞는 첫 구간을 잘라 후보 JSON 문자열을 만든다. + """ + 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 + + if brace_depth == 0 and bracket_depth == 0: + if i > start: + end = i + 1 + break + return text[start:end] + + @staticmethod + def _plan_from_actions_array(loaded: list[object]) -> dict: + """ + GLM이 JSON 배열(`[{"action": ...}, ...]`)로 응답하는 경우를 + decide()에서 기대하는 JSON 객체 구조로 래핑한다. + """ + if not all(isinstance(x, dict) for x in loaded): + raise ValueError("JSON 파싱 실패: actions 배열 원소가 객체(dict)가 아닙니다.") + actions = loaded # type: ignore[assignment] + if actions and "action" not in actions[0]: + raise ValueError("JSON 파싱 실패: actions 배열이 행동 형식(action 키)을 포함하지 않습니다.") + return { + "thinking": "", + "current_goal": "", + "actions": actions, + "after_this": "재시도", + } + def _repair_truncated_json(self, text: str) -> str: if '"actions"' not in text: return '{"thinking":"응답 잘림","current_goal":"탐색","actions":[],"after_this":"재시도"}' diff --git a/docs/plan.md b/docs/plan.md index 4b9dc14..6822c61 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -263,3 +263,22 @@ ### README - `GLM API 연결 문제 디버깅` 절 추가 +--- +## GLM 응답 형식 편차: JSON 객체 외(배열) 처리 강화 + +### 문제 관찰 +- 로그에서 `ValueError: JSON 파싱 실패 ('{' 없음)`이 반복됨 +- `_parse_json()`은 기본적으로 JSON 객체(`{...}`)를 기대하므로, GLM이 최상위에서 JSON 배열(`[...]`)로 응답하면 파싱 실패로 떨어질 수 있음 + +### 변경 목표 +1. `_parse_json()`이 최상위 JSON 배열을 `actions` 리스트로 래핑해 정상 파이프라인으로 이어지게 함 +2. 실패 시 원인 파악을 돕기 위해 `raw`의 첫 비공백 문자를 오류 메시지에 포함 +3. `tests/test_ai_planner_parse_json.py`로 회귀 방지 + +### 구현 범위 +- `ai_planner.py` + - `_parse_json()`에서 JSON `list`도 허용하고, `{' 없음`일 때 `[...]`를 균형 추출 후 래핑 + - 실패 원인 로깅(첫 비공백 문자) +- `tests/` + - JSON 객체/배열 파서 단위 테스트 추가 + diff --git a/tests/test_ai_planner_parse_json.py b/tests/test_ai_planner_parse_json.py new file mode 100644 index 0000000..bc2a9fa --- /dev/null +++ b/tests/test_ai_planner_parse_json.py @@ -0,0 +1,40 @@ +import os +import unittest + +from ai_planner import AIPlanner + + +class TestAIPlannerParseJson(unittest.TestCase): + def setUp(self): + # AIPlanner 생성 시 ZAI_API_KEY가 필요하므로 테스트에서는 더미를 주입한다. + os.environ.setdefault("ZAI_API_KEY", "dummy") + self.planner = AIPlanner() + + def test_parse_json_object(self): + raw = ( + '{"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") + + def test_parse_json_array_top_level(self): + raw = '[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}]' + plan = self.planner._parse_json(raw) + self.assertEqual(len(plan["actions"]), 1) + self.assertEqual(plan["actions"][0]["action"], "explore") + self.assertIn("after_this", plan) + + def test_parse_json_array_with_code_fence(self): + raw = ( + "```json\n" + '[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}]\n' + "```" + ) + plan = self.planner._parse_json(raw) + self.assertEqual(len(plan["actions"]), 1) + self.assertEqual(plan["actions"][0]["action"], "explore") +