feat: enhance AIPlanner payload structure for LM Studio compatibility by including 'input' field and improve response content extraction methods

This commit is contained in:
21in7
2026-03-27 20:04:18 +09:00
parent 4b104f2146
commit 2cf072d38c
4 changed files with 114 additions and 12 deletions

View File

@@ -142,6 +142,7 @@ planner.set_goal(
- 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다
- AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
- `ai_planner.py`의 Ollama/LM Studio 호출 payload는 `messages`와 `input`을 함께 전송해, 서버가 `input` 필드를 요구하는 OpenAI Responses 호환 모드에서도 400(`'input' is required`)을 피하도록 했습니다.
- `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

@@ -196,17 +196,7 @@ class AIPlanner:
spinner.start()
try:
payload = {
"model": OLLAMA_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
],
"format": "json",
"think": False,
"stream": False,
"options": {"temperature": 0.3, "num_ctx": 8192},
}
payload = self._build_chat_payload(user_message)
resp = httpx.post(
f"{OLLAMA_HOST}/api/v1/chat",
json=payload,
@@ -219,12 +209,68 @@ class AIPlanner:
spinner.join()
dt = time.perf_counter() - t0
content = data["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)
@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 _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
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 _ensure_move_before_build_actions(actions: list[dict]) -> list[dict]:
"""

View File

@@ -1,3 +1,21 @@
## 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`
- 필요 시 전체 관련 테스트 재실행
---
## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지)
### 문제

View File

@@ -0,0 +1,37 @@
import unittest
from ai_planner import AIPlanner
class TestAIPlannerLMStudioCompat(unittest.TestCase):
def test_build_chat_payload_includes_input_for_responses_compat(self):
payload = AIPlanner._build_chat_payload("hello")
self.assertIn("messages", payload)
self.assertIn("input", payload)
self.assertEqual(payload["input"], "hello")
self.assertEqual(payload["messages"][1]["content"], "hello")
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()