Spaces:
Runtime error
Runtime error
File size: 17,389 Bytes
9e03a34 4998893 9e03a34 4998893 9e03a34 4998893 9e03a34 4998893 9e03a34 4998893 9e03a34 4998893 9e03a34 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 | """
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 "当前场景中没有特别的可交互对象"
|