PPP commited on
Commit
3e9fe30
·
1 Parent(s): 34783f8

文档&图片显示

Browse files
Files changed (2) hide show
  1. app.py +2 -1
  2. 技术实现文档.md +811 -0
app.py CHANGED
@@ -23,7 +23,7 @@ import gradio as gr
23
 
24
  from state_manager import GameState
25
  from nlu_engine import NLUEngine
26
- from scene_assets import get_scene_image_path
27
  from story_engine import StoryEngine
28
  from telemetry import append_turn_log, create_session_metadata
29
  from utils import logger
@@ -1997,6 +1997,7 @@ if __name__ == "__main__":
1997
  server_port=7860,
1998
  share=False,
1999
  show_error=True,
 
2000
  theme=gr.themes.Soft(
2001
  primary_hue="emerald",
2002
  secondary_hue="blue",
 
23
 
24
  from state_manager import GameState
25
  from nlu_engine import NLUEngine
26
+ from scene_assets import get_scene_image_path, IMAGE_DIR
27
  from story_engine import StoryEngine
28
  from telemetry import append_turn_log, create_session_metadata
29
  from utils import logger
 
1997
  server_port=7860,
1998
  share=False,
1999
  show_error=True,
2000
+ allowed_paths=[str(IMAGE_DIR)],
2001
  theme=gr.themes.Soft(
2002
  primary_hue="emerald",
2003
  secondary_hue="blue",
技术实现文档.md ADDED
@@ -0,0 +1,811 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # StoryWeaver 技术实现文档
2
+
3
+ **版本:** 1.0
4
+ **适用范围:** 供组员理解系统运行原理、撰写项目报告使用
5
+
6
+ ---
7
+
8
+ ## 目录
9
+
10
+ 1. [项目概述](#1-项目概述)
11
+ 2. [技术栈](#2-技术栈)
12
+ 3. [系统架构](#3-系统架构)
13
+ 4. [模块详解](#4-模块详解)
14
+ 5. [核心数据流](#5-核心数据流)
15
+ 6. [API 集成:通义千问](#6-api-集成通义千问)
16
+ 7. [两阶段叙事生成策略](#7-两阶段叙事生成策略)
17
+ 8. [状态管理机制](#8-状态管理机制)
18
+ 9. [自然语言理解(NLU)模块](#9-自然语言理解nlu模块)
19
+ 10. [Web UI 与流式输出](#10-web-ui-与流式输出)
20
+ 11. [场景图片显示机制(及修复说明)](#11-场景图片显示机制及修复说明)
21
+ 12. [遥测与日志系统](#12-遥测与日志系统)
22
+ 13. [评估框架](#13-评估框架)
23
+ 14. [项目文件结构](#14-项目文件结构)
24
+
25
+ ---
26
+
27
+ ## 1. 项目概述
28
+
29
+ StoryWeaver 是一个基于大语言模型(LLM)的**交互式文字冒险 RPG 系统**。玩家通过自然语言输入(或选择预设选项)驱动剧情发展,系统通过 AI 实时生成叙事文本、管理角色状态、解析玩家意图。
30
+
31
+ **核心特点:**
32
+
33
+ - 使用通义千问(Qwen)API 进行自然语言意图识别和叙事内容生成
34
+ - 两阶段生成策略保证剧情逻辑一致性(先生成 JSON 大纲,再生成文学文本)
35
+ - 完整的游戏状态管理(HP/MP、背包、任务、时间、天气、NPC 关系)
36
+ - 基于 Gradio 的 Web UI,支持流式文字输出(实时打字效果)
37
+ - JSONL 格式的完整交互日志,支持离线分析与评估
38
+
39
+ ---
40
+
41
+ ## 2. 技术栈
42
+
43
+ | 技术 | 版本要求 | 用途 |
44
+ |------|---------|------|
45
+ | Python | 3.10+ | 主语言 |
46
+ | Gradio | ≥4.0(实测 6.9.0) | Web UI 框架 |
47
+ | OpenAI Python SDK | ≥1.0.0 | 调用通义千问 API(OpenAI 兼容格式) |
48
+ | Pydantic | ≥2.0.0 | 数据模型定义与校验 |
49
+ | python-dotenv | ≥1.0.0 | 从 `.env` 文件加载 API Key |
50
+
51
+ **外部 API:**
52
+
53
+ - **通义千问 API**(阿里云 DashScope)
54
+ - 端点:`https://dashscope.aliyuncs.com/compatible-mode/v1`
55
+ - 默认模型:`qwen2.5-14b-instruct`
56
+ - 认证:`QWEN_API_KEY` 环境变量
57
+
58
+ ---
59
+
60
+ ## 3. 系统架构
61
+
62
+ ```
63
+ ┌──────────────────────────────────────────────────────────┐
64
+ │ 用户(浏览器) │
65
+ │ 文字输入 / 点击选项按钮 │
66
+ └──────────────────┬───────────────────────────────────────┘
67
+ │ HTTP(Gradio 前端)
68
+ ┌──────────────────▼───────────────────────────────────────┐
69
+ │ app.py(UI 协调层) │
70
+ │ - 构建 Gradio 界面(聊天框、选项按钮、状态面板、地图) │
71
+ │ - 管理 Gradio State(游戏会话持久化) │
72
+ │ - 流式 yield 输出(打字机效果) │
73
+ └──────┬───────────────────────┬──────────────────────────┘
74
+ │ │
75
+ ▼ ▼
76
+ ┌─────────────┐ ┌────────────────────┐
77
+ │ NLU 引擎 │ │ 叙事引擎 │
78
+ │ nlu_engine │──────▶│ story_engine.py │
79
+ │ .py │ │ │
80
+ │ 意图解析 │ │ 两阶段生成策略 │
81
+ │ TALK/MOVE/ │ │ 1. JSON 大纲 │
82
+ │ ATTACK/... │ │ 2. 文学叙事文本 │
83
+ └─────────────┘ └─────────┬──────────┘
84
+
85
+ ┌───────────▼──────────┐
86
+ │ 状态管理器 │
87
+ │ state_manager.py │
88
+ │ │
89
+ │ GameState │
90
+ │ PlayerState │
91
+ │ WorldState │
92
+ │ QuestState │
93
+ │ NPCState │
94
+ └─────────────────────┘
95
+
96
+ ┌───────────▼──────────┐
97
+ │ 通义千问 API │
98
+ │ (via utils.py) │
99
+ │ qwen2.5-14b-instruct│
100
+ └─────────────────────┘
101
+ ```
102
+
103
+ ### 模块职责一览
104
+
105
+ | 文件 | 行数 | 核心职责 |
106
+ |------|------|---------|
107
+ | `app.py` | 2006 | Gradio UI、事件绑定、输出流协调 |
108
+ | `state_manager.py` | 2636 | 游戏状态全量管理、一致性校验 |
109
+ | `story_engine.py` | 4074 | AI 叙事生成(两阶段)、规则判定 |
110
+ | `nlu_engine.py` | 431 | 自然语言意图解析(LLM + 关键词兜底) |
111
+ | `utils.py` | 328 | Qwen API 封装、JSON 解析工具 |
112
+ | `combat_engine.py` | 97 | 战斗结算逻辑 |
113
+ | `scene_assets.py` | 31 | 场景图片路径解析 |
114
+ | `demo_rules.py` | 1410 | 游戏规则常量、选项构建函数 |
115
+ | `telemetry.py` | 81 | 交互日志(JSONL 格式) |
116
+
117
+ ---
118
+
119
+ ## 4. 模块详解
120
+
121
+ ### 4.1 app.py — UI 协调层
122
+
123
+ `app.py` 是整个系统的入口,负责把各个引擎"粘合"在一起并呈现给用户。
124
+
125
+ **关键函数:**
126
+
127
+ ```
128
+ create_new_game(player_name)
129
+ └─ 初始化 GameState、NLUEngine、StoryEngine,返回 game_session 字典
130
+
131
+ start_game(player_name, game_session)
132
+ └─ 生成器函数,流式 yield 开场叙事及初始状态
133
+
134
+ process_user_input(user_input, chat_history, game_session)
135
+ └─ 主处理函数:NLU解析 → 引擎生成 → 状态更新 → 流式输出
136
+
137
+ process_option_click(index, chat_history, game_session)
138
+ └─ 处理选项按钮点击,委托给 story_engine.process_option_selection_stream()
139
+
140
+ _get_scene_image_update(gs)
141
+ └─ 调用 scene_assets 获取图片路径,返回 gr.update(value=..., visible=...)
142
+ ```
143
+
144
+ **Gradio 状态管理:**
145
+
146
+ Gradio 是无状态框架,通过 `gr.State(value={})` 在 HTTP 请求间保持游戏会话:
147
+
148
+ ```python
149
+ game_session = {
150
+ "game_state": GameState, # 完整游戏状态
151
+ "nlu": NLUEngine, # NLU 引擎实例
152
+ "story": StoryEngine, # 叙事引擎实例
153
+ "current_options": [...], # 当前轮次的选项列表
154
+ "started": True, # 是否已开始游戏
155
+ "session_id": "sw-...", # 日志会话 ID
156
+ "turn_index": 3, # 当前回合数
157
+ }
158
+ ```
159
+
160
+ **流式输出机制:**
161
+
162
+ 所有面向用户的函数都是 Python 生成器(`yield`),Gradio 会将每次 `yield` 的结果推送到前端,实现打字机效果:
163
+
164
+ ```python
165
+ def start_game(player_name, game_session):
166
+ # 初始加载状态
167
+ yield (chat_history, world_info, status, map_html, image_update, ...)
168
+
169
+ # 流式文字更新(每收到一个 chunk 推一次)
170
+ for update in story_engine.generate_opening_stream():
171
+ if update["type"] == "story_chunk":
172
+ chat_history[-1]["content"] = update["text"]
173
+ yield (chat_history, ...)
174
+
175
+ # 最终状态(含选项)
176
+ yield (chat_history, ..., enabled_buttons, ...)
177
+ ```
178
+
179
+ ---
180
+
181
+ ### 4.2 state_manager.py — 游戏状态管理
182
+
183
+ `state_manager.py` 是系统的"单一数据源"(Single Source of Truth),所有游戏数据均从此处读写。
184
+
185
+ **数据模型层级:**
186
+
187
+ ```
188
+ GameState(根对象)
189
+ ├── player: PlayerState
190
+ │ ├── name, hp, max_hp, mp, attack, defense, level, exp
191
+ │ ├── gold, location
192
+ │ ├── inventory: list[str] # 背包物品
193
+ │ ├── equipment: dict[str, str] # 装备槽 → 物品名
194
+ │ ├── status_effects: list[StatusEffect]
195
+ │ └── skills: list[str]
196
+
197
+ ├── world: WorldState
198
+ │ ├── current_scene: str # 当前场景名
199
+ │ ├── locations: dict[str, LocationInfo] # 13 个地点
200
+ │ ├── npcs: dict[str, NPCState] # 8 个 NPC
201
+ │ ├── quests: dict[str, QuestState] # 任务列表
202
+ │ ├── time_of_day, weather # 环境状态
203
+ │ └── discovered_locations: set[str]
204
+
205
+ ├── event_log: list[GameEvent] # 事件记录(一致性检测用)
206
+ └── location_history: list[str] # 地点访问历史(地图显示用)
207
+ ```
208
+
209
+ **核心方法:**
210
+
211
+ | 方法 | 作用 |
212
+ |------|------|
213
+ | `apply_changes(changes)` | 应用叙事引擎返回的状态变更 JSON,返回变更日志 |
214
+ | `pre_validate_action(intent)` | 早期拒绝非法操作(如在无商人处交易) |
215
+ | `check_consistency(changes)` | 检测拟议变更是否与当前状态矛盾 |
216
+ | `to_prompt()` | 将当前状态序列化为 LLM prompt 文本 |
217
+ | `tick_time(intent)` | 推进游戏时间,触发天气/环境/状态效果 |
218
+ | `validate()` | 完整状态合法性校验 |
219
+
220
+ **不可变更新原则:**
221
+
222
+ `apply_changes` 不直接修改对象字段,而是通过 Pydantic 的 `model_copy(update=...)` 生成新对象,确保状态更新的可追溯性。
223
+
224
+ ---
225
+
226
+ ### 4.3 story_engine.py — 叙事生成引擎
227
+
228
+ 这是系统最复杂的模块(4074 行),负责调用 LLM 生成游戏叙事。
229
+
230
+ **两阶段生成策略(Chain of Thought):**
231
+
232
+ ```
233
+ 玩家意图 (intent JSON)
234
+
235
+
236
+ 【阶段一:合并单次调用】
237
+ MERGED_SYSTEM_PROMPT_TEMPLATE 包含:
238
+ - 当前世界状态(from game_state.to_prompt())
239
+ - 玩家意图
240
+ - 输出格式约束:---STORY_TEXT--- 与 ---OPTIONS_JSON--- 分隔
241
+
242
+ LLM 输出格式:
243
+ ---STORY_TEXT---
244
+ (200-400字的叙事文本)
245
+ ---OPTIONS_JSON---
246
+ [{"text":"...", "action_type":"MOVE", "target":"..."}, ...]
247
+
248
+
249
+ 【解析 + 状态更新】
250
+ story_engine 解析 JSON 选项
251
+ state_manager.apply_changes() 更新状态
252
+ telemetry.append_turn_log() 记录日志
253
+
254
+
255
+ 【返回前端】
256
+ {"story_text": "...", "options": [...], "state_changes": {...}}
257
+ ```
258
+
259
+ **规则判定优先级(rule-based 短路):**
260
+
261
+ 在调用 LLM 之前,`story_engine` 先检查规则系统是否能直接处理该意图,避免不必要的 API 调用:
262
+
263
+ 1. `_rule_trade_response()` — 商店买卖(直接结算,无需 LLM)
264
+ 2. `_rule_equip_response()` — 装备物品
265
+ 3. `_rule_shop_menu_response()` — 打开商店菜单
266
+ 4. `_rule_scene_options_response()` — 场景选项刷新
267
+
268
+ 只有以上规则无法处理的意图(探索、对话、战斗叙事等),才会进入 LLM 生成流程。
269
+
270
+ **流式生成实现:**
271
+
272
+ `generate_story_stream()` 调用 `call_qwen_stream()`,逐 token 接收 LLM 输出:
273
+
274
+ ```python
275
+ for chunk in call_qwen_stream(messages, ...):
276
+ full_text += chunk
277
+ # 检测到 ---STORY_TEXT--- 后开始向前端推送文字
278
+ if story_started:
279
+ yield {"type": "story_chunk", "text": current_story_text}
280
+
281
+ # 流结束后一次性解析 OPTIONS_JSON
282
+ yield {"type": "final", "story_text": ..., "options": [...], ...}
283
+ ```
284
+
285
+ ---
286
+
287
+ ### 4.4 nlu_engine.py — 自然语言理解
288
+
289
+ `NLUEngine` 将玩家输入的自然语言转换为结构化意图,供叙事引擎处理。
290
+
291
+ **解析流程:**
292
+
293
+ ```
294
+ 用户输入(如"我想和村长聊聊森林的事")
295
+
296
+
297
+ 【第一步:LLM 解析】_llm_parse()
298
+ 构建包含当前场景上下文的 prompt
299
+ 调用 call_qwen() 获取 JSON 意图
300
+
301
+ ├─ 成功 → 返回结构化意图
302
+ └─ 失败 → 降级到关键词匹配
303
+
304
+ 【第二步:关键词兜底】_keyword_fallback()
305
+ 正则匹配攻击/对话/移动/探索等关键词
306
+ 从场景 NPC/地点列表中抽取目标
307
+
308
+
309
+ 【第三步:意图后处理】_apply_intent_postprocessing()
310
+ - 修正高置信度错误(如误识别交易为对话)
311
+ - 推断攻击/交易目标
312
+
313
+
314
+ 输出意图 JSON:
315
+ {
316
+ "intent": "TALK", # 意图类型
317
+ "target": "村长老伯", # 操作目标
318
+ "details": "询问森林怪事", # 补充信息
319
+ "raw_input": "原始输入",
320
+ "parser_source": "llm" # 或 "keyword_fallback"
321
+ }
322
+ ```
323
+
324
+ **支持的意图类型:**
325
+
326
+ `ATTACK` / `TALK` / `MOVE` / `EXPLORE` / `USE_ITEM` / `TRADE` / `EQUIP` / `REST` / `QUEST` / `SKILL` / `PICKUP` / `FLEE` / `CUSTOM`
327
+
328
+ ---
329
+
330
+ ### 4.5 utils.py — API 工具层
331
+
332
+ **API 客户端初始化(懒加载单例):**
333
+
334
+ ```python
335
+ _client: Optional[OpenAI] = None
336
+
337
+ def get_client() -> OpenAI:
338
+ global _client
339
+ if _client is None:
340
+ _client = OpenAI(
341
+ api_key=QWEN_API_KEY,
342
+ base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
343
+ )
344
+ return _client
345
+ ```
346
+
347
+ 通义千问 API 兼容 OpenAI 格式,因此直接使用 `openai` Python 包,仅替换 `base_url`。
348
+
349
+ **重试机制(指数退避):**
350
+
351
+ ```python
352
+ def call_qwen(messages, model, temperature, max_tokens, max_retries=3, retry_delay=1.0):
353
+ for attempt in range(1, max_retries + 1):
354
+ try:
355
+ response = client.chat.completions.create(...)
356
+ return response.choices[0].message.content.strip()
357
+ except Exception as e:
358
+ time.sleep(retry_delay * (2 ** (attempt - 1)))
359
+ raise last_exception
360
+ ```
361
+
362
+ **JSON 鲁棒提取(5 种策略):**
363
+
364
+ LLM 输出格式有时不稳定,`extract_json_from_text()` 依次尝试:
365
+
366
+ 1. 直接 `json.loads(text)`
367
+ 2. 提取 ` ```json ... ``` ` 代码块
368
+ 3. 正则匹配最外层 `{ ... }`
369
+ 4. 深度嵌套花括号匹配
370
+ 5. 数组 `[ ... ]` 匹配
371
+
372
+ ---
373
+
374
+ ## 5. 核心数据流
375
+
376
+ ### 玩家文字输入完整流程
377
+
378
+ ```
379
+ 用户在输入框中输入文字,点击发送
380
+
381
+
382
+ app.py: process_user_input(user_input, chat_history, game_session)
383
+
384
+ ├─ 1. state_manager.pre_validate_action(intent)
385
+ │ → 早期拒绝非法操作,直接返回提示文本
386
+
387
+ ├─ 2. nlu_engine.parse_intent(user_input)
388
+ │ → 返回 intent dict {"intent": "TALK", "target": "..."}
389
+
390
+ ├─ 3. story_engine.generate_story_stream(intent)
391
+ │ ├─ 规则判定短路(交易/装备等直接处理)
392
+ │ ├─ state_manager.tick_time()(时间推进)
393
+ │ ├─ state_manager.check_consistency(changes)
394
+ │ ├─ call_qwen_stream()(流式调用 LLM)
395
+ │ └─ yield {"type": "story_chunk"/"final", ...}
396
+
397
+ ├─ 4. state_manager.apply_changes(state_changes)
398
+ │ → 更新 HP/物品/位置/任务等状态
399
+
400
+ ├─ 5. telemetry.append_turn_log(...)
401
+ │ → 写入 JSONL 日志文件
402
+
403
+ └─ 6. yield 到前端
404
+ → chatbot 内容(流式)
405
+ → world_info_panel(世界信息)
406
+ → status_panel(角色状态)
407
+ → location_map_panel(SVG 地图)
408
+ → scene_image(场景图片)
409
+ → option_buttons(6 个选项按钮)
410
+ → game_session(更新后的状态)
411
+ ```
412
+
413
+ ### 选项按钮点击流程
414
+
415
+ ```
416
+ 用户点击选项按钮(如"前往黑暗森林")
417
+
418
+
419
+ app.py: process_option_click(button_index, chat_history, game_session)
420
+
421
+ ├─ 从 game_session["current_options"][index] 获取选项数据
422
+ │ {"text": "...", "action_type": "MOVE", "target": "黑暗森林入口"}
423
+
424
+ ├─ 构建 fake_intent(跳过 NLU,直接使用选项数据)
425
+
426
+ └─ story_engine.process_option_selection_stream(option)
427
+ └─ 调用 generate_story_stream(intent)(与文字输入相同流程)
428
+ ```
429
+
430
+ ---
431
+
432
+ ## 6. API 集成:通义千问
433
+
434
+ ### 配置方式
435
+
436
+ 项目根目录创建 `.env` 文件:
437
+
438
+ ```
439
+ QWEN_API_KEY=sk-your-actual-api-key-here
440
+ STORYWEAVER_LOG_DIR=logs/interactions
441
+ ```
442
+
443
+ ### 模型选择
444
+
445
+ 默认使用 `qwen2.5-14b-instruct`(定义于 `utils.py:DEFAULT_MODEL`)。
446
+
447
+ ### API 调用两种模式
448
+
449
+ | 函数 | 模式 | 用途 |
450
+ |------|------|------|
451
+ | `call_qwen()` | 同步(阻塞) | NLU 意图解析、规则文本润色 |
452
+ | `call_qwen_stream()` | 流式(generator) | 叙事生成(打字机效果) |
453
+
454
+ ---
455
+
456
+ ## 7. 两阶段叙事生成策略
457
+
458
+ ### 设计动机
459
+
460
+ 直接让 LLM 生成"叙事文本 + 状态变化"时存在两个矛盾:
461
+ - **文学质量要求**:状态数字(HP 减少 15)需要转化为生动描写
462
+ - **数据准确要求**:状态变化必须是精确的、可解析的结构化数据
463
+
464
+ 两阶段策略将两个目标分离:
465
+
466
+ ### 实现方式(MERGED_SYSTEM_PROMPT_TEMPLATE)
467
+
468
+ Gradio 6.x 版本中实际上使用了**合并单次调用**(`MERGED_SYSTEM_PROMPT_TEMPLATE`),在同一个 LLM 响应中要求模型先"思考"状态变化(以 JSON 形式嵌入在思维链中),再输出叙事文本和选项,降低延迟同时保留结构。
469
+
470
+ **Prompt 输出格式约束:**
471
+
472
+ ```
473
+ ---STORY_TEXT---
474
+ (200-400字文学叙事)
475
+ ---OPTIONS_JSON---
476
+ [
477
+ {"text": "向村长询问森林异变的起因", "action_type": "TALK", "target": "村长老伯"},
478
+ {"text": "独自前往黑暗森林探查", "action_type": "MOVE", "target": "黑暗森林入口"},
479
+ {"text": "在旅店休息一晚恢复体力", "action_type": "REST", "target": null}
480
+ ]
481
+ ```
482
+
483
+ `story_engine` 在接收到完整响应后,从 `---OPTIONS_JSON---` 分隔符之后解析 JSON,并从中提取状态变更数据。
484
+
485
+ ---
486
+
487
+ ## 8. 状态管理机制
488
+
489
+ ### 一致性校验(check_consistency)
490
+
491
+ `state_manager.check_consistency()` 在叙事引擎生成大纲后执行校验,检测矛盾:
492
+
493
+ - 不在当前地点的 NPC 不能被互动
494
+ - 背包中没有的物品不能被使用或出售
495
+ - 已死亡的 NPC 不能出现对话
496
+ - 已完成的任务目标不能重复完成
497
+ - HP 不能超过 max_hp
498
+
499
+ ### 时间系统(tick_time)
500
+
501
+ 每次玩家行动后,`tick_time()` 会根据行动类型推进游戏时间(单位:分钟):
502
+
503
+ ```
504
+ ACTION_TIME_COSTS(定义于 demo_rules.py):
505
+ TALK → 5 分钟
506
+ MOVE → 15~60 分钟(取决于距离)
507
+ ATTACK → 5 分钟
508
+ REST → 480 分钟(过夜)
509
+ EXPLORE → 20 分钟
510
+ ...
511
+ ```
512
+
513
+ 时间推进触发:天气变化、光照变化(白天/夜晚/黄昏)、状态效果倒计时、任务截止检查。
514
+
515
+ ### 装备系统
516
+
517
+ 装备采用槽位设计:`weapon`、`armor`、`accessory`。装备后自动通过 `get_equipment_stat_bonuses()` 叠加战斗属性。
518
+
519
+ ---
520
+
521
+ ## 9. 自然语言理解(NLU)模块
522
+
523
+ ### 上下文注入
524
+
525
+ NLU 解析时,`_build_context()` 会将当前场景信息注入 LLM prompt:
526
+
527
+ ```
528
+ 当前地点:村庄广场
529
+ 在场 NPC:村长老伯
530
+ 可见物品:村庄公告板
531
+ 可前往地点:村庄旅店、村庄铁匠铺、村口小路
532
+ ```
533
+
534
+ 这使 LLM 能准确识别"跟他说话"中"他"指的是当前场景的 NPC。
535
+
536
+ ### 关键词回退
537
+
538
+ 当 LLM 解析失败时,`_keyword_fallback()` 通过正则匹配兜底:
539
+
540
+ ```python
541
+ ATTACK_PATTERNS = [r"攻击", r"打", r"砍", r"杀", ...]
542
+ MOVE_PATTERNS = [r"去", r"前往", r"走向", r"移动到", ...]
543
+ TALK_PATTERNS = [r"说", r"聊", r"问", r"交谈", ...]
544
+ ```
545
+
546
+ ---
547
+
548
+ ## 10. Web UI 与流式输出
549
+
550
+ ### 界面布局
551
+
552
+ ```
553
+ ┌─────────────────────────────────────────────────────────┐
554
+ │ StoryWeaver — 交互式叙事系统 │
555
+ ├────────────────────────────────┬────────────────────────┤
556
+ │ 角色名称输入 │ 开始冒险 │ 重启 │ 世界信息面板 │
557
+ ├────────────────────────────────┤ (时间/天气/任务概要) │
558
+ │ ├────────────────────────┤
559
+ │ 聊天框(故事叙事区域) │ 场景图片(当前地点) │
560
+ │ 实时流式文字显示 ├────────────────────────┤
561
+ │ │ 角色状态面板 │
562
+ │ │ (HP/MP/金币/等级) │
563
+ ├────────────────────────────────┤ │
564
+ │ 文字输入框 │ 发送 │ 打开背包 ├────────────────────────┤
565
+ ├────────────────────────────────┤ 地图面板(SVG 拓扑图) │
566
+ │ [选项1] [选项2] [选项3] │ │
567
+ │ [选项4] [选项5] [选项6] │ │
568
+ └────────────────────────────────┴────────────────────────┘
569
+ ```
570
+
571
+ ### 选项按钮系统
572
+
573
+ 每轮生成 3 个选项按钮(最多 6 个),选项数据结构:
574
+
575
+ ```python
576
+ {
577
+ "text": "前往黑暗森林入口", # 按钮显示文字
578
+ "action_type": "MOVE", # 行动类型
579
+ "target": "黑暗森林入口", # 目标
580
+ }
581
+ ```
582
+
583
+ ### SVG 地图
584
+
585
+ `_render_text_map()` 根据 `discovered_locations` 动态生成 SVG 拓扑图,显示已探索地点及连接关系,当前位置高亮显示。
586
+
587
+ ---
588
+
589
+ ## 11. 场景图片显示机制(及修复说明)
590
+
591
+ ### 图片资源
592
+
593
+ `image/` 目录包含 21 张 PNG 图片(约 4~6 MB 每张),按名称分为两类:
594
+
595
+ - **地点图片**:文件名 = 游戏中地点名称(如 `村庄广场.png`、`黑暗森林入口.png`)
596
+ - **NPC 图片**:文件名 = NPC 名称(如 `村长老伯.png`、`铁匠格林.png`)
597
+
598
+ ### 图片路径解析(scene_assets.py)
599
+
600
+ ```python
601
+ IMAGE_DIR = Path(__file__).resolve().parent / "image"
602
+
603
+ def get_scene_image_path(game_state, focus_npc=None):
604
+ # 优先显示当前交互 NPC 的图片
605
+ if focus_npc:
606
+ path = IMAGE_DIR / f"{focus_npc}.png"
607
+ if path.exists():
608
+ return str(path)
609
+
610
+ # 其次显示当前地点图片
611
+ for location_name in (game_state.world.current_scene, game_state.player.location):
612
+ path = IMAGE_DIR / f"{location_name}.png"
613
+ if path.exists():
614
+ return str(path)
615
+
616
+ return None
617
+ ```
618
+
619
+ ### 图片更新触发
620
+
621
+ 每次游戏状态更新(包括开场、玩家输入、点击选项)时,`app.py` 调用:
622
+
623
+ ```python
624
+ def _get_scene_image_update(gs: GameState):
625
+ image_value = _get_scene_image_value(gs)
626
+ return gr.update(value=image_value, visible=bool(image_value))
627
+ ```
628
+
629
+ 该更新被包含在所有事件处理函数的 `yield` 元组中,传递给 `gr.Image` 组件。
630
+
631
+ ### 问题根因
632
+
633
+ **Gradio 6.x 文件访问安全限制(`allowed_paths` 未配置)**
634
+
635
+ 在 Gradio 5.0 以后,框架收紧了文件服务安全策略:服务器只允许向客户端传输经过授权的文件路径。默认情况下,仅有 Gradio 的临时上传目录(`uploaded_file_dir`)和缓存目录(`get_cache_folder()`)中的文件被允许访问。
636
+
637
+ **核心代码(Gradio 源码 `routes.py`):**
638
+
639
+ ```python
640
+ allowed, reason = utils.is_allowed_file(
641
+ abs_path,
642
+ blocked_paths=blocks.blocked_paths,
643
+ allowed_paths=blocks.allowed_paths + _StaticFiles.all_paths,
644
+ created_paths=[app.uploaded_file_dir, utils.get_cache_folder()],
645
+ )
646
+ if not allowed:
647
+ raise HTTPException(403, f"File not allowed: {path_or_url}.")
648
+ ```
649
+
650
+ 原代码的 `app.launch()` 未设置 `allowed_paths`,导致 `blocks.allowed_paths = []`,图片文件路径验证失败,浏览器收到 **HTTP 403 Forbidden** 响应,图片无法加载。
651
+
652
+ ### 修复方案
653
+
654
+ **修改文件:`app.py`**
655
+
656
+ **修改 1**:在导入行添加 `IMAGE_DIR`:
657
+
658
+ ```python
659
+ # 修改前
660
+ from scene_assets import get_scene_image_path
661
+
662
+ # 修改后
663
+ from scene_assets import get_scene_image_path, IMAGE_DIR
664
+ ```
665
+
666
+ **修改 2**:在 `app.launch()` 中添加 `allowed_paths` 参数:
667
+
668
+ ```python
669
+ # 修改前
670
+ app.launch(
671
+ server_name="0.0.0.0",
672
+ server_port=7860,
673
+ ...
674
+ )
675
+
676
+ # 修改后
677
+ app.launch(
678
+ server_name="0.0.0.0",
679
+ server_port=7860,
680
+ allowed_paths=[str(IMAGE_DIR)], # 新增:授权图片目录
681
+ ...
682
+ )
683
+ ```
684
+
685
+ `allowed_paths` 接受字符串列表,Gradio 会检查请求文件是否位于列出的目录下(递归),通过验证后正常返回图片数据。
686
+
687
+ ---
688
+
689
+ ## 12. 遥测与日志系统
690
+
691
+ 每次玩家交互生成一条 JSONL 记录,写入 `logs/interactions/{session_id}.jsonl`。
692
+
693
+ ### 日志字段结构
694
+
695
+ ```json
696
+ {
697
+ "timestamp": "2026-03-28T10:23:45.123Z",
698
+ "session_id": "sw-20260328-102345-ab12cd34",
699
+ "turn_index": 3,
700
+ "input_source": "text_input", // text_input | option_click | system_opening
701
+ "user_input": "我想询问村长关于森林的事",
702
+ "nlu_result": {
703
+ "intent": "TALK",
704
+ "target": "村长老伯",
705
+ "parser_source": "llm"
706
+ },
707
+ "latency_ms": 842.13,
708
+ "nlu_latency_ms": 150.0,
709
+ "generation_latency_ms": 692.0,
710
+ "used_fallback": false,
711
+ "engine_mode": "merged_generation",
712
+ "state_changes": { "hp": -5, "gold": 0, ... },
713
+ "change_log": ["HP: 100 → 95", "时间: 08:00 → 08:05"],
714
+ "consistency_issues": [],
715
+ "output_text": "村长摘下烟斗,神情凝重...",
716
+ "post_turn_snapshot": {
717
+ "location": "村庄广场",
718
+ "player": { "hp": 95, "level": 1, ... }
719
+ }
720
+ }
721
+ ```
722
+
723
+ ### 用途
724
+
725
+ - **延迟分析**:测量 NLU 和叙事生成的各阶段耗时
726
+ - **意图准确率**:评估 NLU 解析正确率
727
+ - **一致性检测**:统计逻辑矛盾发生频率
728
+ - **剧情分支分析**:分析不同选择路径的分布
729
+
730
+ ---
731
+
732
+ ## 13. 评估框架
733
+
734
+ `evaluation/` 目录提供可复现的实验评估体系:
735
+
736
+ ### 评估维度
737
+
738
+ | 测试任务 | 数据集文件 | 评估指标 |
739
+ |---------|-----------|---------|
740
+ | NLU 意图准确率 | `intent_accuracy.json` | Accuracy(标注输入 vs 预测意图) |
741
+ | 状态一致性 | `consistency.json` | 一致性违规率 |
742
+ | 生成延迟 | `latency.json` | P50/P95 延迟(毫秒) |
743
+ | 剧情分支多样性 | `branch_divergence.json` | 相同状态不同选择→不同结果率 |
744
+
745
+ ### 运行评估
746
+
747
+ ```bash
748
+ cd evaluation
749
+ python run_evaluations.py --task intent # 只跑意图识别评估
750
+ python run_evaluations.py --task latency # 只跑延迟评估
751
+ python run_evaluations.py # 运行所有评估
752
+ ```
753
+
754
+ ---
755
+
756
+ ## 14. 项目文件结构
757
+
758
+ ```
759
+ StoryWeaver/
760
+ ├── app.py # 入口:Gradio UI + 事件协调(2006行)
761
+ ├── state_manager.py # 游戏状态模型与管理(2636行)
762
+ ├── story_engine.py # AI 叙事生成引擎(4074行)
763
+ ├── nlu_engine.py # 自然语言意图解析(431行)
764
+ ├── utils.py # Qwen API 封装 + JSON 工具(328行)
765
+ ├── combat_engine.py # 战斗结算(97行)
766
+ ├── scene_assets.py # 场景图片路径解析(31行)
767
+ ├── demo_rules.py # 游戏规则常量与选项构建(1410行)
768
+ ├── telemetry.py # 交互日志(81行)
769
+
770
+ ├── image/ # 场景图片(21张 PNG)
771
+ │ ├── 村庄广场.png
772
+ │ ├── 黑暗森林入口.png
773
+ │ ├── 村长老伯.png
774
+ │ └── ...(共21张)
775
+
776
+ ├── evaluation/ # 评估框架
777
+ │ ├── run_evaluations.py
778
+ │ └── datasets/
779
+ │ ├── intent_accuracy.json
780
+ │ ├── consistency.json
781
+ │ ├── latency.json
782
+ │ └── branch_divergence.json
783
+
784
+ ├── demo/
785
+ │ ├── scenarios.json
786
+ │ └── show_demo_checklist.py
787
+
788
+ ├── logs/interactions/ # 运行时生成的 JSONL 日志
789
+ ├── requirements.txt # Python 依赖
790
+ ├── .env.example # 环境变量模板
791
+ └── README.md # 项目概览文档
792
+ ```
793
+
794
+ ### 快速启动
795
+
796
+ ```bash
797
+ # 1. 安装依赖
798
+ pip install -r requirements.txt
799
+
800
+ # 2. 配置 API Key
801
+ cp .env.example .env
802
+ # 编辑 .env,填写 QWEN_API_KEY=sk-xxxxxx
803
+
804
+ # 3. 启动
805
+ python app.py
806
+ # 访问 http://localhost:7860
807
+ ```
808
+
809
+ ---
810
+
811
+ *本文档由 Claude Code 生成,描述 StoryWeaver v1.0 的技术实现细节。*