Spaces:
Runtime error
Runtime error
| """ | |
| nlu_engine.py - StoryWeaver 自然语言理解引擎 | |
| 职责: | |
| 1. 解析用户自然语言输入,提取结构化意图 | |
| 2. 将玩家"乱七八糟的输入"映射到具体的动作类型 | |
| 3. 封装意图识别的 Prompt 与 API 调用 | |
| 设计思路: | |
| - 使用 Qwen API 进行意图识别,利用 LLM 的语义理解能力 | |
| - Prompt 设计中明确列出所有可能的意图类型和示例 | |
| - 低温度 (0.2) 确保输出的 JSON 格式稳定可靠 | |
| - 提供降级机制:如果 API 调用失败,使用关键词匹配兜底 | |
| 输入/输出示例(来自需求文档): | |
| Input: "我想攻击那个哥布林" | |
| Output: {"intent": "ATTACK", "target": "哥布林", "details": null} | |
| """ | |
| import re | |
| import logging | |
| from typing import Optional | |
| from demo_rules import build_scene_actions | |
| from utils import safe_json_call, DEFAULT_MODEL | |
| from state_manager import GameState | |
| logger = logging.getLogger("StoryWeaver") | |
| # ============================================================ | |
| # 意图识别 Prompt 模板 | |
| # | |
| # 设计思路: | |
| # - System Prompt 提供完整的意图类型列表和示例 | |
| # - 注入当前可用的行动上下文(当前场景的NPC、物品等) | |
| # - 要求严格输出 JSON 格式 | |
| # - 低温度确保稳定性 | |
| # ============================================================ | |
| NLU_SYSTEM_PROMPT_TEMPLATE = """你是一个 RPG 游戏的自然语言理解模块(NLU)。你的任务是将玩家的自然语言输入解析为结构化的 JSON 意图数据。 | |
| 【当前游戏上下文】 | |
| {context} | |
| 【支持的意图类型】 | |
| 以下是所有合法的意图类型及其说明和示例: | |
| | 意图 (intent) | 说明 | 示例输入 | | |
| |:--|:--|:--| | |
| | ATTACK | 攻击目标 | "攻击哥布林"、"打那个怪物"、"我要和它战斗" | | |
| | TALK | 与NPC对话 | "和村长说话"、"找铁匠聊聊"、"我想打听消息" | | |
| | MOVE | 移动到某地 | "去森林"、"回村庄"、"我要离开这里" | | |
| | EXPLORE | 探索/观察环境 | "看看周围"、"仔细搜索"、"调查这个地方" | | |
| | USE_ITEM | 使用物品 | "喝治疗药水"、"使用火把"、"吃面包" | | |
| | TRADE | 交易(买/卖) | "买一把剑"、"卖掉这个"、"看看有什么卖的" | | |
| | EQUIP | 装备物品 | "装备铁剑"、"穿上皮甲" | | |
| | REST | 休息恢复 | "休息一下"、"在旅店过夜"、"睡觉" | | |
| | QUEST | 接受/查看任务 | "接受任务"、"查看任务"、"任务完成了" | | |
| | SKILL | 使用技能 | "施放火球术"、"使用隐身技能" | | |
| | PICKUP | 拾取物品 | "捡起来"、"拿走那个东西" | | |
| | FLEE | 逃跑 | "快跑"、"逃离这里"、"我要撤退" | | |
| | CUSTOM | 其他自由行动 | "给NPC唱首歌"、"在墙上涂鸦" | | |
| 【当前场景中可交互的对象】 | |
| {interactables} | |
| 【输出格式要求】 | |
| 请严格输出以下 JSON 格式(不要输出任何其他文字): | |
| {{ | |
| "intent": "意图类型(从上表中选择)", | |
| "target": "行动目标(NPC名称、物品名称、地点名称等,如果没有明确目标则为 null)", | |
| "details": "补充细节(如 '用剑攻击'、'询问关于森林的事情' 等,如果没有额外细节则为 null)" | |
| }} | |
| 【解析规则】 | |
| 1. 如果玩家输入模糊(如"我不知道该干什么"),意图设为 EXPLORE。 | |
| 2. 如果玩家输入包含多个动作,提取最主要的一个。 | |
| 3. target 应尽量匹配当前场景中实际存在的对象。 | |
| 4. 如果输入完全无法理解,设 intent 为 CUSTOM。 | |
| """ | |
| class NLUEngine: | |
| """ | |
| 自然语言理解引擎 | |
| 核心能力:将玩家自由文本输入映射到结构化意图。 | |
| 工作流程: | |
| 1. 收集当前场景上下文(NPC、物品、可达地点等) | |
| 2. 构造 Prompt 并调用 Qwen API | |
| 3. 解析返回的 JSON 意图 | |
| 4. 如果 API 失败,使用关键词匹配降级 | |
| 为什么用 LLM 而不是规则匹配: | |
| - 玩家输入千变万化,规则难以覆盖 | |
| - LLM 能理解同义词、口语化表达、上下文隐含意图 | |
| - 例如:"我饿了" → 可能是 USE_ITEM(吃东西)或 MOVE(去旅店) | |
| """ | |
| def __init__(self, game_state: GameState, model: str = DEFAULT_MODEL): | |
| self.game_state = game_state | |
| self.model = model | |
| def parse_intent(self, user_input: str) -> dict: | |
| """ | |
| 解析用户输入,返回结构化意图。 | |
| Args: | |
| user_input: 玩家的原始文本输入 | |
| Returns: | |
| { | |
| "intent": "ATTACK", | |
| "target": "哥布林", | |
| "details": "用剑攻击", | |
| "raw_input": "我想用剑攻击那个哥布林" | |
| } | |
| """ | |
| if not user_input or not user_input.strip(): | |
| return { | |
| "intent": "EXPLORE", | |
| "target": None, | |
| "details": "玩家沉默不语", | |
| "raw_input": "", | |
| "parser_source": "empty_input", | |
| } | |
| user_input = user_input.strip() | |
| logger.info(f"NLU 解析输入: '{user_input}'") | |
| # 尝试 LLM 解析 | |
| result = self._llm_parse(user_input) | |
| # 如果 LLM 解析失败,使用关键词降级 | |
| if result is None: | |
| logger.warning("LLM 解析失败,使用关键词降级") | |
| result = self._keyword_fallback(user_input) | |
| result = self._apply_intent_postprocessing(result, user_input) | |
| # 附加原始输入 | |
| result["raw_input"] = user_input | |
| logger.info(f"NLU 解析结果: {result}") | |
| return result | |
| def _llm_parse(self, user_input: str) -> Optional[dict]: | |
| """ | |
| 使用 Qwen API 进行意图识别。 | |
| 低温度 (0.2) 确保 JSON 输出稳定。 | |
| """ | |
| context = self._build_context() | |
| interactables = self._build_interactables() | |
| system_prompt = NLU_SYSTEM_PROMPT_TEMPLATE.format( | |
| context=context, | |
| interactables=interactables, | |
| ) | |
| messages = [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_input}, | |
| ] | |
| result = safe_json_call( | |
| messages, | |
| model=self.model, | |
| temperature=0.2, | |
| max_tokens=300, | |
| max_retries=2, | |
| ) | |
| if result and isinstance(result, dict) and "intent" in result: | |
| # 验证意图类型合法 | |
| valid_intents = { | |
| "ATTACK", "TALK", "MOVE", "EXPLORE", "USE_ITEM", | |
| "TRADE", "EQUIP", "REST", "QUEST", "SKILL", | |
| "PICKUP", "FLEE", "CUSTOM", | |
| } | |
| if result["intent"] not in valid_intents: | |
| result["intent"] = "CUSTOM" | |
| result.setdefault("parser_source", "llm") | |
| return result | |
| return None | |
| def _keyword_fallback(self, user_input: str) -> dict: | |
| """ | |
| 关键词匹配降级方案。 | |
| 设计思路: | |
| - 当 API 不可用时的兜底策略 | |
| - 使用正则匹配常见中文关键词 | |
| - 覆盖最常见的意图类型 | |
| - 无法匹配时默认为 EXPLORE | |
| """ | |
| text = user_input.lower() | |
| # 关键词 → 意图映射(按优先级排序) | |
| keyword_rules = [ | |
| # 攻击相关 | |
| (r"攻击|打|杀|战斗|砍|刺|射|揍", "ATTACK"), | |
| # 逃跑相关 | |
| (r"逃|跑|撤退|逃离|闪", "FLEE"), | |
| # 对话相关 | |
| (r"说话|对话|交谈|聊|打听|询问|问", "TALK"), | |
| # 移动相关 | |
| (r"去|前往|移动|走|回|离开|进入", "MOVE"), | |
| # 物品使用 | |
| (r"使用|喝|吃|用|服用", "USE_ITEM"), | |
| # 交易 | |
| (r"买|卖|交易|购买|出售|商店", "TRADE"), | |
| # 装备 | |
| (r"装备|穿|戴|换装", "EQUIP"), | |
| # 休息 | |
| (r"休息|睡|过夜|恢复|歇", "REST"), | |
| # 任务 | |
| (r"任务|接受|完成|查看任务", "QUEST"), | |
| # 技能 | |
| (r"施放|技能|魔法|法术|释放", "SKILL"), | |
| # 拾取 | |
| (r"捡|拾|拿|拿走|拾取|收集", "PICKUP"), | |
| # 探索 | |
| (r"看|观察|搜索|调查|探索|检查|四周", "EXPLORE"), | |
| ] | |
| detected_intent = "CUSTOM" | |
| for pattern, intent in keyword_rules: | |
| if re.search(pattern, text): | |
| detected_intent = intent | |
| break | |
| # 尝试提取目标 | |
| target = self._extract_target_from_text(user_input) | |
| return { | |
| "intent": detected_intent, | |
| "target": target, | |
| "details": None, | |
| "parser_source": "keyword_fallback", | |
| } | |
| def _extract_target_from_text(self, text: str) -> Optional[str]: | |
| """ | |
| 从文本中提取可能的目标对象。 | |
| 尝试匹配当前场景中的 NPC、物品、地点名称。 | |
| """ | |
| # 检查 NPC 名称 | |
| for npc_name in self.game_state.world.npcs: | |
| if npc_name in text: | |
| return npc_name | |
| # 检查物品名称(背包 + 当前场景) | |
| for item in self.game_state.player.inventory: | |
| if item in text: | |
| return item | |
| # 检查地点名称 | |
| current_loc = self.game_state.world.locations.get(self.game_state.player.location) | |
| if current_loc: | |
| for loc_name in current_loc.connected_to: | |
| if loc_name in text: | |
| return loc_name | |
| # 检查物品注册表 | |
| for item_name in self.game_state.world.item_registry: | |
| if item_name in text: | |
| return item_name | |
| return None | |
| def _apply_intent_postprocessing(self, result: dict, user_input: str) -> dict: | |
| """Apply narrow intent corrections for high-confidence mixed phrases.""" | |
| normalized = dict(result) | |
| intent = str(normalized.get("intent", "")).upper() | |
| if intent == "MOVE" and self._looks_like_trade_request(user_input, normalized.get("target")): | |
| inferred_trade_target = self._infer_trade_target(user_input, normalized.get("target")) | |
| target_text = str(normalized.get("target") or "") | |
| target_location = self.game_state.world.locations.get(target_text) | |
| # 目标是商店地点且玩家尚未到达时,优先保持 MOVE,避免生成“未到店先扣钱”的错误交易。 | |
| if ( | |
| target_location is not None | |
| and target_location.shop_available | |
| and target_text != self.game_state.player.location | |
| ): | |
| normalized["intent_correction"] = "preserve_move_for_shop_travel" | |
| elif inferred_trade_target is not None: | |
| normalized["intent"] = "TRADE" | |
| normalized["target"] = inferred_trade_target | |
| normalized["intent_correction"] = "move_to_trade_with_structured_target" | |
| if intent == "TRADE" and not isinstance(normalized.get("target"), dict): | |
| target_text = str(normalized.get("target") or "") | |
| target_location = self.game_state.world.locations.get(target_text) | |
| if ( | |
| target_location is not None | |
| and target_location.shop_available | |
| and target_text != self.game_state.player.location | |
| ): | |
| normalized["intent"] = "MOVE" | |
| normalized["intent_correction"] = "trade_to_move_for_shop_travel" | |
| return normalized | |
| inferred_trade_target = self._infer_trade_target(user_input, normalized.get("target")) | |
| if inferred_trade_target is not None: | |
| normalized["target"] = inferred_trade_target | |
| normalized["intent_correction"] = "trade_target_inferred_from_text" | |
| if intent in {"ATTACK", "COMBAT"}: | |
| target = normalized.get("target") | |
| if not isinstance(target, str) or not target.strip() or target in {"怪物", "敌人", "它", "那个怪物"}: | |
| inferred_target = self._infer_attack_target() | |
| if inferred_target: | |
| normalized["target"] = inferred_target | |
| normalized["intent_correction"] = "attack_target_inferred_from_scene" | |
| return normalized | |
| def _looks_like_trade_request(self, user_input: str, target: Optional[str]) -> bool: | |
| trade_pattern = r"买|卖|交易|购买|出售|看看有什么卖的|买点" | |
| if not re.search(trade_pattern, user_input): | |
| return False | |
| target_text = str(target or "") | |
| if target_text: | |
| npc = self.game_state.world.npcs.get(target_text) | |
| if npc and npc.can_trade: | |
| return True | |
| location = self.game_state.world.locations.get(target_text) | |
| if location and location.shop_available: | |
| return True | |
| shop_hint_pattern = r"商店|杂货铺|旅店|铁匠铺" | |
| return bool(re.search(shop_hint_pattern, user_input)) | |
| def _infer_attack_target(self) -> Optional[str]: | |
| """Infer a concrete ATTACK target from deterministic scene actions first.""" | |
| try: | |
| scene_actions = build_scene_actions(self.game_state, self.game_state.player.location) | |
| except Exception: | |
| scene_actions = [] | |
| for action in scene_actions: | |
| if str(action.get("action_type", "")).upper() != "ATTACK": | |
| continue | |
| target = action.get("target") | |
| if isinstance(target, str) and target.strip(): | |
| return target | |
| current_loc = self.game_state.world.locations.get(self.game_state.player.location) | |
| if current_loc and current_loc.enemies: | |
| return str(current_loc.enemies[0]) | |
| return None | |
| def _infer_trade_target(self, user_input: str, target: object) -> Optional[dict]: | |
| """Infer structured trade target for rule-based TRADE handling.""" | |
| text_blob = f"{user_input} {target if isinstance(target, str) else ''}" | |
| merchant_name: Optional[str] = None | |
| for npc in self.game_state.world.npcs.values(): | |
| if not npc.can_trade or npc.location != self.game_state.player.location: | |
| continue | |
| if npc.name in text_blob or (npc.occupation and npc.occupation in text_blob): | |
| merchant_name = npc.name | |
| break | |
| if merchant_name is None: | |
| for npc in self.game_state.world.npcs.values(): | |
| if npc.can_trade and npc.location == self.game_state.player.location: | |
| merchant_name = npc.name | |
| break | |
| if merchant_name is None: | |
| return None | |
| merchant = self.game_state.world.npcs.get(merchant_name) | |
| if merchant is None: | |
| return None | |
| item_name: Optional[str] = None | |
| for candidate in merchant.shop_inventory: | |
| if candidate in text_blob: | |
| item_name = candidate | |
| break | |
| if item_name is None and isinstance(target, str) and target in merchant.shop_inventory: | |
| item_name = target | |
| if item_name is None: | |
| return None | |
| return {"merchant": merchant_name, "item": item_name, "confirm": False} | |
| def _build_context(self) -> str: | |
| """构建当前场景的简要上下文描述""" | |
| gs = self.game_state | |
| return ( | |
| f"场景: {gs.world.current_scene}\n" | |
| f"时间: 第{gs.world.day_count}天 {gs.world.time_of_day}\n" | |
| f"玩家位置: {gs.player.location}\n" | |
| f"玩家 HP: {gs.player.hp}/{gs.player.max_hp}\n" | |
| f"玩家背包: {', '.join(gs.player.inventory) if gs.player.inventory else '空'}" | |
| ) | |
| def _build_interactables(self) -> str: | |
| """构建当前场景中可交互对象的列表""" | |
| gs = self.game_state | |
| lines = [] | |
| # 当前场景的 NPC | |
| current_npcs = [ | |
| npc for npc in gs.world.npcs.values() | |
| if npc.location == gs.player.location and npc.is_alive | |
| ] | |
| if current_npcs: | |
| npc_names = [f"{npc.name}({npc.occupation})" for npc in current_npcs] | |
| lines.append(f"NPC: {', '.join(npc_names)}") | |
| # 可前往的地点 | |
| loc = gs.world.locations.get(gs.player.location) | |
| if loc and loc.connected_to: | |
| lines.append(f"可前往: {', '.join(loc.connected_to)}") | |
| # 场景中的敌人 | |
| if loc and loc.enemies: | |
| lines.append(f"可能的敌人: {', '.join(loc.enemies)}") | |
| # 背包物品 | |
| if gs.player.inventory: | |
| lines.append(f"背包物品: {', '.join(gs.player.inventory)}") | |
| # 技能 | |
| if gs.player.skills: | |
| lines.append(f"可用技能: {', '.join(gs.player.skills)}") | |
| return "\n".join(lines) if lines else "当前场景中没有特别的可交互对象" | |