fix simulation reporting and research resilience
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user