fix simulation reporting and research resilience

This commit is contained in:
2026-09-28 15:43:23 +08:00
parent 23297be2d7
commit a428214c53
25 changed files with 1488 additions and 244 deletions
@@ -0,0 +1,270 @@
# 问题记录: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)