Files
mirosociety/docs/问题记录/问题记录-2026-09-28-报告缺失系统性总结.md

271 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 问题记录: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)