# 续报生成 — 救援进程报告生成流水线（supplement_report）

基于 **确定性统计 + 受控大模型润色** 的续报（救援进程报告）生成模块。输入院内 HIS 收治数据，输出符合公文规范的进程报告 markdown；报告数字由 `facts.py` 生成并经事实守门校验，**三、下一步工作留空**，同事件多次续报保留版本快照。

> 独立自包含模块，位于 `projects/supplement_report/`，**不依赖** `projects/yingji` 现有代码，不改动任何既有服务。需求来自 2026-08-14 与产品经理对齐。

## 当前基线

- 工具模型：DashScope `qwen-plus`（OpenAI 兼容端点，`enable_thinking=False`，temperature 0）。温度置 0 后同 case 连跑 5 次逐字一致，消除采样抖动。
- 测试集：**118 例冻结**（`testset/cases/`），含 9 例内部用例 + 100 例客户真实事件脱敏用例（`cust_*`，全量 100 事件 / 452 患者，姓名脱敏保留姓+X）+ 9 例对抗用例（`adv_*`，覆盖 12+科室/重复ID冲突/缺字段/未知状态/全死亡/全未匹配/组合异常/格式畸形日期/大规模holdout），覆盖普通流程与全部异常分支；金标准 `testset/golden/` 中客户 case 由独立同口径算法重算，对抗 case 由人工闭式核算 + `eval/verify_golden_three_way.py` 三方交叉验证落盘（`goldStatus=verified` + `crossCheckHash`，非构建脚本自声明）。
- 当前回归：118 例冻结集通过；最近一次真实模型运行中，110 例模型文本通过守门、8 例安全回退。该分布允许随采样波动，是发布门禁观测，不是泛化率。mutation 检出 297 个有效篡改，版本快照 17 项校验通过。
- 语言质量：最新 GLM judge 单字符协议全量复测完成 118/118 例、1401 轮无效 0，冻结集自动评审均分 `0.9841`。`measurementQuality=valid` 只说明评审链路可测；结果尚未经过人工盲评校准，不作为外部泛化或质量 KPI。详见 [`docs/evaluation_status.md`](docs/evaluation_status.md)。
- 性能：实测 2 并发 2.8s/例（qwen-plus 单次润色 + 模板拼接），满足 PRD:148（5min 内 2 并发）。

## 核心铁律

续报是要落进公文的事实报告，数字错一字就是事故。模块职责物理隔离：

1. **三、下一步工作留空**（不生成）。
2. **开头导语 + 一、基本情况** 走大模型润色；其余（标题、二、伤员收治明细、落款）用模板确定性拼接，**不过大模型**。
3. **大模型只润色语言，绝不创造事实数字**——所有数字由 `facts.py` 确定性计算后注入 prompt；模板段根本不进 LLM。

## 处理流程

```
HIS 事件信息 + 伤患数据（eventInfo + patients）
    │
    ▼
[Step 1] 解析输入 + 确定性统计          ──► facts（收治/死亡/存活/分级/科室聚合 + 异常预处理）
    │   facts.py：收治按 hisPatientId 去重、死亡按出院状态、分级由预检分诊映射
    │   异常预处理：BS313 退院撤销→不计收治 / BS321 召回→回存活组且死亡-1 / 未匹配→单列待核实不计死亡
    ▼
[Step 2] 大模型润色开头与基本情况        ──► lead + basic（仅语言组织，数字来自 facts）
    │   llm.py：DashScope qwen-plus，关 thinking；system prompt 铁律"不得增删数字"
    │   失败自动回退确定性文案（_fallback_lead / _fallback_basic），保证报告可出
    ▼
[Step 3] 模板拼接伤员明细与落款          ──► admission + signature + title（确定性，不进 LLM）
    │   generator.py：按科室聚合渲染明细行，姓名脱敏，落款含卫健委/医院
    │   三、下一步工作 = ""（留空）
    ▼
[Step 4] 组装全文 + 保存版本快照         ──► markdown 报告 + versionId/versionNo/prevVersionId
                                        版本快照（store.py，JSON+fcntl 锁，历史不可覆盖）
```

> **SSE 流式**：`/generate` 接口逐 step 推送 `step`（running/done + facts/source）→ `report`（完整结果）→ `done`（耗时），demo 体验页据此实时呈现统计→润色→组装全过程。

## 数据口径

续报统计走 **HIS 院内口径**：

| 维度 | 口径 |
|------|------|
| 收治总数 | 按 `hisPatientId` 去重；BS313 退院撤销（`dischargeCancel=true`）不计收治 |
| 死亡总数 | 按出院状态判定；BS321 召回患者（`recallStatus=recalled`）回存活组、从死亡扣除；未匹配患者不计死亡 |
| 伤情分级 | 预检分诊级别映射：危重→危重症 / 重症→重症 / 其余→轻症 |
| 科室聚合 | 按 `department` 聚合，每科室列明细行（序号/脱敏姓名/性别/年龄/分级/诊断） |
| 出院去向 | BS313 出院且去向含"转…院/医"标"已转院"、其它出院标"已出院"、在院/死亡不标 |

## 异常处理（PRD:121/144/147 四类边界）

续报输入来自 HIS，真实数据含召回、退院撤销、关联失败等异常，必须正确处理并标注：

| 异常类型 | 处理 | 对应 case |
|----------|------|-----------|
| BS321 召回 | 被召回患者回存活科室明细按在院统计、从死亡组扣除；缺数字段标注召回说明 | `recall_case`（23 项 check） |
| BS313 退院冲销 | `dischargeCancel=true` 患者不计收治；缺数字段标注退院撤销 | `recall_case` |
| 未匹配隔离 | `isMatched=false` 患者不计入院内死亡统计，二段单列"待核实伤员N名"组，附"不计入院内死亡统计"说明 | `unmatched_case`（24 项 check） |
| 出院去向标注 | 出院状态患者按去向标"已转院"/"已出院"，附明细行末尾如"（已转院）" | `disposition_case`（21 项 check） |

空患者 → "经初步核实，暂无收治人员信息，待补"；分级缺失 → "另有N人分级待核实"。

## API 接口

服务默认 `:8003`，所有接口前缀 `/api/supplement_report`。

| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/api/supplement_report/generate` | 生成续报（SSE 流式，逐 step 推送） |
| `GET` | `/api/supplement_report/presets` | 返回 118 例冻结测试集预设（demo 体验页用） |
| `GET` | `/api/supplement_report/versions/{event_id}` | 列出该事件所有历史版本摘要（按版本号升序） |
| `GET` | `/api/supplement_report/versions/{event_id}/latest` | 取最新版本完整报告 |
| `GET` | `/api/supplement_report/versions/{event_id}/{version_id}` | 取指定版本完整报告 |
| `GET` | `/api/supplement_report/diff/{event_id}?from=&to=` | 两版本数字演进对比（delta） |
| `GET` | `/api/supplement_report/health` | 健康检查 |

### 请求参数（`/generate`）

```json
{
  "requestId": "req-001",
  "eventInfo": {
    "eventId": "EVT-2026-0618-001",
    "eventName": "通州区"6·18"建筑坍塌事故",
    "eventCategory": "事故灾难",
    "eventTypeName": "工矿商贸事故",
    "eventTypeSmall": "建筑坍塌",
    "eventTime": "2026-06-18T14:32:00+08:00",
    "eventLevel": "III",
    "eventDesc": "...",
    "districtsName": "通州区",
    "detailLocation": "...",
    "isSensitive": "N",
    "planName": "",
    "planLevel": ""
  },
  "patients": [
    {
      "patientUuid": "P001",
      "name": "陈某",
      "age": 42,
      "sex": "男",
      "hisPatientId": "HIS-001",
      "visitId": "V1",
      "department": "急诊科",
      "admissionTime": "2026-06-18T15:00:00+08:00",
      "dischargeTime": null,
      "dischargeStatus": "在院",
      "dischargeDisposition": null,
      "diagnosisName": "多发伤、失血性休克",
      "triageLevel": "危重",
      "recallStatus": "",
      "dischargeCancel": false,
      "isMatched": true
    }
  ],
  "asOfTime": "2026-06-20T10:00:00+08:00"
}
```

> `patients` 可为空数组（对应无收治人员场景，报告落"暂无收治人员信息，待补"）。
> 异常字段：`recallStatus="recalled"`（BS321 召回）、`dischargeCancel=true`（BS313 退院撤销）、`isMatched=false`（未匹配待核实）。

### 响应（SSE 事件流）

```
event: step
data: {"step": 1, "name": "解析事件与伤患数据", "status": "running"}

event: step
data: {"step": 1, "name": "解析事件与伤患数据", "status": "done",
       "facts": {"admission_total": 20, "death_total": 2, "survivor_total": 18, ...}}

event: step
data: {"step": 2, "name": "大模型润色开头与基本情况", "status": "done", "source": "llm"}

event: step
data: {"step": 3, "name": "模板拼接伤员明细与落款", "status": "done"}

event: report
data: {"reportType": "supplement", "eventId": "...", "title": "...",
       "facts": {...}, "sections": {"lead": "...", "basic": "...", "admission": "...", "next_steps": ""},
       "signature": "...", "markdown": "# 关于...报告\n...",
       "versionId": "EVT-..._v1", "versionNo": 1, "prevVersionId": ""}

event: done
data: {"elapsedMs": 2800}
```

> `source` 字段：`llm` 表示大模型润色通过守门，`fallback` 表示回退确定性文案；具体原因见 `reason`。
> `versionError` 字段：版本存储失败时附带错误信息（不影响报告输出）。

## 版本快照（PRD:142 同事件多次续报保留历史）

`store.py` 本地 JSON + fcntl 锁实现，**历史不可覆盖**：

- 每次 `/generate` 自动存版本，`versionNo` 递增，`prevVersionId` 指向上一版。
- 同事件并发生成时 fcntl 锁串行化版本号分配，避免覆盖。
- 4 个查询路由：列版本 / 取最新 / 取指定 / 两版本 diff。
- `verify_versions.py` 17 项校验：v1/v2/v3 版本号与链路、历史只读、latest、diff delta 等。

## 评测与泛化边界

`eval/evaluate.py` 对 118 例冻结数据执行事实、结构、异常处理和输出一致性检查。本次真实模型
最近一次真实模型运行中 110 例保留模型润色、8 例触发安全回退；冻结集全部通过只说明已知回归未破坏，**不作为
泛化率或语言质量总分**。

`evaluate.py --mutation` 检出了 297 个有效篡改，另有 411 个变异因原样本不存在对应字段而记
N/A。守门专项测试还覆盖未知修饰语组合与包含“不/无”的合法科室实体：普通事实陈述只有在前缀
能被解析为明确肯定结构时才放行，未知结构默认回退。这提高了错误拒绝的泛化安全性，但可能让
新的合法句式回退，因此必须同时观察真实模型接受率。

GLM-5.2 语言 judge 已改用严格单字符 `A/B/C` 协议，并把每轮输出限制为 1 token，从生成端阻断
JSON 对象后的模板残片。最新全量落盘结果完成 `118/118` 例，`1401/1401` 轮可严格解析，
`measurementQuality.status=valid`、冻结集自动评审均分 `0.9841`；分维度为通顺度 `1.0000`、
公文语体 `0.9915`、信息完整 `0.9425`（`n=113`，5 例因源数据契约矛盾记 N/A）、洁净度
`1.0000`。旧 `0.5159` / 46.41% 无效率是已被替代的端点协议故障快照，不是文本质量回退。

`scoreReportable=true` 仅表示本轮评审链路技术上完整、可展示为**内部冻结集自动评审参考**；
冻结样本已参与修复，judge 也尚未经过人工盲评校准，因此不得解释为外部泛化率、人工质量结论
或“100%”。页面在展示分数时必须同步保留这一边界。

真正的泛化验收仍需要修复过程未见、人工冻结标注的外部留出集，并分别报告事实误接收率、合法
文本接受率、回退率、逐字段表现与人工语言盲评；不再用一个“全 1.0”概括。当前口径与下一步
验收要求见 [`docs/evaluation_status.md`](docs/evaluation_status.md)，历史修复记录见
[`docs/known_limitations.md`](docs/known_limitations.md)。

## 目录

```
projects/supplement_report/
├── README.md                   本文件
├── src/supplement_report/
│   ├── llm.py                  DashScope/Qwen OpenAI 兼容客户端（关 thinking，缺 key 抛异常）
│   ├── facts.py                确定性统计真源：收治去重/死亡/分级映射/科室聚合/异常预处理
│   ├── generator.py            组装：开头+基本情况 LLM 润色 + 模板拼接 + 下一步留空 + 回退文案
│   ├── app.py                  FastAPI SSE + 版本快照 4 路由（:8003）
│   ├── store.py                版本存储：JSON + fcntl 锁，历史不可覆盖
│   └── cli.py                  命令行入口：读测试输入 → 生成 → 输出
├── testset/
│   ├── build_cases.py          从数据源总览合成 9 例内部测试集
│   ├── build_customer_cases.py 从客户 100 事件脱敏合成 100 例 cust_* 测试集
│   ├── build_adversarial_cases.py 合成 9 例对抗用例 adv_*（重复ID/缺字段/全死亡/全未匹配/格式畸形日期/大规模holdout等）
│   ├── gen_golden.py           独立同口径算法为客户 case 重算金标准
│   ├── cases/*.json            118 例测试输入（9 内部 + 100 客户真实脱敏 cust_* + 9 对抗 adv_*）
│   └── golden/*.json           期望 facts 金标准（数字可回溯）
├── eval/
│   ├── evaluate.py             13 维冻结集回归 + mutation
│   ├── llm_judge.py            GLM-5.2 盲审 + 严格解析 + measurementQuality 门槛
│   ├── verify_golden_three_way.py 三方交叉验证（facts/golden/gen_golden，落盘 goldStatus+hash）
│   └── verify_versions.py      17 项版本快照校验
├── docs/
│   ├── design.md               技术设计母版
│   ├── design.html             设计文档 HTML 版
│   ├── evaluation_status.md    当前评测状态与外部留出集要求
│   └── known_limitations.md    历史修复记录与诚实边界
├── reports/
│   ├── work_report.html        工作汇报
│   └── ppt_one_page.html       一页 PPT
└── results/                    评测产物（gitignore）
```

## 快速开始

```bash
# 启动服务（:8003，SSE 流式）
cd <yingji>
source .venv/bin/activate
# 润色模型三项原子 profile（必填，见下方「模型配置」；或 cp .env.example .env 后填）
export SUPPLEMENT_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
export SUPPLEMENT_LLM_API_KEY=<your-key>
export SUPPLEMENT_LLM_MODEL=qwen-plus
PYTHONPATH=projects/supplement_report/src \
  python -m uvicorn supplement_report.app:app --host 0.0.0.0 --port 8003

# 生成单份续报（命令行）
python projects/supplement_report/src/supplement_report/cli.py \
  projects/supplement_report/testset/cases/tongzhou_collapse.json

# 调 SSE 接口
curl -N -X POST http://localhost:8003/api/supplement_report/generate \
  -H "Content-Type: application/json" \
  -d @projects/supplement_report/testset/cases/tongzhou_collapse.json

# 评测 118 例（13 维纯检查器 + mutation）
python projects/supplement_report/eval/evaluate.py projects/supplement_report/testset/cases/
# 独立语言质量 judge（GLM-5.2 盲审）
PYTHONPATH=src python projects/supplement_report/eval/llm_judge.py

# 对抗金标准三方交叉验证（落盘 goldStatus=verified + crossCheckHash）
python projects/supplement_report/eval/verify_golden_three_way.py

# 版本快照校验
python projects/supplement_report/eval/verify_versions.py
```

> 一键启动全套服务（emergency:8000 + duty:8001 + supplement_report:8003 + demo:8080）：`bash demo/run.sh`，设 `SUPPLEMENT_REPORT_ENABLED=0` 可跳过续报服务。

## 环境变量

| 变量 | 说明 | 必需 |
|------|------|------|
| `SUPPLEMENT_LLM_BASE_URL` | 润色端点 URL（OpenAI 兼容） | 是 |
| `SUPPLEMENT_LLM_API_KEY` | 润色端点 API Key | 是 |
| `SUPPLEMENT_LLM_MODEL` | 润色模型名 | 是 |
| `SUPPLEMENT_REPORT_ENABLED` | `demo/run.sh` 中是否启动续报服务，默认 `1` | 可选 |

> 三项原子 profile，与 emergency/duty 的 `EXP_TOOL_*` 一致：任一项已设置则三项必须同时给，部分配置直接抛错，不静默回退。模板见仓库根 [`.env.example`](../../.env.example)。

## 模型配置

润色模型在 `src/supplement_report/llm.py` 配置，统一走 OpenAI 兼容端点三项原子 profile：

```python
_PROFILE_ENV = ("SUPPLEMENT_LLM_BASE_URL", "SUPPLEMENT_LLM_API_KEY", "SUPPLEMENT_LLM_MODEL")
DEFAULT_MODEL = os.environ.get("SUPPLEMENT_LLM_MODEL", "qwen-plus")  # 仅用于 health 回显默认名
# 强制 enable_thinking=False（Qwen 思考模式会污染正文且变慢）
# temperature 0，超时 60s
```

切换润色模型（三种端点，改 `export` 后重启续报服务 :8003 即生效）：

```bash
# 1) DashScope qwen-plus（默认示例）
export SUPPLEMENT_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
export SUPPLEMENT_LLM_API_KEY=<dashscope-key>
export SUPPLEMENT_LLM_MODEL=qwen-plus

# 2) 自建 Qwen（与 emergency/duty 共用 EXP_TOOL 同款端点）
export SUPPLEMENT_LLM_BASE_URL=http://192.168.68.92:8986/v1
export SUPPLEMENT_LLM_API_KEY=Empty
export SUPPLEMENT_LLM_MODEL=Qwen2.5-32B-Instruct

# 3) 自建 GLM
export SUPPLEMENT_LLM_BASE_URL=http://10.33.1.184:13001/v1
export SUPPLEMENT_LLM_API_KEY=<glm-key>
export SUPPLEMENT_LLM_MODEL=GLM-5.2
```

> 评测/CLI 直接跑时若环境未 `source`，`llm.py` 会从仓库根 `.env` 读 `SUPPLEMENT_LLM_*`。
> LLM 失败或事实守门拒绝时自动回退 `_fallback_lead` / `_fallback_basic` 确定性文案，保证报告可出（`source="fallback"`，具体原因见 `reason`）。

## 后续维护

续报按"输入解析 / 确定性统计 / 受控润色 / 模板拼接 / 版本存储"分层，后面比较好改。原则：数字真源只在 `facts.py`，润色只改语言不改事实，模板段不进 LLM。

| 要改什么 | 改哪里 | 说明 |
|----------|--------|------|
| 统计口径 / 异常预处理 | `src/supplement_report/facts.py` | 收治去重、死亡判定、分级映射、召回/退院/未匹配预处理；改后同步更新金标准与评测 check |
| 润色 prompt / 铁律 | `src/supplement_report/generator.py` `_build_llm_prompt` | 仅改语言组织约束，不得放开数字生成；改后跑"润色段无越界大数字"check |
| 模板段（明细/落款/标题） | `src/supplement_report/generator.py` `_render_*` | 确定性拼接，不进 LLM；改后跑结构完整性 check |
| 接口 / SSE 事件 | `src/supplement_report/app.py` | 路由、事件结构；demo 体验页消费 `step`/`report`/`done` |
| 版本存储 | `src/supplement_report/store.py` | JSON + fcntl 锁；改后跑 `verify_versions.py` 17 项 |
| 评测维度 / 金标准 | `eval/evaluate.py` + `testset/golden/*.json` | 金标准数字与生成代码同源但独立校验，防过拟合 |

### 修改护栏

- **数字真源唯一**：所有数字只能来自 `facts.py`，LLM 与模板段不得增删；润色段"无越界大数字"check 必须保持通过。
- **三、下一步工作永远留空**：`next_steps = ""`，不得改为生成。
- **历史不可覆盖**：`store.py` 版本号递增 + fcntl 锁，已有版本文件冲突直接抛 `FileExistsError`。
- LLM 失败必须回退确定性文案，不能阻断报告输出。
- 改 `facts.py` 后必须同步更新 `testset/golden/*.json` 与 `eval/evaluate.py` 对应 check，并跑全量评测 + 版本校验。

## 技术栈

- **LLM**: DashScope/百炼 qwen-plus（OpenAI 兼容端点，`enable_thinking=False`）
- **统计真源**: `facts.py` 纯 Python 确定性计算（无 LLM 参与）
- **版本存储**: 本地 JSON + fcntl 文件锁
- **Web**: FastAPI + Uvicorn（SSE 流式）
- **Python**: 3.11
