Files
kangkang/docs/superpowers/specs/2026-06-07-export-indicator-trends-design.md
link2026 074d99715d docs: 导出身体档案指标趋势段设计 spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:31:54 +08:00

118 lines
5.2 KiB
Markdown
Raw 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-06-07 · 在「导出身体档案」(`HealthExportService`)的输出里,为本次就诊相关、且有历史记录的指标补一段确定性计算的趋势摘要。
## 背景
当前导出是「快照式」:`HealthExportService.retrieve()` 在时间窗内每个指标只取最近一条,`serializeData()` 序列化成单点数值(name/value/unit/range/status/date),交给 LLM 拼成「## 关键指标」一段。医生看不到指标随时间的变化方向。
需求:导出要带上**相关指标的趋势信息**(同一指标多次记录的变化)。
## 决策(已与用户确认)
| 维度 | 决定 |
|---|---|
| 覆盖范围 | 本次就诊相关(命中关键词或异常)且时间窗内有 **≥2 次**记录的指标 |
| 粒度 | 一行摘要(首值→末值 + 方向箭头 + 时间跨度 + 次数) |
| 生成方式 | **确定性计算**(模板拼装,不经 LLM),与 `ReportCompareService` 同思路,零编造风险 |
| 呈现位置 | LLM 输出 6 段之后,**追加**独立一段 `## 指标趋势`;无数据则整段省略 |
## 架构
```
retrieve() ──► 全量 in-window 指标(裁剪前) ┐
└─► 相关指标集(裁剪后,决定哪些 series 出趋势) ┤
ExportTrendBuilder.build(...) → [TrendSummary]
Snapshot.trends ──► export() 在 completed 前追加 "## 指标趋势"
```
LLM 链路(prompt / `serializeData`)**完全不变**——趋势不进 JSON,LLM 不知情。
## 组件
### 1. `TrendSummary`(值类型)
一个 series 的趋势结果。字段:
- `title: String` — 显示名(如「收缩压」「血压」)
- `unit: String`
- `firstValue: Double``lastValue: Double`
- `firstDate: Date``lastDate: Date`
- `count: Int` — 时间窗内记录次数
- `direction: Direction`(`.up` / `.down` / `.flat`)
- `range: String` — 参考范围原文(可空)
- `flagged: Bool` — 末值仍异常 **或** 跨越参考范围边界,为真时行首加 `⚠️`
方法 `line() -> String`,一行中文,格式:
```
收缩压 152→138 mmHg ↓(参考 90-140近 21 天 4 次
```
- 方向箭头:`.up``↑``.down``↓``.flat``→`
- `flagged` 为真前缀 `⚠️ `
- `range` 为空时省略「(参考 …)」括号
- 数值用与现有指标一致的格式化(去掉无意义小数;血压等整数不带小数点)
> 血压合并行:`title` = 「血压」,数值写成「收缩/舒张」对,如 `血压 152/96→138/88 mmHg ↓…`;方向以收缩压为准。
### 2. `ExportTrendBuilder`(纯函数,可单测)
```swift
enum ExportTrendBuilder {
static func build(allInWindow: [Indicator],
relevant: [Indicator]) -> [TrendSummary]
}
```
逻辑:
1. **确定相关 series**:从 `relevant` 收集 series 标识(优先 `seriesKey`,无则 `name|unit`)。
2. **分组全量点**:把 `allInWindow` 按同一 series 标识分组;血压 `bp.systolic` + `bp.diastolic` 归到合成 series「血压」。
3. **过滤**:只保留(a)属于相关 series、(b)点数 ≥2 的组。
4. 每组按 `capturedAt` 升序,取首/末点,算:
- `direction`:相对变化 `|last-first|/first`,<5% → `.flat`,否则按符号 `.up`/`.down`(first 为 0 时退化按绝对差判定)
- `flagged`:末点 `status != .normal`,或首点 normal 而末点非 normal(或反之,跨界)
- `count``firstDate``lastDate``range`(取末点的 range)
5. 排序:`flagged` 优先,其次按 `lastDate` 倒序。
6. 返回 `[TrendSummary]`
数值解析复用现有方式(`Double(indicator.value)`);解析失败的点跳过,若有效点 <2 则该 series 不出趋势。
### 3. 接入 `HealthExportService`
- `Snapshot``trends: [TrendSummary]`
- `retrieve()`:在现有第 268 行 fetch 全量 in-window 指标后,保留该全量列表;裁剪逻辑不变得到 `indicators`(相关集);调用 `ExportTrendBuilder.build(allInWindow: 全量, relevant: indicators)` 填入 `Snapshot.trends`
- `serializeData()`:**不改**(趋势不进 LLM)。
- `export()`:在发出 `completed` 事件、把内容存进 `HealthExport.content` 之前,若 `snapshot.trends` 非空,把 `## 指标趋势` 段追加到 LLM markdown 末尾。空数据兜底路径(`isEffectivelyEmpty`)trends 自然为空,不追加。
`## 指标趋势` 段渲染:
```markdown
## 指标趋势
⚠️ 收缩压 152→138 mmHg ↓(参考 90-140近 21 天 4 次
空腹血糖 6.8→6.2 mmol/L ↓(参考 3.9-6.1),近 28 天 3 次
```
## 测试
`ExportTrendBuilder.build` 是纯函数,单测覆盖:
- 升 / 降 / 平稳(阈值边界)方向判定
- 血压双 series 合并成一行
- 点数 <2 的 series 被过滤
- 不相关 series(不在 relevant 集)被过滤
- 跨参考范围边界 → `flagged = true`
- 数值无法解析的点被跳过
## 不做
- 不改 LLM prompt / `serializeData`(零编造风险的前提)
- 不引入 embedding、不加新颜色/字体 token
- 不改导出 UI 布局(仅输出内容多一段;`HealthExportSheet` / `HealthExportDetailView``MarkdownView` 已能渲染新段落)
- 不做逐点列表 / 峰谷均值(本次只要一行摘要)
```