# 问题记录:2026-09-28 — 报告缺失"系统性总结",概念无数据来源 > 状态:待处理 > 记录人:项目相关方 > 触发场景:跑完一次 30 天模拟(`sim_id=8a2c6953-224`,10 人 / 90 轮 / 2238 秒 / 无报错),拿到报告 PDF 后**读不懂**。 > 与已有记录的关系:第四份问题记录。前三份见 [目录](./) --- ## 一、背景与现象 30 天模拟正常跑完(`engine_finished` + `simulation_run_complete` 都在日志里,无 `pipeline_failed`),但生成的报告**读起来"不明不白"**: - 概念讲得含糊:"低冲突与高信任掩盖了深层不稳定"——读完不知道具体指哪个字段 - 怀疑有数据没被记录/没被带出来:"可能是不完整,或者有些该说的东西没说或者没记录" 本文件拆解这个"看不懂",并给出改造方案。 --- ## 二、先纠正一个误判:数据其实是全的 我最初怀疑"日志只存了 6 个指标,导致报告里其他概念找不到数据来源"。**这个判断是错的**,实际排查结果: ### 2.1 DB 里 14 个指标全存 [`WorldMetrics`](../backend/app/models/world.py#L30-L43) 定义了 14 个字段: ```python stability, prosperity, trust, freedom, conflict, brand_sentiment, purchase_intent, word_of_mouth, churn_risk, adoption_rate, information_spread, echo_chamber_index, rumor_distortion ``` [`store.save_metrics`](../backend/app/db/store.py#L231-L237) 用 `metrics.model_dump_json()` **全量序列化**,`get_metrics_history` 原样反序列化返回。 **所以 `conflict`(冲突)、`freedom`(自由度)这些"报告里提到但日志里没看到"的指标,DB 里一直有。** ### 2.2 只有 JSONL 日志是 6 个 [`sim_logger.log_round`](../backend/app/services/sim_logger.py#L153-L160) 只写 6 个 market/social 指标: ```python "metrics": { "brand_sentiment": ..., "purchase_intent": ..., "churn_risk": ..., "adoption_rate": ..., "stability": ..., "trust": ..., } ``` **结论**:`logs/*.jsonl` 缺 `conflict / prosperity / freedom / information_spread / echo_chamber_index / rumor_distortion` 是**日志抽样**行为,不是数据丢失。报告的数据源是 DB,**数据完整**。 ### 2.3 真正的问题定位 既然数据是全的,那"看不懂"只能是**报告生成 pipeline 的加工方式**问题。这才是本记录的核心。 --- ## 三、报告生成 pipeline 现状 [`narrator.generate_report`](../backend/app/services/narrator.py#L226-L486) 现在是 5 段式: | Pass | 输入 | 输出 | 实现位置 | |------|------|------|---------| | 0 | 全部叙事文本 | 每 30 天一个 epoch 摘要(历史学家口吻) | [L240-L288](../backend/app/services/narrator.py#L240-L288) | | 1 | 指标历史 + 行动 + 居民 | `metric_stats` / `agent_stats` / `segment_stats` / `inflection_points` / `confidence` | [L290-L297](../backend/app/services/narrator.py#L290-L297) | | 1b | metrics_history | forecast 因果 / 反事实 / 一致性 | [L299-L326](../backend/app/services/narrator.py#L299-L326) | | 2 | epoch 摘要 + metric_stats + inflection + agents | `executive_brief`(headline+summary)、`metric_explanations`、`insights`(3-5 条) | [L328-L380](../backend/app/services/narrator.py#L328-L380) | | 3 | segment_stats | 每 segment 的 reaction / objection / champion / 代表语 | [L382-L415](../backend/app/services/narrator.py#L382-L415) | | 4 | brief + insights + segments + scorecard | `action_items` / `risks` / `second_order_effects` | [L417-L441](../backend/app/services/narrator.py#L417-L441) | 最终 `report` 字典在 [L452-L478](../backend/app/services/narrator.py#L452-L478) 组装。 --- ## 四、"看不懂"的四个具体成因 ### 4.1 缺少"时间轴串联",产物是拼盘不是故事 Pass 0 产出了按时间顺序的 epoch 摘要,Pass 2/3/4 又产出了洞察、分群、建议,**但没有任何一个 pass 把这些按时间轴串成一篇完整叙述**。 最终结构是: ``` executive_brief.summary ← 1 段话 scorecard ← 9 行「指标: 评级 (值) — 解释」 insights ← 3-5 条 segments ← N 段 action_items / risks ← 各 3-5 条 ``` 读者要自己在脑子里把这些拼回去,所以"每个部分都懂,合起来不知道在讲什么"。 ### 4.2 `metric_explanations` 是逐指标独立生成,没有交叉引用 [`INTERPRET_METRICS_SYSTEM`](../backend/app/services/narrator.py#L46-L79) 要求"每个指标一句解释 WHY"。这导致: - 每条解释**孤立**,不说"trust 高是因为 conflict 低"这类**指标间关系** - 读者看到 trust=100/100 和 conflict=低/xx 两行,**不会自动联想到"这俩矛盾"** - 于是报告只能靠 Pass 2 的 `insights` 补一句文学化描述(如"掩盖了深层不稳定")——**这句在替数据做结论,但没给出推导过程** ### 4.3 `insights` 允许"不可追溯",且有 `surprise` 类型 `INTERPRET_METRICS_SYSTEM` 要求 insights "Things that were NOT predictable from the simulation setup alone",且 `evidence` 是**可选字段**([L71-L75](../backend/app/services/narrator.py#L71-L75))。 后果:LLM 可以写一条听起来很有洞察、但**没有具体 agent / day / action 支撑**的结论。这正是"讲得不明不白"的直接来源——**读者无法判断哪句有据、哪句是猜的**。 ### 4.4 没有任何地方标注"数据盲区" `report` 字典里([L452-L478](../backend/app/services/narrator.py#L452-L478))**没有 data gaps / limitations 字段**。 本次 30 天模拟的真实盲区(从 [`logs/8a2c6953-224.jsonl`](../../backend/logs/8a2c6953-224.jsonl) 尾部实测): | 盲区 | 实测值 | 说明 | |------|--------|------| | `adoption_rate` | 全程 0.0 | 90 轮无人 `PROPOSE_RULE` → "制度演化"叙事无数据支撑 | | `defect_count` | 每轮 0 | `has_defected` 一次性标记 + 无回退机制(见 [resolver.py:178](../backend/app/services/resolver.py#L178-L179))→ 叛徒事件结构性稀缺 | | `trust` | 恒定 1.0 | 衰减只由 `DEFECT` 触发([resolver.py:545-L549](../backend/app/services/resolver.py#L545-L549)),DEFECT=0 → trust 永不下滑 | | `purchase_intent` | 0.458→0.458 | 30 天内几乎不动 → "经济演化"在 30 天尺度不可见 | | 张力事件 | 15 次 | 但 `tension_event` 内容未进报告任何字段 | **报告完全没提这 5 点。** 读者只能靠自己去翻 JSONL 才发现——这就是"该说的东西没说"的实质。 --- ## 五、方案:在现有 pipeline 后加 Pass 5 / Pass 6 ### 5.1 Pass 5:系统性总结(核心) **目的**:把前面所有产物按时间轴 + 矛盾剖析 + 数据盲区串成一篇完整叙述。 **输入**(全部来自已有产物,不新增数据源): - `epoch_summaries`(Pass 0) - `scorecard` 含 `explanation`(Pass 2) - `insights`(Pass 2) - `segments_final`(Pass 3) - `action_items` / `risks` / `second_order_effects`(Pass 4) - `inflection_points` - `metric_stats` 全量 14 字段(DB 已有) - `meta`(agent_count / total_days / total_actions / rules / world_name) **建议 prompt 骨架**: ``` 你是 {world_name} 的历史学家,刚刚目睹了一场为期 {total_days} 天、 {agent_count} 人、{total_actions} 次行动的社会实验。 # 观测数据(全部) ## 时间轴摘要 {epoch_text} ## 指标全表(含 14 项原始值 + 评级 + 逐项解释) {metrics_full_table} ## 关键转折点 {inflection_summary} ## 居民快照 {agent_summary} ## 分群结果 {segments_text} ## 已有洞察 / 风险 / 建议 {insights_text} 请写一篇系统性总结,结构如下: 1. 【开场】150 字:一句话点出这场模拟最核心的故事 2. 【起承转合】按时间轴分段,每段 300-500 字:发生了什么、为什么、谁推动 3. 【关键人物】3-5 个对结果影响最大的居民,各 200 字:性格 → 选择 → 后果 4. 【矛盾剖析】300 字:把"表面读数"和"深层动力"分开,指出哪些是规则必然、哪些是涌现 5. 【数据盲区】200 字:明确列出这份数据**不能**回答的问题 硬性要求: - 每个断言必须能追溯到上面某个具体指标值、某个具名居民、或某个具体 day - 不许用"也许/可能/或许";要么写"数据显示 X",要么写"数据未覆盖" - 不要重复 executive_brief 的 verdict,但要呼应它 - 指标必须带原始数值出现(如"trust 从 0.50 到 1.00"),不许只说"很高" - 中文人名直接用原文 ``` **返回 JSON**: ```json { "narrative_summary": "完整正文(Markdown,1500-2500 字)", "chapters": [ {"title": "...", "body": "...", "evidence_days": [1, 15, 30]} ], "key_figures": [ {"name": "...", "arc": "...", "evidence_days": [3, 20]} ], "contradictions": [ {"surface": "...", "underlying": "...", "evidence": "..."} ], "data_gaps": [ {"gap": "...", "why_it_matters": "...", "suggested_fix": "..."} ] } ``` `data_gaps` 字段是**这次改造的关键收益**——它把第四节列的 5 个盲区变成报告的正式输出,读者不用再自己去翻日志。 ### 5.2 Pass 6:开场旁白(可选,营造"故事感") **目的**:你提到的"像旁白一样讲完这个故事"。 **输入**:`executive_brief.headline` + top3 居民 + top3 转折点 **prompt 骨架**: ``` 你是 {world_name} 的旁白者,为一份模拟报告写开场词。 核心事件:{headline} 主线人物:{top3_agents_with_traits} 关键转折:{top3_inflections} 用 300 字写一段旁白: - 第一句必须是一个具体画面或场景(有人/有地方/有动作),不要"在这个社会中" - 中间三句讲清起承转合 - 最后一句留一个未解的问题 - 不解释数据,不下结论,不出现指标名 ``` ### 5.3 前端改动 [ReportView.vue](../frontend/src/views/ReportView.vue) 增加两个区块: - `narrative_summary`(全文渲染,支持 Markdown) - `chapters`(时间轴折叠列表,点击跳转到对应 day) - `data_gaps`(**高亮显示**,建议用警示色,因为这是"诚实度"卖点) `key_figures` 和 `contradictions` 可折叠,默认收起。 --- ## 六、改动量评估 | 方案 | 改动 | LLM 调用增量 | 效果 | |------|------|-------------|------| | **A. 只加 Pass 5** | narrator.py +100 行、1 个新 prompt、ReportView.vue +40 行 | +1 次(约 10-20s) | 报告从拼盘变故事,且带数据盲区 | | **B. A + Pass 6** | 再 +40 行 | +1 次 | 额外开场旁白,阅读体验更好 | | **C. A + epoch 粒度细化** | epoch 从 30 天改 7 天 | +3~4 次 | 时间轴更细,但收益递减 | | **D. 修 sim_logger 指标缺失** | sim_logger.py +4 行 | 0 | 只影响日志可观测性,**不影响报告**(报告读 DB) | **建议先做 A**,验证效果后再决定要不要 B/C。D 是独立的可观测性改进,成本极低可顺手做。 --- ## 七、待澄清 - Pass 5 的 1500-2500 字对 30 天模拟够吗?长模拟(365 天)是不是要按 epoch 动态伸缩? - `data_gaps` 要不要让 LLM 生成,还是**用代码硬规则检测**(如"adoption_rate 全程为 0 → 报为盲区")?后者更可靠但覆盖不全 - `insights.evidence` 是否应改为**必填**,LLM 填不出证据就不让出该 insight?这样能根治"讲得不明不白" - 报告 PDF 是渲染型(pypdf 抽不出文字),Pass 5 的 Markdown 需确认导出链路是否支持 --- ## 八、相关代码位置 - 报告生成主流程:[`backend/app/services/narrator.py` `generate_report`](../backend/app/services/narrator.py#L226-L486) - Pass 2 指标解释 prompt:[`backend/app/services/narrator.py` `INTERPRET_METRICS_SYSTEM`](../backend/app/services/narrator.py#L46-L79) - Pass 3 分群 prompt:[`backend/app/services/narrator.py` `SEGMENT_ANALYSIS_SYSTEM`](../backend/app/services/narrator.py#L81-L110) - 纯统计计算:[`backend/app/services/report_analyzer.py`](../backend/app/services/report_analyzer.py) - 指标定义(14 项):[`backend/app/models/world.py` `WorldMetrics`](../backend/app/models/world.py#L30-L43) - 指标持久化:[`backend/app/db/store.py` `save_metrics` / `get_metrics_history`](../backend/app/db/store.py#L231-L243) - JSONL 日志(只 6 项):[`backend/app/services/sim_logger.py` `log_round`](../backend/app/services/sim_logger.py#L153-L160) - 报告前端:[`frontend/src/views/ReportView.vue`](../frontend/src/views/ReportView.vue) - 本次模拟日志:[`backend/logs/8a2c6953-224.jsonl`](../../backend/logs/8a2c6953-224.jsonl)