feat: enhance AIPlanner payload structure for LM Studio compatibility by including 'input' field and improve response content extraction methods
This commit is contained in:
@@ -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` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
18
docs/plan.md
18
docs/plan.md
@@ -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 반복 설정 제거 (우클릭 유지)
|
||||
|
||||
### 문제
|
||||
|
||||
37
tests/test_ai_planner_lmstudio_compat.py
Normal file
37
tests/test_ai_planner_lmstudio_compat.py
Normal 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()
|
||||
Reference in New Issue
Block a user