feat: enhance AIPlanner with compact system prompt and improved JSON parsing for input-only mode responses

This commit is contained in:
21in7
2026-03-27 20:10:44 +09:00
parent d054f9aee1
commit 8e743d12e7
3 changed files with 80 additions and 3 deletions

View File

@@ -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` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.

View File

@@ -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]:
"""

View File

@@ -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 반복 설정 제거 (우클릭 유지)
### 문제