Compare commits
17 Commits
90a0ada6ff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d0522dc33 | ||
|
|
d9801ee457 | ||
|
|
66f3a327e8 | ||
|
|
8e743d12e7 | ||
|
|
d054f9aee1 | ||
|
|
2cf072d38c | ||
|
|
4b104f2146 | ||
|
|
c93785a809 | ||
|
|
1b2688d1e1 | ||
|
|
dabce8b6fb | ||
|
|
3fccbb20eb | ||
|
|
f6947d7345 | ||
|
|
a4ade0d5c0 | ||
|
|
fe1f0c1193 | ||
|
|
7014b47231 | ||
|
|
82fa73342f | ||
|
|
2d20e729f9 |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:github.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ inventory_memory.json
|
||||
ore_patch_memory.json
|
||||
agent_last_action_memory.json
|
||||
.cursor/.worktrees/
|
||||
.cursor
|
||||
@@ -142,6 +142,11 @@ 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 앞뒤 설명이 섞이면 `{...}` 구간을 복구 추출해 파싱을 시도합니다(복구 불가 시 기존 휴리스틱 폴백).
|
||||
- 파싱된 계획은 `actions` 스키마와 지원 action 화이트리스트를 통과한 경우에만 채택합니다. 유효 액션이 없거나 형식이 깨지면 LLM 출력을 버리고 상태 기반 휴리스틱 폴백으로 전환해 `알 수 없는 행동` 루프를 줄입니다.
|
||||
- LM Studio가 표준과 다른 동의어를 반환할 때(예: `place_building`, `target_x/target_y`, `position_x/position_y`), planner가 표준 스키마(`place_entity`, `x/y`, `name`)로 자동 정규화한 뒤 검증합니다.
|
||||
- 특히 `mine_resource`는 실행기 시그니처에 맞게 `ore`, `count`만 남기도록 강제 정규화합니다. `resource`/`item`은 `ore`로 매핑하고 `tile`/`target` 같은 비표준 키는 제거해 `unexpected keyword argument` 오류를 방지합니다.
|
||||
- `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` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.
|
||||
|
||||
Binary file not shown.
422
ai_planner.py
422
ai_planner.py
@@ -10,18 +10,49 @@ ai_planner.py — 순수 AI 플레이 버전
|
||||
|
||||
LLM 백엔드: 로컬 Ollama (structured output으로 JSON 스키마 강제)
|
||||
- OLLAMA_HOST: Ollama 서버 주소 (기본값: http://192.168.50.67:11434)
|
||||
- OLLAMA_MODEL: 사용할 모델 (기본값: qwen3:14b)
|
||||
- OLLAMA_MODEL: 사용할 모델 (기본값: qwen3.5:9b)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import traceback
|
||||
import ollama
|
||||
import httpx
|
||||
|
||||
|
||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3:14b")
|
||||
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://192.168.50.67:11434")
|
||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:9b")
|
||||
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://192.168.50.61:1234")
|
||||
ALLOWED_ACTIONS = {
|
||||
"explore",
|
||||
"move",
|
||||
"mine_resource",
|
||||
"craft_item",
|
||||
"place_entity",
|
||||
"build_smelting_line",
|
||||
"place_belt_line",
|
||||
"insert_to_entity",
|
||||
"set_recipe",
|
||||
"start_research",
|
||||
"wait",
|
||||
}
|
||||
ACTION_ALIASES = {
|
||||
"place_building": "place_entity",
|
||||
"build_entity": "place_entity",
|
||||
"mine": "mine_resource",
|
||||
"research": "start_research",
|
||||
}
|
||||
ORE_ALIASES = {
|
||||
"iron": "iron-ore",
|
||||
"iron ore": "iron-ore",
|
||||
"iron-ore": "iron-ore",
|
||||
"copper": "copper-ore",
|
||||
"copper ore": "copper-ore",
|
||||
"copper-ore": "copper-ore",
|
||||
"coal": "coal",
|
||||
"stone": "stone",
|
||||
}
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """당신은 팩토리오 게임을 순수하게 플레이하는 AI 에이전트입니다.
|
||||
@@ -108,7 +139,18 @@ state_reader가 상태 요약에 포함하는 `마지막 행동(기억)` 섹션
|
||||
- "start_research" → {"tech": "automation"}
|
||||
|
||||
### 대기
|
||||
- "wait" → {"seconds": int}"""
|
||||
- "wait" → {"seconds": int}
|
||||
|
||||
## 응답 형식
|
||||
반드시 아래 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:
|
||||
@@ -117,10 +159,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 = (
|
||||
"완전 자동화 달성: "
|
||||
"석탄 채굴 → 철 채굴+제련 자동화 → 구리 채굴+제련 → "
|
||||
@@ -141,7 +186,7 @@ class AIPlanner:
|
||||
"⚠️ 제작은 재료가 있어야 합니다. 인벤토리를 확인하세요."
|
||||
)
|
||||
|
||||
print(f"\n[AI] 생각 중... (model={OLLAMA_MODEL}, host={OLLAMA_HOST})")
|
||||
print(f"\n[AI] 요청 시작 (model={OLLAMA_MODEL})")
|
||||
|
||||
try:
|
||||
plan = self._call_ollama(user_message)
|
||||
@@ -151,6 +196,8 @@ class AIPlanner:
|
||||
traceback.print_exc()
|
||||
plan = self._fallback_plan_from_summary(state_summary, last_error=str(e))
|
||||
|
||||
plan = self._sanitize_plan(plan)
|
||||
|
||||
thinking = plan.get("thinking", "")
|
||||
if thinking:
|
||||
print(f"\n[AI] 판단:\n{thinking}\n")
|
||||
@@ -177,42 +224,343 @@ class AIPlanner:
|
||||
|
||||
def _call_ollama(self, user_message: str) -> dict:
|
||||
t0 = time.perf_counter()
|
||||
client = ollama.Client(host=OLLAMA_HOST)
|
||||
response = client.chat(
|
||||
model=OLLAMA_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
format={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"thinking": {"type": "string"},
|
||||
"current_goal": {"type": "string"},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string"},
|
||||
"params": {"type": "object"},
|
||||
"reason": {"type": "string"},
|
||||
},
|
||||
"required": ["action", "params"],
|
||||
},
|
||||
},
|
||||
"after_this": {"type": "string"},
|
||||
},
|
||||
"required": ["actions"],
|
||||
},
|
||||
options={"temperature": 0.3},
|
||||
stop_event = threading.Event()
|
||||
|
||||
def _spinner():
|
||||
while not stop_event.is_set():
|
||||
elapsed = time.perf_counter() - t0
|
||||
print(f"\r[AI] 생각 중... {elapsed:.0f}s", end="", flush=True)
|
||||
stop_event.wait(1.0)
|
||||
print("\r", end="")
|
||||
|
||||
spinner = threading.Thread(target=_spinner, daemon=True)
|
||||
spinner.start()
|
||||
|
||||
try:
|
||||
data = None
|
||||
last_http_error: Exception | None = None
|
||||
candidates = self._build_payload_candidates(user_message)
|
||||
for i, payload in enumerate(candidates):
|
||||
resp = httpx.post(
|
||||
f"{OLLAMA_HOST}/api/v1/chat",
|
||||
json=payload,
|
||||
timeout=600.0,
|
||||
)
|
||||
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)
|
||||
print(f"[AI] payload 호환 재시도 {i + 1}: {err_msg}")
|
||||
continue
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
last_http_error = e
|
||||
break
|
||||
if data is None:
|
||||
if last_http_error is not None:
|
||||
raise last_http_error
|
||||
raise RuntimeError("Ollama/LM Studio 응답을 받지 못했습니다.")
|
||||
finally:
|
||||
stop_event.set()
|
||||
spinner.join()
|
||||
|
||||
dt = time.perf_counter() - t0
|
||||
content = response.message.content
|
||||
content = self._extract_response_content(data)
|
||||
print(f"[AI] 응답 수신 ({dt:.2f}s, {len(content)}자)")
|
||||
if _debug_enabled():
|
||||
print(f"[AI][디버그] raw={content[:300]}")
|
||||
return json.loads(content)
|
||||
plan = self._parse_plan_json(content)
|
||||
if plan is None:
|
||||
raise ValueError("유효한 계획 JSON을 찾지 못했습니다.")
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def _build_chat_payload(user_message: str) -> dict:
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_message},
|
||||
]
|
||||
return {
|
||||
"model": OLLAMA_MODEL,
|
||||
"messages": messages,
|
||||
# LM Studio(OpenAI Responses 호환)에서는 input 필드가 필수인 경우가 있어 함께 보낸다.
|
||||
"input": user_message,
|
||||
"format": "json",
|
||||
"think": False,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.3, "num_ctx": 8192},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_input_only_payload(user_message: str) -> dict:
|
||||
merged = f"{SYSTEM_PROMPT_COMPACT}\n\n{user_message}"
|
||||
return {
|
||||
"model": OLLAMA_MODEL,
|
||||
"input": merged,
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_payload_candidates(user_message: str) -> list[dict]:
|
||||
# 서버 구현체마다 스키마가 달라 자동 호환을 위해 후보를 순차 시도한다.
|
||||
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:
|
||||
try:
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
err = data.get("error")
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message")
|
||||
if isinstance(msg, str) and msg.strip():
|
||||
return msg
|
||||
except Exception:
|
||||
pass
|
||||
text = resp.text.strip()
|
||||
if text:
|
||||
return text[:200]
|
||||
return f"HTTP {resp.status_code}"
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_content(data: dict) -> str:
|
||||
message = data.get("message")
|
||||
if isinstance(message, dict):
|
||||
content = message.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content
|
||||
|
||||
output_text = data.get("output_text")
|
||||
if isinstance(output_text, str) and output_text.strip():
|
||||
return output_text
|
||||
|
||||
choices = data.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
first = choices[0]
|
||||
if isinstance(first, dict):
|
||||
msg = first.get("message")
|
||||
if isinstance(msg, dict):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content
|
||||
|
||||
output = data.get("output")
|
||||
if isinstance(output, list):
|
||||
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
|
||||
for c in content_items:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
text = c.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text
|
||||
|
||||
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 _iter_json_object_texts(text: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(text):
|
||||
start = text.find("{", i)
|
||||
if start < 0:
|
||||
break
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
end = -1
|
||||
for j in range(start, len(text)):
|
||||
ch = text[j]
|
||||
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:
|
||||
end = j
|
||||
break
|
||||
if end >= 0:
|
||||
out.append(text[start:end + 1])
|
||||
i = end + 1
|
||||
else:
|
||||
break
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _parse_plan_json(cls, content: str) -> dict | None:
|
||||
try:
|
||||
obj = json.loads(content)
|
||||
if cls._looks_like_plan(obj):
|
||||
return obj
|
||||
except Exception:
|
||||
pass
|
||||
for obj_text in cls._iter_json_object_texts(content):
|
||||
try:
|
||||
obj = json.loads(obj_text)
|
||||
except Exception:
|
||||
continue
|
||||
if cls._looks_like_plan(obj):
|
||||
return obj
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_plan(obj: object) -> bool:
|
||||
if not isinstance(obj, dict):
|
||||
return False
|
||||
actions = obj.get("actions")
|
||||
return isinstance(actions, list)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_plan(plan: dict) -> dict:
|
||||
if not isinstance(plan, dict):
|
||||
return {"thinking": "", "current_goal": "", "actions": [], "after_this": ""}
|
||||
actions = plan.get("actions")
|
||||
if not isinstance(actions, list):
|
||||
actions = []
|
||||
valid_actions: list[dict] = []
|
||||
for a in actions:
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
act = a.get("action")
|
||||
if not isinstance(act, str):
|
||||
continue
|
||||
act = ACTION_ALIASES.get(act, act)
|
||||
if act not in ALLOWED_ACTIONS:
|
||||
continue
|
||||
params = a.get("params")
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
params = AIPlanner._normalize_params(act, params)
|
||||
reason = a.get("reason")
|
||||
if not isinstance(reason, str):
|
||||
reason = ""
|
||||
valid_actions.append({"action": act, "params": params, "reason": reason})
|
||||
return {
|
||||
"thinking": str(plan.get("thinking", "")),
|
||||
"current_goal": str(plan.get("current_goal", "")),
|
||||
"actions": valid_actions,
|
||||
"after_this": str(plan.get("after_this", "")),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_params(action: str, params: dict) -> dict:
|
||||
p = dict(params)
|
||||
if action == "move":
|
||||
if "x" not in p and "target_x" in p:
|
||||
p["x"] = p.get("target_x")
|
||||
if "y" not in p and "target_y" in p:
|
||||
p["y"] = p.get("target_y")
|
||||
return AIPlanner._keep_allowed_keys(p, {"x", "y"})
|
||||
elif action == "place_entity":
|
||||
if "name" not in p and "item" in p:
|
||||
p["name"] = p.get("item")
|
||||
if "x" not in p and "position_x" in p:
|
||||
p["x"] = p.get("position_x")
|
||||
if "y" not in p and "position_y" in p:
|
||||
p["y"] = p.get("position_y")
|
||||
return AIPlanner._keep_allowed_keys(p, {"name", "x", "y", "direction"})
|
||||
elif action == "mine_resource":
|
||||
if "ore" not in p:
|
||||
if "resource" in p:
|
||||
p["ore"] = p.get("resource")
|
||||
elif "item" in p:
|
||||
p["ore"] = p.get("item")
|
||||
ore = AIPlanner._normalize_ore_name(p.get("ore", "iron-ore"))
|
||||
try:
|
||||
count = int(p.get("count", 35))
|
||||
except Exception:
|
||||
count = 35
|
||||
if count <= 0:
|
||||
count = 35
|
||||
return {"ore": ore, "count": count}
|
||||
elif action == "craft_item":
|
||||
return AIPlanner._keep_allowed_keys(p, {"item", "count"})
|
||||
elif action == "explore":
|
||||
return AIPlanner._keep_allowed_keys(p, {"direction", "max_steps", "wanted_ores"})
|
||||
elif action == "build_smelting_line":
|
||||
return AIPlanner._keep_allowed_keys(p, {"ore", "x", "y", "furnace_count"})
|
||||
elif action == "place_belt_line":
|
||||
return AIPlanner._keep_allowed_keys(p, {"from_x", "from_y", "to_x", "to_y"})
|
||||
elif action == "insert_to_entity":
|
||||
return AIPlanner._keep_allowed_keys(p, {"x", "y", "item", "count"})
|
||||
elif action == "set_recipe":
|
||||
return AIPlanner._keep_allowed_keys(p, {"x", "y", "recipe"})
|
||||
elif action == "start_research":
|
||||
return AIPlanner._keep_allowed_keys(p, {"tech"})
|
||||
elif action == "wait":
|
||||
return AIPlanner._keep_allowed_keys(p, {"seconds"})
|
||||
return p
|
||||
|
||||
@staticmethod
|
||||
def _keep_allowed_keys(params: dict, allowed: set[str]) -> dict:
|
||||
return {k: v for k, v in params.items() if k in allowed}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ore_name(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
return "iron-ore"
|
||||
key = value.strip().lower()
|
||||
return ORE_ALIASES.get(key, "iron-ore")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_move_before_build_actions(actions: list[dict]) -> list[dict]:
|
||||
|
||||
106
docs/plan.md
106
docs/plan.md
@@ -1,3 +1,109 @@
|
||||
## 2026-03-27 LM Studio `input required` 오류 수정 계획
|
||||
|
||||
### 문제 요약
|
||||
- LM Studio 서버로 `POST /api/v1/chat` 호출 시 `{"error":{"message":"'input' is required"}}` 400이 발생.
|
||||
- 현재 `ai_planner.py`는 `messages` 기반 payload만 전송하고 있어, `input` 기반 스키마를 요구하는 서버와 비호환.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py` 요청 payload를 함수로 분리해 `messages` + `input`을 함께 포함하도록 수정한다.
|
||||
2. 응답 파싱을 LM Studio/OpenAI 호환 형태(`message.content` 우선, `output_text` 대안)까지 처리한다.
|
||||
3. 테스트를 먼저 추가해 payload에 `input`이 포함되는지, 응답 추출이 대체 경로에서 동작하는지 검증한다.
|
||||
4. 문서(`README.md`)에 LM Studio 호환 동작을 반영한다.
|
||||
|
||||
### 검증 계획
|
||||
- `pytest tests/test_ai_planner_lmstudio_compat.py -q`
|
||||
- 필요 시 전체 관련 테스트 재실행
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 LM Studio `unrecognized_keys` 400 추가 수정 계획
|
||||
|
||||
### 문제 요약
|
||||
- `input`을 추가한 뒤에도 LM Studio 서버가 `messages`, `format`, `think`, `options` 키를 거부하며 400을 반환.
|
||||
- 즉, 서버 모드에 따라 허용 스키마가 다르며 단일 payload로는 호환이 불안정.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py`에서 payload를 단일 형태로 고정하지 않고, `legacy chat`와 `input-only` 후보를 순차 시도한다.
|
||||
2. 400 응답일 때 에러 메시지를 파싱해 다음 후보 payload로 자동 재시도한다.
|
||||
3. README 설명을 “messages+input 동시 전송”에서 “서버 스키마 자동 호환 재시도”로 정정한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- LM Studio 로그에서 `unrecognized_keys`가 사라지고 정상 응답으로 전환되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
- 실제 실행 로그에서 `응답에서 텍스트 콘텐츠를 찾지 못했습니다`가 사라지는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 LM Studio 비JSON/잘못된 action 방어 계획
|
||||
|
||||
### 문제 요약
|
||||
- LM Studio가 reasoning 텍스트를 길게 반환하면, JSON 복구가 불완전해 임의 객체가 계획으로 채택될 수 있음.
|
||||
- 그 결과 executor에서 `알 수 없는 행동` 실패가 발생해 루프가 생김.
|
||||
|
||||
### 구현 계획
|
||||
1. 응답 텍스트에서 `{...}` 후보를 모두 스캔해 `actions`를 포함한 계획 객체만 채택한다.
|
||||
2. 액션명을 화이트리스트로 검증해 지원되지 않는 action은 제거한다.
|
||||
3. 유효 액션이 0개면 LLM 결과를 버리고 즉시 상태 기반 휴리스틱 폴백으로 전환한다.
|
||||
4. README에 “유효 계획 검증 후 채택” 동작을 반영한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- 런타임 로그에서 `알 수 없는 행동` 반복이 사라지는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 LM Studio action/params 동의어 정규화 계획
|
||||
|
||||
### 문제 요약
|
||||
- LM Studio가 JSON은 지키지만 프로젝트 스키마와 다른 action/params를 반환함.
|
||||
- 예: `place_building` vs `place_entity`, `target_x`/`target_y`, `position_x`/`position_y`.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py`에서 action alias를 표준 action으로 매핑한다.
|
||||
2. params alias를 표준 키로 정규화한다.
|
||||
3. 정규화 후 화이트리스트 검증을 적용해 실행 가능한 액션만 남긴다.
|
||||
4. README에 동의어 자동 정규화 동작을 반영한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- 런타임에서 `move`/`place_entity`로 정상 변환되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 mine_resource 파라미터 강제 표준화 계획
|
||||
|
||||
### 문제 요약
|
||||
- LM Studio가 `mine_resource`에 `tile`, `target`, `resource` 같은 비표준 키를 보내면서 실행기(`mine_resource(ore, count)`)에서 TypeError 발생.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py`에서 `mine_resource`는 `ore`, `count`만 남기도록 필터링한다.
|
||||
2. `resource`/`item` 동의어를 `ore`로 매핑하고, `iron`/`copper` 같은 축약값도 정규 ore 이름으로 보정한다.
|
||||
3. `count`가 없거나 비정상이면 기본값(35)으로 보정한다.
|
||||
4. README에 `mine_resource` 파라미터 표준화 동작을 반영한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- 런타임에서 `unexpected keyword argument 'tile'/'target'`가 사라지는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지)
|
||||
|
||||
### 문제
|
||||
|
||||
6
main.py
6
main.py
@@ -15,7 +15,7 @@ import json
|
||||
from factorio_rcon import FactorioRCON
|
||||
from state_reader import StateReader
|
||||
from context_compressor import ContextCompressor
|
||||
from ai_planner import AIPlanner
|
||||
from ai_planner import AIPlanner, OLLAMA_MODEL, OLLAMA_HOST
|
||||
from action_executor import ActionExecutor
|
||||
from agent_last_action_memory import save_last_action_memory, load_last_action_memory
|
||||
from ore_patch_memory import load_ore_patch_memory, compute_distance_sq
|
||||
@@ -137,8 +137,8 @@ def run():
|
||||
print("=" * 60)
|
||||
print(" 팩토리오 순수 AI 에이전트 (치트 없음)")
|
||||
print(" - 실제 걷기 / 실제 채굴 / 실제 제작 / 건설 거리 제한")
|
||||
print(f" - LLM: Ollama {os.environ.get('OLLAMA_MODEL', 'qwen3:14b')}")
|
||||
print(f" - Ollama host: {os.environ.get('OLLAMA_HOST', 'http://192.168.50.67:11434')}")
|
||||
print(f" - LLM: Ollama {OLLAMA_MODEL}")
|
||||
print(f" - Ollama host: {OLLAMA_HOST}")
|
||||
print("=" * 60)
|
||||
|
||||
with FactorioRCON(RCON_HOST, RCON_PORT, RCON_PASSWORD) as rcon:
|
||||
|
||||
51
tests/test_ai_planner_lmstudio_compat.py
Normal file
51
tests/test_ai_planner_lmstudio_compat.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
|
||||
from ai_planner import AIPlanner
|
||||
|
||||
|
||||
class TestAIPlannerLMStudioCompat(unittest.TestCase):
|
||||
def test_build_chat_payload_has_legacy_chat_keys(self):
|
||||
payload = AIPlanner._build_chat_payload("hello")
|
||||
|
||||
self.assertIn("messages", payload)
|
||||
self.assertIn("options", payload)
|
||||
self.assertEqual(payload["messages"][1]["content"], "hello")
|
||||
|
||||
def test_build_input_only_payload_has_minimal_keys(self):
|
||||
payload = AIPlanner._build_input_only_payload("hello")
|
||||
|
||||
self.assertIn("input", payload)
|
||||
self.assertNotIn("messages", payload)
|
||||
self.assertEqual(payload["stream"], False)
|
||||
self.assertIn("hello", payload["input"])
|
||||
|
||||
def test_payload_candidates_order(self):
|
||||
payloads = AIPlanner._build_payload_candidates("hello")
|
||||
self.assertEqual(len(payloads), 2)
|
||||
self.assertIn("messages", payloads[0])
|
||||
self.assertIn("input", payloads[1])
|
||||
self.assertNotIn("messages", payloads[1])
|
||||
|
||||
def test_extract_response_content_supports_message_content(self):
|
||||
data = {
|
||||
"message": {
|
||||
"content": (
|
||||
'{"thinking":"t","current_goal":"g","actions":[],"after_this":"a"}'
|
||||
)
|
||||
}
|
||||
}
|
||||
content = AIPlanner._extract_response_content(data)
|
||||
self.assertIn('"current_goal":"g"', content)
|
||||
|
||||
def test_extract_response_content_supports_output_text(self):
|
||||
data = {
|
||||
"output_text": (
|
||||
'{"thinking":"t","current_goal":"g","actions":[],"after_this":"a"}'
|
||||
)
|
||||
}
|
||||
content = AIPlanner._extract_response_content(data)
|
||||
self.assertIn('"after_this":"a"', content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user