feat: mining_state 설정 최적화 및 GLM 예외 처리 개선
- `mine_resource` 함수에서 채굴 상태를 매 루프마다 반복 설정하던 로직을 제거하여, 우클릭 유지와 유사한 동작으로 최적화 - GLM API 호출 실패 시 예외 처리 로직을 개선하여, 다양한 오류 원인을 로그로 출력하고 상태 요약 기반의 폴백 로직 추가 - 새로운 연결 검사 스크립트 추가 및 README.md에 GLM API 연결 문제 디버깅 섹션 추가 - 관련 문서 및 주의사항 업데이트
This commit is contained in:
215
ai_planner.py
215
ai_planner.py
@@ -18,6 +18,7 @@ import re
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import traceback
|
||||
|
||||
|
||||
GLM_API_URL = "https://api.z.ai/api/coding/paas/v4/chat/completions"
|
||||
@@ -118,6 +119,56 @@ state_reader가 상태 요약에 포함하는 `마지막 행동(기억)` 섹션
|
||||
## 절대 중요: 순수 JSON만 출력하세요. ```json 같은 마크다운 블록, 설명 텍스트, 주석 없이 오직 { } 만."""
|
||||
|
||||
|
||||
def _glm_debug_enabled() -> bool:
|
||||
v = os.environ.get("GLM_DEBUG", "").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def describe_glm_exception(exc: BaseException) -> str:
|
||||
"""
|
||||
GLM HTTP 호출 실패 시 원인 분류용 문자열.
|
||||
- URLError.reason 안의 SSL/소켓 예외, errno 등을 함께 적어
|
||||
'TimeoutError' 한 줄만으로는 알 수 없는 정보를 남긴다.
|
||||
"""
|
||||
parts: list[str] = [type(exc).__name__]
|
||||
msg = str(exc).strip()
|
||||
if msg:
|
||||
parts.append(msg[:800])
|
||||
|
||||
if isinstance(exc, urllib.error.URLError):
|
||||
r = exc.reason
|
||||
parts.append(f"URLError.reason={type(r).__name__}: {r!s}"[:500])
|
||||
if isinstance(r, OSError):
|
||||
parts.append(f"reason.errno={getattr(r, 'errno', None)!r}")
|
||||
we = getattr(r, "winerror", None)
|
||||
if we is not None:
|
||||
parts.append(f"reason.winerror={we!r}")
|
||||
inner = getattr(r, "__cause__", None) or getattr(r, "__context__", None)
|
||||
if inner is not None:
|
||||
parts.append(f"chained={type(inner).__name__}: {inner!s}"[:300])
|
||||
|
||||
if isinstance(exc, OSError) and not isinstance(exc, urllib.error.URLError):
|
||||
parts.append(f"errno={exc.errno!r}")
|
||||
if getattr(exc, "winerror", None) is not None:
|
||||
parts.append(f"winerror={exc.winerror!r}")
|
||||
|
||||
# 힌트(자주 나오는 케이스)
|
||||
low = " ".join(parts).lower()
|
||||
if "timed out" in low or "timeout" in low:
|
||||
parts.append(
|
||||
"hint=응답 수신 지연/차단 — GLM_HTTP_TIMEOUT_SECONDS 증가, "
|
||||
"서버·Docker 네트워크 egress, 프록시(HTTPS_PROXY) 확인"
|
||||
)
|
||||
if "connection refused" in low or "econnrefused" in low:
|
||||
parts.append("hint=대상 포트 닫힘 또는 잘못된 호스트(프록시/방화벽)")
|
||||
if "certificate" in low or "ssl" in low or "tls" in low:
|
||||
parts.append("hint=SSL 검증 실패 — 기업 프록시/자체 CA, 시스템 시간 오차")
|
||||
if "name or service not known" in low or "getaddrinfo" in low:
|
||||
parts.append("hint=DNS 실패 — 컨테이너/호스트 resolv.conf")
|
||||
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
class AIPlanner:
|
||||
def __init__(self):
|
||||
self.api_key = os.environ.get("ZAI_API_KEY", "")
|
||||
@@ -126,6 +177,8 @@ class AIPlanner:
|
||||
|
||||
self.step = 0
|
||||
self.feedback_log: list[dict] = []
|
||||
# GLM 전부 실패 시 explore 방향 순환 (동일 방향 탐색 루프 완화)
|
||||
self._fallback_explore_turn = 0
|
||||
self.long_term_goal = (
|
||||
"완전 자동화 달성: "
|
||||
"석탄 채굴 → 철 채굴+제련 자동화 → 구리 채굴+제련 → "
|
||||
@@ -154,23 +207,33 @@ class AIPlanner:
|
||||
raw = self._call_glm(user_message, attempt=attempt)
|
||||
plan = self._parse_json(raw)
|
||||
break
|
||||
except (ValueError, json.JSONDecodeError, TimeoutError, ConnectionError, urllib.error.URLError) as e:
|
||||
except (
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
TimeoutError,
|
||||
ConnectionError,
|
||||
urllib.error.URLError,
|
||||
OSError,
|
||||
) as e:
|
||||
detail = describe_glm_exception(e)
|
||||
if attempt < 2:
|
||||
print(
|
||||
f"[경고] GLM 처리 실패 (시도 {attempt+1}/3): "
|
||||
f"{type(e).__name__} 재시도..."
|
||||
)
|
||||
time.sleep(1 + attempt * 2)
|
||||
print(f" [GLM 원인] {detail}")
|
||||
if _glm_debug_enabled():
|
||||
traceback.print_exc()
|
||||
time.sleep(2 + attempt * 3)
|
||||
continue
|
||||
print(f"[오류] GLM 처리 3회 실패. 기본 탐색 행동 사용.")
|
||||
plan = {
|
||||
"thinking": "API 응답 파싱 실패로 기본 탐색 수행",
|
||||
"current_goal": "주변 탐색",
|
||||
"actions": [
|
||||
{"action": "explore", "params": {"direction": "east", "max_steps": 200}, "reason": "자원 탐색"},
|
||||
],
|
||||
"after_this": "자원 발견 후 채굴 시작"
|
||||
}
|
||||
print(f"[오류] GLM 처리 3회 실패. 상태 요약 기반 휴리스틱 폴백 사용.")
|
||||
print(f" [GLM 원인] {detail}")
|
||||
if _glm_debug_enabled():
|
||||
traceback.print_exc()
|
||||
plan = self._fallback_plan_from_summary(
|
||||
state_summary,
|
||||
last_error=detail,
|
||||
)
|
||||
|
||||
thinking = plan.get("thinking", "")
|
||||
if thinking:
|
||||
@@ -220,6 +283,14 @@ class AIPlanner:
|
||||
prompt_chars = len(user_message)
|
||||
system_chars = len(SYSTEM_PROMPT)
|
||||
max_tokens = 2000
|
||||
http_timeout = float(os.environ.get("GLM_HTTP_TIMEOUT_SECONDS", "120"))
|
||||
|
||||
if _glm_debug_enabled():
|
||||
print(
|
||||
f"[GLM][디버그] POST {GLM_API_URL} | "
|
||||
f"timeout={http_timeout}s | payload_bytes={len(payload)} | "
|
||||
f"model={GLM_MODEL}"
|
||||
)
|
||||
|
||||
req = urllib.request.Request(
|
||||
GLM_API_URL,
|
||||
@@ -235,7 +306,7 @@ class AIPlanner:
|
||||
t_payload0 = time.perf_counter()
|
||||
# payload 직렬화 직후(대략)부터 타임라인 측정
|
||||
_t0 = time.perf_counter()
|
||||
with urllib.request.urlopen(req, timeout=90) as resp:
|
||||
with urllib.request.urlopen(req, timeout=http_timeout) as resp:
|
||||
raw_text = resp.read().decode("utf-8")
|
||||
t_read_done = time.perf_counter()
|
||||
|
||||
@@ -262,7 +333,14 @@ class AIPlanner:
|
||||
)
|
||||
return content
|
||||
except urllib.error.HTTPError as e:
|
||||
raise ConnectionError(f"GLM API 오류 {e.code}: {e.read().decode()}")
|
||||
body = ""
|
||||
try:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = ""
|
||||
raise ConnectionError(
|
||||
f"GLM API HTTP {e.code}: {body[:1200]}"
|
||||
) from e
|
||||
|
||||
def _parse_json(self, raw: str) -> dict:
|
||||
text = raw.strip()
|
||||
@@ -354,3 +432,114 @@ class AIPlanner:
|
||||
self.long_term_goal = goal
|
||||
self.feedback_log.clear()
|
||||
print(f"[AI] 새 목표: {goal}")
|
||||
|
||||
def _fallback_plan_from_summary(self, state_summary: str, last_error: str = "") -> dict:
|
||||
"""
|
||||
GLM 실패 시에도 상태 요약(주변 패치·기억된 광맥 좌표)이 있으면
|
||||
무한 explore 루프 대신 mine_resource / move 를 선택한다.
|
||||
"""
|
||||
pos = self._parse_player_position(state_summary)
|
||||
if pos is None:
|
||||
px = py = None
|
||||
else:
|
||||
px, py = pos
|
||||
anchors = self._parse_ore_anchors(state_summary)
|
||||
|
||||
ore_priority = {"iron-ore": 0, "copper-ore": 1, "coal": 2, "stone": 3}
|
||||
best: tuple[str, int, int, float] | None = None
|
||||
best_key: tuple | None = None
|
||||
|
||||
for ore, ox, oy in anchors:
|
||||
if px is not None and py is not None:
|
||||
dist = ((ox - px) ** 2 + (oy - py) ** 2) ** 0.5
|
||||
else:
|
||||
dist = 0.0
|
||||
key = (ore_priority.get(ore, 9), dist)
|
||||
if best_key is None or key < best_key:
|
||||
best_key = key
|
||||
best = (ore, ox, oy, dist)
|
||||
|
||||
if best is not None:
|
||||
ore, ox, oy, dist = best
|
||||
move_threshold = float(os.environ.get("GLM_FALLBACK_MOVE_THRESHOLD", "200"))
|
||||
actions: list[dict] = []
|
||||
if px is not None and py is not None and dist > move_threshold:
|
||||
actions.append({
|
||||
"action": "move",
|
||||
"params": {"x": ox, "y": oy},
|
||||
"reason": f"GLM 폴백: 광맥까지 약 {dist:.0f}타일 — 먼저 이동",
|
||||
})
|
||||
actions.append({
|
||||
"action": "mine_resource",
|
||||
"params": {"ore": ore, "count": 35},
|
||||
"reason": "GLM 폴백: 상태에 표시된 인근 광맥 채굴",
|
||||
})
|
||||
err_note = f" ({last_error})" if last_error else ""
|
||||
return {
|
||||
"thinking": (
|
||||
f"GLM API를 사용할 수 없어 상태 요약의 광맥({ore}, 앵커 {ox},{oy})으로 "
|
||||
f"채굴을 시도합니다.{err_note}"
|
||||
),
|
||||
"current_goal": f"{ore} 채굴 (휴리스틱)",
|
||||
"actions": actions,
|
||||
"after_this": "GLM 복구 시 정상 계획으로 복귀",
|
||||
}
|
||||
|
||||
dirs = [
|
||||
"east", "north", "south", "west",
|
||||
"northeast", "northwest", "southeast", "southwest",
|
||||
]
|
||||
self._fallback_explore_turn += 1
|
||||
direction = dirs[(self._fallback_explore_turn - 1) % len(dirs)]
|
||||
err_note = f" ({last_error})" if last_error else ""
|
||||
return {
|
||||
"thinking": (
|
||||
f"GLM API 실패이며 상태 요약에서 광맥 좌표를 찾지 못해 "
|
||||
f"{direction} 방향으로 탐색합니다.{err_note}"
|
||||
),
|
||||
"current_goal": "주변 탐색 (휴리스틱)",
|
||||
"actions": [
|
||||
{
|
||||
"action": "explore",
|
||||
"params": {"direction": direction, "max_steps": 200},
|
||||
"reason": "GLM 폴백: 광맥 정보 없음 — 탐색",
|
||||
},
|
||||
],
|
||||
"after_this": "자원 발견 후 채굴",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_player_position(text: str) -> tuple[float, float] | None:
|
||||
m = re.search(
|
||||
r"-\s*위치:\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)",
|
||||
text,
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
return float(m.group(1)), float(m.group(2))
|
||||
|
||||
@staticmethod
|
||||
def _parse_ore_anchors(text: str) -> list[tuple[str, int, int]]:
|
||||
"""주변 스캔 '앵커' 좌표와 기억된 광맥 (tile_x, tile_y) 파싱."""
|
||||
seen: set[tuple[str, int, int]] = set()
|
||||
out: list[tuple[str, int, int]] = []
|
||||
|
||||
for m in re.finditer(
|
||||
r"-\s*(iron-ore|copper-ore|coal|stone):\s*\d+타일\s*\(\s*앵커:\s*(-?\d+)\s*,\s*(-?\d+)\s*\)",
|
||||
text,
|
||||
):
|
||||
t = (m.group(1), int(m.group(2)), int(m.group(3)))
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
|
||||
for m in re.finditer(
|
||||
r"-\s*(iron-ore|copper-ore|coal|stone):\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)",
|
||||
text,
|
||||
):
|
||||
t = (m.group(1), int(m.group(2)), int(m.group(3)))
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user