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
|
ore_patch_memory.json
|
||||||
agent_last_action_memory.json
|
agent_last_action_memory.json
|
||||||
.cursor/.worktrees/
|
.cursor/.worktrees/
|
||||||
|
.cursor
|
||||||
@@ -142,6 +142,11 @@ planner.set_goal(
|
|||||||
|
|
||||||
- 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다
|
- 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다
|
||||||
- AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
|
- 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`를 끼워 넣습니다
|
- `ai_planner.py`는 LLM이 move 순서를 놓쳐 `place_entity`/`insert_to_entity`/`set_recipe`가 건설 거리 제한으로 실패하는 경우를 줄이기 위해, 해당 액션 직전에 최근 `move`가 같은 좌표가 아니면 자동으로 `move`를 끼워 넣습니다
|
||||||
- `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다
|
- `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다
|
||||||
- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비거나(또는 JSON 형태가 아니면) `message.reasoning_content`를 우선 사용합니다. 그리고 응답 안에 여러 개의 `{...}`가 섞여 있어도 그중 `actions`를 포함한 계획 객체를 우선 선택합니다. 또한 JSON 파싱 실패가 감지되면 다음 재시도에는 `JSON-only repair` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.
|
- `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.
424
ai_planner.py
424
ai_planner.py
@@ -10,18 +10,49 @@ ai_planner.py — 순수 AI 플레이 버전
|
|||||||
|
|
||||||
LLM 백엔드: 로컬 Ollama (structured output으로 JSON 스키마 강제)
|
LLM 백엔드: 로컬 Ollama (structured output으로 JSON 스키마 강제)
|
||||||
- OLLAMA_HOST: Ollama 서버 주소 (기본값: http://192.168.50.67:11434)
|
- OLLAMA_HOST: Ollama 서버 주소 (기본값: http://192.168.50.67:11434)
|
||||||
- OLLAMA_MODEL: 사용할 모델 (기본값: qwen3:14b)
|
- OLLAMA_MODEL: 사용할 모델 (기본값: qwen3.5:9b)
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
import ollama
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3:14b")
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:9b")
|
||||||
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://192.168.50.67:11434")
|
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 에이전트입니다.
|
SYSTEM_PROMPT = """당신은 팩토리오 게임을 순수하게 플레이하는 AI 에이전트입니다.
|
||||||
@@ -108,7 +139,18 @@ state_reader가 상태 요약에 포함하는 `마지막 행동(기억)` 섹션
|
|||||||
- "start_research" → {"tech": "automation"}
|
- "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:
|
def _debug_enabled() -> bool:
|
||||||
@@ -117,10 +159,13 @@ def _debug_enabled() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
class AIPlanner:
|
class AIPlanner:
|
||||||
|
_last_payload_mode = "auto"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.step = 0
|
self.step = 0
|
||||||
self.feedback_log: list[dict] = []
|
self.feedback_log: list[dict] = []
|
||||||
self._fallback_explore_turn = 0
|
self._fallback_explore_turn = 0
|
||||||
|
self._payload_mode_hint = "auto"
|
||||||
self.long_term_goal = (
|
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:
|
try:
|
||||||
plan = self._call_ollama(user_message)
|
plan = self._call_ollama(user_message)
|
||||||
@@ -151,6 +196,8 @@ class AIPlanner:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
plan = self._fallback_plan_from_summary(state_summary, last_error=str(e))
|
plan = self._fallback_plan_from_summary(state_summary, last_error=str(e))
|
||||||
|
|
||||||
|
plan = self._sanitize_plan(plan)
|
||||||
|
|
||||||
thinking = plan.get("thinking", "")
|
thinking = plan.get("thinking", "")
|
||||||
if thinking:
|
if thinking:
|
||||||
print(f"\n[AI] 판단:\n{thinking}\n")
|
print(f"\n[AI] 판단:\n{thinking}\n")
|
||||||
@@ -177,42 +224,343 @@ class AIPlanner:
|
|||||||
|
|
||||||
def _call_ollama(self, user_message: str) -> dict:
|
def _call_ollama(self, user_message: str) -> dict:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
client = ollama.Client(host=OLLAMA_HOST)
|
stop_event = threading.Event()
|
||||||
response = client.chat(
|
|
||||||
model=OLLAMA_MODEL,
|
def _spinner():
|
||||||
messages=[
|
while not stop_event.is_set():
|
||||||
{"role": "system", "content": SYSTEM_PROMPT},
|
elapsed = time.perf_counter() - t0
|
||||||
{"role": "user", "content": user_message},
|
print(f"\r[AI] 생각 중... {elapsed:.0f}s", end="", flush=True)
|
||||||
],
|
stop_event.wait(1.0)
|
||||||
format={
|
print("\r", end="")
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
spinner = threading.Thread(target=_spinner, daemon=True)
|
||||||
"thinking": {"type": "string"},
|
spinner.start()
|
||||||
"current_goal": {"type": "string"},
|
|
||||||
"actions": {
|
try:
|
||||||
"type": "array",
|
data = None
|
||||||
"items": {
|
last_http_error: Exception | None = None
|
||||||
"type": "object",
|
candidates = self._build_payload_candidates(user_message)
|
||||||
"properties": {
|
for i, payload in enumerate(candidates):
|
||||||
"action": {"type": "string"},
|
resp = httpx.post(
|
||||||
"params": {"type": "object"},
|
f"{OLLAMA_HOST}/api/v1/chat",
|
||||||
"reason": {"type": "string"},
|
json=payload,
|
||||||
},
|
timeout=600.0,
|
||||||
"required": ["action", "params"],
|
)
|
||||||
},
|
if resp.status_code < 400:
|
||||||
},
|
data = resp.json()
|
||||||
"after_this": {"type": "string"},
|
if "messages" in payload:
|
||||||
},
|
AIPlanner._last_payload_mode = "legacy_chat"
|
||||||
"required": ["actions"],
|
else:
|
||||||
},
|
AIPlanner._last_payload_mode = "input_only"
|
||||||
options={"temperature": 0.3},
|
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
|
dt = time.perf_counter() - t0
|
||||||
content = response.message.content
|
content = self._extract_response_content(data)
|
||||||
print(f"[AI] 응답 수신 ({dt:.2f}s, {len(content)}자)")
|
print(f"[AI] 응답 수신 ({dt:.2f}s, {len(content)}자)")
|
||||||
if _debug_enabled():
|
if _debug_enabled():
|
||||||
print(f"[AI][디버그] raw={content[:300]}")
|
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
|
@staticmethod
|
||||||
def _ensure_move_before_build_actions(actions: list[dict]) -> list[dict]:
|
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 반복 설정 제거 (우클릭 유지)
|
## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지)
|
||||||
|
|
||||||
### 문제
|
### 문제
|
||||||
|
|||||||
6
main.py
6
main.py
@@ -15,7 +15,7 @@ import json
|
|||||||
from factorio_rcon import FactorioRCON
|
from factorio_rcon import FactorioRCON
|
||||||
from state_reader import StateReader
|
from state_reader import StateReader
|
||||||
from context_compressor import ContextCompressor
|
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 action_executor import ActionExecutor
|
||||||
from agent_last_action_memory import save_last_action_memory, load_last_action_memory
|
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
|
from ore_patch_memory import load_ore_patch_memory, compute_distance_sq
|
||||||
@@ -137,8 +137,8 @@ def run():
|
|||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(" 팩토리오 순수 AI 에이전트 (치트 없음)")
|
print(" 팩토리오 순수 AI 에이전트 (치트 없음)")
|
||||||
print(" - 실제 걷기 / 실제 채굴 / 실제 제작 / 건설 거리 제한")
|
print(" - 실제 걷기 / 실제 채굴 / 실제 제작 / 건설 거리 제한")
|
||||||
print(f" - LLM: Ollama {os.environ.get('OLLAMA_MODEL', 'qwen3:14b')}")
|
print(f" - LLM: Ollama {OLLAMA_MODEL}")
|
||||||
print(f" - Ollama host: {os.environ.get('OLLAMA_HOST', 'http://192.168.50.67:11434')}")
|
print(f" - Ollama host: {OLLAMA_HOST}")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
with FactorioRCON(RCON_HOST, RCON_PORT, RCON_PASSWORD) as rcon:
|
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