From 8e743d12e72b038e1c09a041c4924765d0caa8dd Mon Sep 17 00:00:00 2001 From: 21in7 Date: Fri, 27 Mar 2026 20:10:44 +0900 Subject: [PATCH] feat: enhance AIPlanner with compact system prompt and improved JSON parsing for input-only mode responses --- README.md | 1 + ai_planner.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++--- docs/plan.md | 18 +++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d85506e..a2d71ce 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ planner.set_goal( - 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다 - AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다 - `ai_planner.py`의 Ollama/LM Studio 호출은 서버 스키마 차이를 자동으로 맞추기 위해 payload 후보를 순차 재시도합니다. 먼저 `messages` 기반(legacy chat)으로 시도하고, 400(`unrecognized_keys`/`input required`)이면 `input` 중심 최소 payload로 재시도합니다. +- LM Studio `input-only` 모드에서 `output[].type=reasoning` 형태로 응답이 오는 경우도 파서가 처리하며, 응답 텍스트에 JSON 앞뒤 설명이 섞이면 `{...}` 구간을 복구 추출해 파싱을 시도합니다(복구 불가 시 기존 휴리스틱 폴백). - `ai_planner.py`는 LLM이 move 순서를 놓쳐 `place_entity`/`insert_to_entity`/`set_recipe`가 건설 거리 제한으로 실패하는 경우를 줄이기 위해, 해당 액션 직전에 최근 `move`가 같은 좌표가 아니면 자동으로 `move`를 끼워 넣습니다 - `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다 - `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비거나(또는 JSON 형태가 아니면) `message.reasoning_content`를 우선 사용합니다. 그리고 응답 안에 여러 개의 `{...}`가 섞여 있어도 그중 `actions`를 포함한 계획 객체를 우선 선택합니다. 또한 JSON 파싱 실패가 감지되면 다음 재시도에는 `JSON-only repair` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다. diff --git a/ai_planner.py b/ai_planner.py index 3a9af42..5a06eec 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -116,6 +116,13 @@ state_reader가 상태 요약에 포함하는 `마지막 행동(기억)` 섹션 반드시 아래 JSON 형식으로만 응답하세요. 마크다운이나 설명 텍스트 없이 순수 JSON만 출력하세요. {"thinking": "상황 분석 내용", "current_goal": "현재 목표", "actions": [{"action": "액션명", "params": {...}, "reason": "이유"}], "after_this": "다음 계획"}""" +SYSTEM_PROMPT_COMPACT = """당신은 팩토리오 순수 플레이 AI입니다. +치트 없이 실제 이동/채굴/제작/건설 제약을 지키세요. +출력은 반드시 JSON 객체 1개만: +{"thinking":"...","current_goal":"...","actions":[{"action":"...","params":{},"reason":"..."}],"after_this":"..."} +절대 마크다운/설명문/사고과정 텍스트를 출력하지 마세요. +actions는 1~4개, 가능한 짧고 실행 가능한 계획만 반환하세요.""" + def _debug_enabled() -> bool: v = os.environ.get("AI_DEBUG", "").strip().lower() @@ -123,10 +130,13 @@ def _debug_enabled() -> bool: class AIPlanner: + _last_payload_mode = "auto" + def __init__(self): self.step = 0 self.feedback_log: list[dict] = [] self._fallback_explore_turn = 0 + self._payload_mode_hint = "auto" self.long_term_goal = ( "완전 자동화 달성: " "석탄 채굴 → 철 채굴+제련 자동화 → 구리 채굴+제련 → " @@ -207,6 +217,10 @@ class AIPlanner: ) if resp.status_code < 400: data = resp.json() + if "messages" in payload: + AIPlanner._last_payload_mode = "legacy_chat" + else: + AIPlanner._last_payload_mode = "input_only" break if resp.status_code == 400 and i < len(candidates) - 1: err_msg = self._extract_http_error_message(resp) @@ -230,7 +244,13 @@ class AIPlanner: print(f"[AI] 응답 수신 ({dt:.2f}s, {len(content)}자)") if _debug_enabled(): print(f"[AI][디버그] raw={content[:300]}") - return json.loads(content) + try: + return json.loads(content) + except json.JSONDecodeError: + repaired = self._extract_json_object_text(content) + if repaired is None: + raise + return json.loads(repaired) @staticmethod def _build_chat_payload(user_message: str) -> dict: @@ -251,7 +271,7 @@ class AIPlanner: @staticmethod def _build_input_only_payload(user_message: str) -> dict: - merged = f"{SYSTEM_PROMPT}\n\n{user_message}" + merged = f"{SYSTEM_PROMPT_COMPACT}\n\n{user_message}" return { "model": OLLAMA_MODEL, "input": merged, @@ -261,10 +281,16 @@ class AIPlanner: @staticmethod def _build_payload_candidates(user_message: str) -> list[dict]: # 서버 구현체마다 스키마가 달라 자동 호환을 위해 후보를 순차 시도한다. - return [ + default = [ AIPlanner._build_chat_payload(user_message), AIPlanner._build_input_only_payload(user_message), ] + # 이전 호출에서 성공한 모드를 다음 턴에 우선 사용해 불필요한 400 재시도를 줄인다. + if getattr(AIPlanner, "_last_payload_mode", "auto") == "input_only": + return [default[1], default[0]] + if getattr(AIPlanner, "_last_payload_mode", "auto") == "legacy_chat": + return default + return default @staticmethod def _extract_http_error_message(resp: httpx.Response) -> str: @@ -310,6 +336,10 @@ class AIPlanner: for item in output: if not isinstance(item, dict): continue + # LM Studio Responses 스타일: {"type":"reasoning","content":"..."} + content_str = item.get("content") + if isinstance(content_str, str) and content_str.strip(): + return content_str content_items = item.get("content") if not isinstance(content_items, list): continue @@ -322,6 +352,34 @@ class AIPlanner: raise ValueError(f"응답에서 텍스트 콘텐츠를 찾지 못했습니다. keys={list(data.keys())}") + @staticmethod + def _extract_json_object_text(text: str) -> str | None: + start = text.find("{") + if start < 0: + return None + depth = 0 + in_str = False + esc = False + for i in range(start, len(text)): + ch = text[i] + if in_str: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return text[start:i + 1] + return None + @staticmethod def _ensure_move_before_build_actions(actions: list[dict]) -> list[dict]: """ diff --git a/docs/plan.md b/docs/plan.md index 07d21e1..2d9609e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -33,6 +33,24 @@ --- +## 2026-03-27 LM Studio `output.reasoning` 파싱/잘림 대응 계획 + +### 문제 요약 +- `input-only` 재시도는 성공하지만, 응답이 `output[0].type=reasoning` + `content` 문자열로 돌아와 기존 파서가 텍스트를 추출하지 못함. +- 또한 서버 컨텍스트(4096)에서 장문 추론으로 출력 토큰이 소진되어 JSON 본문이 잘리는 케이스가 발생. + +### 구현 계획 +1. `ai_planner.py`의 input-only payload는 긴 시스템 프롬프트 대신 압축 프롬프트를 사용해 토큰 사용량을 줄인다. +2. 응답 파서를 `output[].content`가 문자열인 경우까지 지원한다. +3. 텍스트에 JSON이 섞여 있을 때 `{...}` 구간을 복구 파싱하는 보조 로직을 추가한다. +4. README에 LM Studio input-only 모드와 파싱/폴백 동작을 명시한다. + +### 검증 계획 +- `python -m py_compile ai_planner.py` +- 실제 실행 로그에서 `응답에서 텍스트 콘텐츠를 찾지 못했습니다`가 사라지는지 확인 + +--- + ## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지) ### 문제