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 "当前场景中没有特别的可交互对象"