feat: enhance AIPlanner with compact system prompt and improved JSON parsing for input-only mode responses
This commit is contained in:
@@ -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` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
18
docs/plan.md
18
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 반복 설정 제거 (우클릭 유지)
|
||||
|
||||
### 문제
|
||||
|
||||
Reference in New Issue
Block a user