Story_Weaver / nlu_engine.py
wzh0617's picture
Upload 12 files
8bdaafd verified
"""
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 "当前场景中没有特别的可交互对象"