Compare commits

..

17 Commits

Author SHA1 Message Date
21in7
1d0522dc33 feat: standardize parameters for mine_resource action in AIPlanner to improve error handling and compatibility with LM Studio responses 2026-03-27 20:23:22 +09:00
21in7
d9801ee457 feat: implement action aliasing and parameter normalization in AIPlanner to enhance compatibility with LM Studio responses 2026-03-27 20:19:53 +09:00
21in7
66f3a327e8 feat: add action validation and sanitization in AIPlanner to ensure only allowed actions are processed 2026-03-27 20:14:52 +09:00
21in7
8e743d12e7 feat: enhance AIPlanner with compact system prompt and improved JSON parsing for input-only mode responses 2026-03-27 20:10:44 +09:00
21in7
d054f9aee1 feat: implement automatic payload candidate retry mechanism in AIPlanner for improved LM Studio compatibility 2026-03-27 20:07:32 +09:00
21in7
2cf072d38c feat: enhance AIPlanner payload structure for LM Studio compatibility by including 'input' field and improve response content extraction methods 2026-03-27 20:04:18 +09:00
21in7
4b104f2146 chore: update OLLAMA_HOST and API endpoint in AIPlanner, and add .cursor to .gitignore 2026-03-27 19:58:18 +09:00
kswdev0
c93785a809 chore: increase timeout for API requests in AIPlanner to 600 seconds and add local settings for Claude permissions 2026-03-27 14:46:28 +09:00
kswdev0
1b2688d1e1 refactor: update AIPlanner response format to JSON and simplify structure 2026-03-27 11:03:02 +09:00
kswdev0
dabce8b6fb refactor: replace Ollama client with HTTPX for API requests in AIPlanner 2026-03-27 10:59:43 +09:00
kswdev0
3fccbb20eb refactor: remove assistant message placeholder from AIPlanner user message format 2026-03-27 10:55:48 +09:00
kswdev0
f6947d7345 feat: modify user message format and enhance AIPlanner options with increased context size 2026-03-27 10:51:43 +09:00
kswdev0
a4ade0d5c0 fix: update default Ollama model to qwen3.5:9b 2026-03-27 09:39:38 +09:00
kswdev0
fe1f0c1193 feat: add think parameter to AIPlanner options for enhanced configuration 2026-03-27 09:38:44 +09:00
kswdev0
7014b47231 feat: enhance AIPlanner with loading spinner during Ollama requests and update print statements for clarity 2026-03-27 09:26:38 +09:00
kswdev0
82fa73342f fix: update default Ollama model to qwen3.5:9b-nothink 2026-03-27 09:14:13 +09:00
21in7
2d20e729f9 fix: change default model to qwen3.5:9b 2026-03-27 00:07:48 +09:00
8 changed files with 559 additions and 41 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)"
]
}
}

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ inventory_memory.json
ore_patch_memory.json
agent_last_action_memory.json
.cursor/.worktrees/
.cursor

View File

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

View File

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

View File

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

View File

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

View 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()