118 lines
5.2 KiB
Markdown
118 lines
5.2 KiB
Markdown
# 导出身体档案 — 指标趋势段 设计
|
||
|
||
> 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` 已能渲染新段落)
|
||
- 不做逐点列表 / 峰谷均值(本次只要一行摘要)
|
||
```
|
||
|