feat: JSON 배열 처리 및 오류 메시지 개선
- `_parse_json()` 메서드에서 최상위 JSON 배열을 허용하고, 이를 `actions` 리스트로 래핑하여 정상적인 파이프라인으로 이어지도록 개선 - JSON 파싱 실패 시 원인 파악을 돕기 위해 오류 메시지에 첫 비공백 문자를 포함 - 새로운 단위 테스트를 추가하여 JSON 객체 및 배열 파서의 회귀 방지 - README.md 및 문서에 변경 사항 반영
This commit is contained in:
@@ -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":"재시도"}'
|
||||
|
||||
Reference in New Issue
Block a user