feat: add action validation and sanitization in AIPlanner to ensure only allowed actions are processed
This commit is contained in:
@@ -144,6 +144,7 @@ planner.set_goal(
|
|||||||
- AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
|
- AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
|
||||||
- `ai_planner.py`의 Ollama/LM Studio 호출은 서버 스키마 차이를 자동으로 맞추기 위해 payload 후보를 순차 재시도합니다. 먼저 `messages` 기반(legacy chat)으로 시도하고, 400(`unrecognized_keys`/`input required`)이면 `input` 중심 최소 payload로 재시도합니다.
|
- `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 앞뒤 설명이 섞이면 `{...}` 구간을 복구 추출해 파싱을 시도합니다(복구 불가 시 기존 휴리스틱 폴백).
|
- LM Studio `input-only` 모드에서 `output[].type=reasoning` 형태로 응답이 오는 경우도 파서가 처리하며, 응답 텍스트에 JSON 앞뒤 설명이 섞이면 `{...}` 구간을 복구 추출해 파싱을 시도합니다(복구 불가 시 기존 휴리스틱 폴백).
|
||||||
|
- 파싱된 계획은 `actions` 스키마와 지원 action 화이트리스트를 통과한 경우에만 채택합니다. 유효 액션이 없거나 형식이 깨지면 LLM 출력을 버리고 상태 기반 휴리스틱 폴백으로 전환해 `알 수 없는 행동` 루프를 줄입니다.
|
||||||
- `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` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.
|
||||||
|
|||||||
116
ai_planner.py
116
ai_planner.py
@@ -24,6 +24,19 @@ import httpx
|
|||||||
|
|
||||||
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:9b")
|
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:9b")
|
||||||
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://192.168.50.61:1234")
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """당신은 팩토리오 게임을 순수하게 플레이하는 AI 에이전트입니다.
|
SYSTEM_PROMPT = """당신은 팩토리오 게임을 순수하게 플레이하는 AI 에이전트입니다.
|
||||||
@@ -167,6 +180,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")
|
||||||
@@ -244,13 +259,10 @@ class AIPlanner:
|
|||||||
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]}")
|
||||||
try:
|
plan = self._parse_plan_json(content)
|
||||||
return json.loads(content)
|
if plan is None:
|
||||||
except json.JSONDecodeError:
|
raise ValueError("유효한 계획 JSON을 찾지 못했습니다.")
|
||||||
repaired = self._extract_json_object_text(content)
|
return plan
|
||||||
if repaired is None:
|
|
||||||
raise
|
|
||||||
return json.loads(repaired)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_chat_payload(user_message: str) -> dict:
|
def _build_chat_payload(user_message: str) -> dict:
|
||||||
@@ -380,6 +392,96 @@ class AIPlanner:
|
|||||||
return text[start:i + 1]
|
return text[start:i + 1]
|
||||||
return None
|
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) or act not in ALLOWED_ACTIONS:
|
||||||
|
continue
|
||||||
|
params = a.get("params")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
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
|
@staticmethod
|
||||||
def _ensure_move_before_build_actions(actions: list[dict]) -> list[dict]:
|
def _ensure_move_before_build_actions(actions: list[dict]) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
18
docs/plan.md
18
docs/plan.md
@@ -51,6 +51,24 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
- 런타임 로그에서 `알 수 없는 행동` 반복이 사라지는지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지)
|
## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지)
|
||||||
|
|
||||||
### 문제
|
### 문제
|
||||||
|
|||||||
Reference in New Issue
Block a user