fix: improve OpenAI-compatible LLM fallback and per-player round replies

This commit is contained in:
leeexin committed 2026-03-08 17:52:05 +08:00
1 parent e474e137ea
commit 22b90c4a6d
3 files changed
+196 -8

No files matched your search

+15 -2
View File
@@ -40,6 +40,16 @@ def build_system_prompt() -> str:
def build_user_prompt(context: dict[str, Any]) -> str:
story_context = context.get("story_context", {})
allowed_clues = story_context.get("allowed_clues", [])
players = context.get("players", [])
if not isinstance(players, list):
players = []
player_names = []
for row in players:
if not isinstance(row, dict):
continue
name = str(row.get("username") or row.get("name") or row.get("player_id") or "").strip()
if name:
player_names.append(name)
clue_rule = "关键线索只可从允许列表中新增"
if allowed_clues == "ANY_STRING":
clue_rule = "线索字段可新增任意短文本,但必须与当前游戏卡世界观一致"
@@ -47,7 +57,9 @@ def build_user_prompt(context: dict[str, Any]) -> str:
payload = {
"story_context": story_context,
"game_state": context.get("game_state", {}),
"players": context.get("players", []),
"players": players,
"player_count": len(players),
"player_usernames": player_names,
"merged_actions": context.get("merged_actions", {}),
"schema": SCHEMA_DESCRIPTION,
"constraints": [
@@ -60,7 +72,8 @@ def build_user_prompt(context: dict[str, Any]) -> str:
"如果行动描述是“使用/消耗/投掷/服用/注射某道具”,且结果生效,请在 inventory_remove 删除该道具",
"自定义卡会按 content_pack.end_conditions 由服务端判定结局",
"choices 最多 4 条",
"private_player_updates 可为空",
"private_player_updates 需要覆盖所有玩家,每个 player_id 至少 1 条 message",
"private_player_updates 中的 player_id 必须来自 players 列表",
"如果故事未满足结局条件,应设置 should_end_game=false",
],
}
+56 -1
View File
@@ -79,6 +79,48 @@ class RoomManager:
return f"{text[:417]}..."
return text
def _ensure_private_updates_for_all_players(
self,
ai_payload: dict[str, Any],
players: list[dict[str, Any]],
actions: list[PlayerAction],
) -> None:
updates_raw = ai_payload.get("private_player_updates", [])
updates = updates_raw if isinstance(updates_raw, list) else []
by_player: dict[str, str] = {}
for row in updates:
if not isinstance(row, dict):
continue
player_id = str(row.get("player_id", "")).strip()
message = " ".join(str(row.get("message", "")).strip().split())
if not player_id or not message:
continue
if player_id not in by_player:
by_player[player_id] = message[:220]
action_by_player: dict[str, PlayerAction] = {row.player_id: row for row in actions}
for player in players:
player_id = str(player.get("player_id", "")).strip()
if not player_id:
continue
if player_id in by_player:
continue
action = action_by_player.get(player_id)
player_name = str(player.get("name") or player_id).strip()
if action:
action_text = " ".join(str(action.content or "").strip().split())
if len(action_text) > 56:
action_text = f"{action_text[:56]}..."
by_player[player_id] = f"{player_name},你的行动“{action_text}”已计入结算,请根据新线索继续推进。"
else:
by_player[player_id] = f"{player_name},本轮未记录到你的行动,下一轮请尽快提交你的选择。"
ai_payload["private_player_updates"] = [
{"player_id": player_id, "message": message}
for player_id, message in by_player.items()
]
def _ensure_unique_name(self, name: str, existing_names: set[str]) -> str:
if name not in existing_names:
return name
@@ -473,11 +515,23 @@ class RoomManager:
try:
merge_result = self.action_resolver.merge_actions(actions, room_id, int(state.get("round_no", 0)) + 1)
user_cache: dict[str, dict[str, Any]] = {}
for player in players:
user_id = str(player.get("user_id") or "")
if not user_id or user_id in user_cache:
continue
user_cache[user_id] = self.persistence.get_user_by_id(user_id) or {}
prompt_context = {
"story_context": self.story_engine.story_context(state.get("scene_id", "lobby"), state),
"game_state": state,
"players": [
{"player_id": player["player_id"], "name": player["name"], "is_online": player["is_online"]}
{
"player_id": player["player_id"],
"name": player["name"],
"username": user_cache.get(str(player.get("user_id") or ""), {}).get("username", ""),
"display_name": user_cache.get(str(player.get("user_id") or ""), {}).get("display_name", ""),
"is_online": player["is_online"],
}
for player in players
],
"merged_actions": merge_result,
@@ -512,6 +566,7 @@ class RoomManager:
if ai_payload is None:
raise ValueError("ai_call_failed:empty_ai_payload")
self._ensure_private_updates_for_all_players(ai_payload, players, actions)
next_state, round_result = self.story_engine.apply_turn(state, ai_payload, merge_result, players)
next_status = "ended" if next_state.get("game_phase") == "ended" else "running"
self.state_manager.set_room_state(room_id, next_status, next_state)
+125 -5
View File
@@ -63,9 +63,33 @@ class DeepSeekClient:
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
payload["temperature"] = temperature
payload["max_tokens"] = max_tokens
if use_json_mode:
payload["response_format"] = {"type": "json_object"}
return payload
def _build_payload(
self,
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
*,
use_json_mode: bool,
token_field: str,
include_temperature: bool,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
}
if include_temperature:
payload["temperature"] = temperature
if token_field == "max_tokens":
payload["max_tokens"] = max_tokens
elif token_field == "max_completion_tokens":
payload["max_completion_tokens"] = max_tokens
if use_json_mode:
payload["response_format"] = {"type": "json_object"}
return payload
@@ -80,6 +104,50 @@ class DeepSeekClient:
return True
return "json schema" in lower and "support" in lower
def _extract_error_message(self, body: Any, raw_text: str) -> str:
if isinstance(body, dict):
error = body.get("error")
if isinstance(error, dict):
msg = error.get("message")
if isinstance(msg, str) and msg.strip():
return msg.strip()
if isinstance(error, str) and error.strip():
return error.strip()
msg = body.get("message")
if isinstance(msg, str) and msg.strip():
return msg.strip()
text = str(raw_text or "").strip()
if not text:
return ""
if len(text) > 1000:
return text[:1000]
return text
def _detect_token_field_switch(self, status_code: int, error_message: str, current_field: str) -> str | None:
if status_code not in {400, 404, 415, 422}:
return None
lower = str(error_message or "").lower()
if current_field == "max_tokens":
if "max_tokens" in lower and any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized")):
return "max_completion_tokens"
if current_field == "max_completion_tokens":
if "max_completion_tokens" in lower and any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized")):
return "max_tokens"
if current_field != "none":
if "maximum context length" in lower and "max_tokens" in lower:
return "none"
if "unexpected parameter" in lower and ("max_tokens" in lower or "max_completion_tokens" in lower):
return "none"
return None
def _is_temperature_unsupported(self, status_code: int, error_message: str) -> bool:
if status_code not in {400, 404, 415, 422}:
return False
lower = str(error_message or "").lower()
if "temperature" not in lower:
return False
return any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized"))
def _content_to_text(self, value: Any) -> str:
if isinstance(value, str):
return value.strip()
@@ -119,14 +187,43 @@ class DeepSeekClient:
content = ""
if isinstance(message, dict):
content = self._content_to_text(message.get("content"))
if not content:
tool_calls = message.get("tool_calls", [])
if isinstance(tool_calls, list) and tool_calls:
call = tool_calls[0] if isinstance(tool_calls[0], dict) else {}
func = call.get("function", {}) if isinstance(call, dict) else {}
if isinstance(func, dict):
content = self._content_to_text(func.get("arguments"))
if not content:
content = self._content_to_text(message.get("reasoning"))
if not content:
content = self._content_to_text(first.get("text"))
if not content and isinstance(first.get("delta"), dict):
content = self._content_to_text(first.get("delta", {}).get("content"))
if content:
return content
output_text = self._content_to_text(body.get("output_text"))
if output_text:
return output_text
output = body.get("output", [])
if isinstance(output, list):
blocks: list[str] = []
for item in output:
if not isinstance(item, dict):
continue
content_rows = item.get("content", [])
if not isinstance(content_rows, list):
continue
for row in content_rows:
if isinstance(row, dict):
text = self._content_to_text(row.get("text"))
if text:
blocks.append(text)
merged = "\n".join(blocks).strip()
if merged:
return merged
return ""
def chat_json(
@@ -146,12 +243,22 @@ class DeepSeekClient:
last_detail = ""
attempt = 0
use_json_mode = self.force_json_mode
token_field = "max_tokens"
include_temperature = True
while attempt <= self.retries:
try:
payload = self._build_payload(
messages,
temperature,
max_tokens,
use_json_mode=use_json_mode,
token_field=token_field,
include_temperature=include_temperature,
)
response = requests.post(
url,
headers=headers,
json=self._payload(messages, temperature, max_tokens, use_json_mode),
json=payload,
timeout=(self.connect_timeout, self.read_timeout),
)
raw_text = response.text or ""
@@ -160,15 +267,28 @@ class DeepSeekClient:
raise requests.HTTPError(last_error)
if response.status_code >= 400:
body_error: Any = None
try:
body_error = response.json()
except (json.JSONDecodeError, ValueError):
body_error = None
error_message = self._extract_error_message(body_error, raw_text)
if use_json_mode and self._is_response_format_unsupported(response.status_code, raw_text):
use_json_mode = False
continue
next_token_field = self._detect_token_field_switch(response.status_code, error_message, token_field)
if next_token_field is not None and next_token_field != token_field:
token_field = next_token_field
continue
if include_temperature and self._is_temperature_unsupported(response.status_code, error_message):
include_temperature = False
continue
return {
"ok": False,
"error": f"http_{response.status_code}",
"content": None,
"detail": "",
"raw": raw_text,
"detail": error_message,
"raw": body_error if isinstance(body_error, dict) else raw_text,
}
try: