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

5.2 KiB
Raw Blame History

导出身体档案 — 指标趋势段 设计

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: DoublelastValue: Double
  • firstDate: DatelastDate: 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(纯函数,可单测)

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(或反之,跨界)
    • countfirstDatelastDaterange(取末点的 range)
  5. 排序:flagged 优先,其次按 lastDate 倒序。
  6. 返回 [TrendSummary]

数值解析复用现有方式(Double(indicator.value));解析失败的点跳过,若有效点 <2 则该 series 不出趋势。

3. 接入 HealthExportService

  • Snapshottrends: [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 自然为空,不追加。

## 指标趋势 段渲染:

## 指标趋势
⚠️ 收缩压 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 / HealthExportDetailViewMarkdownView 已能渲染新段落)
  • 不做逐点列表 / 峰谷均值(本次只要一行摘要)