Files
kangkang/docs/superpowers/specs/2026-05-27-export-health-profile-design.md
link2026 7ad41c5f09 ```
docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档

补充了关于导出摘要出现虚构病例问题的详细分析和修复方案,
包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。
```
2026-05-30 20:06:12 +08:00

19 KiB

导出身体档案 — 设计文档

日期:2026-05-27 (W2) 作者:link2026 + Claude 关联卖点:#1 影像档案系统、#2 100% 本地、#3 本地 RAG 长期记忆、#4 隐私三件套、#6 Live Activity tok/s 优先级:P0(打通 RAG 链路 + demo 主要演示场景)


1. 一句话定位

在「记录」Tab 顶部增加「导出身体档案」入口,用户输入自然语言主诉(如「我感冒 3 天,把最近一个月给医生看」),完全本地的两段式 RAG 把 SwiftData 里相关的指标 / 报告 / 症状 / 日记 / 个人资料检索并生成给医生看的 Markdown 摘要,可复制、分享、查看历史。


2. 用户故事

周日晚上,我感冒第 3 天还没好。明早要去社区医院,医生只有 5 分钟问诊,我想把过去一个月的体温记录、上次体检的关键异常项、在服的降压药、家族过敏史一次性整理出来给医生。我不想把这些数据上传到任何云。

成功标准:

  • 输入 prompt → 30 秒内出现首字 → 90 秒内完整生成
  • 输出 Markdown 包含主诉 / 患者背景 / 近期症状 / 关键指标 / 在服药与过敏 / 患者疑问
  • 一键复制到微信发给医生,或直接 AirDrop / 邮件分享
  • 重启 App 后能看到历史导出

3. 范围

:

  • 记录 Tab 右上角 toolbar「导出」按钮
  • ArchiveListView 顶部「我的导出」横向卡区(有历史时显示,前 3 条 + 查看全部)
  • 全屏 sheet:prompt 输入 / Phase 指示 / 流式 Markdown / 完成后复制+分享+重新生成
  • 历史列表页 + 详情页
  • 两段式 RAG 链路:Qwen3-1.7B 抽意图 → SwiftData 结构化检索 → Qwen3-1.7B 生成 Markdown
  • HealthExport @Model + Schema 注册
  • 引用回链(referencedXxxIDs,W3 再做点击跳转)

不做:

  • embedding / 向量检索
  • 跨设备同步、云端备份
  • PDF 导出(W6 余力再说)
  • 给医生的诊断建议 / 用药建议(红线 §10.1)
  • 自动定期导出(此版无 schedule)

4. 架构

┌─ UI ─────────────────────────────────────────────────────┐
│ ArchiveListView                                          │
│  ├─ .toolbar trailing: "导出" 图标按钮                  │
│  └─ 顶部横向卡区 HealthExportRecentStrip(有历史时显示)│
│         │                                                │
│         └─→ HealthExportSheet (full-screen cover)        │
│              ├─ prompt TextEditor                        │
│              ├─ Phase 状态条                             │
│              ├─ Markdown 流式渲染                        │
│              └─ Actions: 复制 / 分享 / 重新生成          │
│                                                          │
│ HealthExportListView (NavigationLink "查看全部")         │
│   └─ 全部历史(@Query DESC)→ HealthExportDetailView    │
└────────────────────────────────────────────────────────┘
                            ↑ Event 流
┌─ Service ────────────────────────────────────────────────┐
│ HealthExportService (struct, DI ModelContext + Runtime) │
│   func export(prompt:) -> AsyncThrowingStream<Event>    │
│   Event = .phaseChanged(Phase) | .token(TokenChunk)     │
│         | .completed(HealthExport) | .failed(Error)     │
└────────────────────────────────────────────────────────┘
                            ↑ 串行排队
┌─ AI 层 (已存在) ──────────────────────────────────────────┐
│ AIRuntime(actor 单例)→ LLMSession 串行两次调用        │
└────────────────────────────────────────────────────────┘
                            ↑ 检索
┌─ Persistence (SwiftData) ────────────────────────────────┐
│ Indicator / Report / Symptom / DiaryEntry /             │
│ UserProfile / HealthExport(新增)                       │
└────────────────────────────────────────────────────────┘

红线对齐(CLAUDE.md §10):

  • UI 不直接调 AIRuntime,只与 HealthExportService 通讯
  • AIRuntime 仍是 actor 单例,两段调用在它的队列内串行,与 CaptureService / 未来的 AskService 互不抢占 GPU
  • 两个 prompt 都带 few-shot + 失败回退
  • 不引入云服务、不自实现密码学、不重构现有 Tab/RecordSheet

5. 数据模型

新增 Models/HealthExport.swift:

import Foundation
import SwiftData

@Model final class HealthExport {
    var id: UUID = UUID()
    var prompt: String = ""              // 用户原始输入
    var content: String = ""             // 生成的 Markdown 全文
    var createdAt: Date = .now

    // 引用回链(对齐 §3.3)
    var referencedIndicatorIDs: [UUID] = []
    var referencedReportIDs: [UUID] = []
    var referencedSymptomIDs: [UUID] = []
    var referencedDiaryIDs: [UUID] = []

    // 意图抽取快照(供"重新生成"复用,不再调一次 LLM)
    var inferredTimeFromDate: Date?
    var inferredTimeToDate: Date?
    var inferredIntent: String?

    // demo 卖点凭证
    var modelTag: String = "Qwen3-1.7B-4bit"
    var decodeRate: Double = 0           // 末次 tok/s

    init() {}
}

Schema 注册:App/KangkangApp.swiftModelContainer(for:) 加入 HealthExport.self(增表是 SwiftData 兼容变更,无需手写迁移)。

为什么 referenced*IDs[UUID] 而不是 SwiftData 关系: 导出是历史快照,源 Indicator / Report 可能后续被用户永久删除(§10.4);弱关联避免 cascade 影响历史导出本身。点击跳转时,源记录若已不存在,UI 显示「记录已删除」灰态。


6. 状态机 + 数据流

HealthExportService.export(prompt:)AsyncThrowingStream<Event, Error>:

enum Phase: String {
    case extractingIntent   // 理解意图
    case retrieving         // 检索数据
    case generating         // 撰写报告
    case completed
}

enum Event {
    case phaseChanged(Phase)
    case token(TokenChunk)
    case completed(HealthExport)
    case failed(Error)
}

流程:

.idle
  │ user tap 生成
  ▼
phaseChanged(.extractingIntent)
  │ LLMSession.generate(prompt: INTENT_PROMPT, maxTokens: 120)
  │   失败 → 回退到默认 {time_range_days: 30, keywords: [], symptom_keywords: []}
  ▼
phaseChanged(.retrieving)
  │ 同步 SwiftData 查询:
  │   - Indicator   where capturedAt ∈ [from, to], 可选按 keyword 过滤 name/seriesKey
  │   - Report      where reportDate  ∈ [from, to]
  │   - Symptom     where startedAt <= to AND (endedAt == nil OR endedAt >= from)
  │   - DiaryEntry  where createdAt ∈ [from, to] AND content contains any symptom_keyword
  │                 (privacy 过滤:无主诉相关关键词的日记不入 prompt;
  │                  若 symptom_keywords 为空,则一律不包含日记 —— 安全默认)
  │   - UserProfile 单例,无条件包含
  ▼
phaseChanged(.generating)
  │ 拼 GENERATION_PROMPT(把上一步结果序列化为简短结构)
  │ LLMSession.generate(prompt:, maxTokens: 1024)
  │   for token in stream: yield .token(chunk)
  ▼
phaseChanged(.completed)
  │ build HealthExport(prompt, content, referencedIDs, inferred*, decodeRate)
  │ modelContext.insert + try modelContext.save()
  ▼
.completed(healthExport)

取消语义:UI 关闭 sheet → stream 被取消 → 中间态不入库。

与 AIRuntime 互斥:HealthExportService 在 AIRuntime 的 actor 函数里调度两次 LLM 调用;若此时 CaptureService 正在跑 VL,自然在 actor 队列里等待。Phase indicator 在 UI 上显示「排队中」(可选,W3 polish)。


7. Prompt 设计

两个 prompt 都放在 AI/Prompts/HealthExportPrompts.swift,带 2 个 few-shot。

7.1 意图抽取(Qwen3-1.7B,~120 token 输出)

你是健康数据助手。读用户的请求,只输出严格 JSON,不要任何解释或 Markdown。

字段:
{
  "time_range_days": int,             // 时间窗,默认 30
  "keywords": [string],               // 指标关键词(中文,如"血压"/"血糖"/"体温")
  "symptom_keywords": [string],       // 症状关键词
  "intent": string                    // 简短意图标签
}

示例 1:
User: 我感冒3天了,要把最近一个月的健康情况给医生看
Output: {"time_range_days":30,"keywords":["体温","血压","脉搏"],"symptom_keywords":["感冒","咳嗽","咽喉痛","发烧"],"intent":"cold_consult"}

示例 2:
User: 我最近血糖好像不稳,把上次体检前后的化验单整理一下
Output: {"time_range_days":90,"keywords":["血糖","糖化血红蛋白","胰岛素"],"symptom_keywords":[],"intent":"glucose_review"}

User: {{USER_PROMPT}}
Output:

解析容错:

  • 非 JSON → 抓 {…} 之间的子串再试一次
  • 仍失败 → 用默认 {30, [], []},继续流程,不报错给用户

7.2 报告生成(Qwen3-1.7B,maxTokens 1024)

你正在帮患者撰写一份给社区医生看的就诊摘要。
要求:
- 输出 Markdown,严格按下方结构
- 只用「数据」中提供的信息,数据缺失就写"无记录"
- 不要给诊断意见、不要给用药建议、不要写"建议就医"
- 引用具体数值时保留单位和参考范围
- 全文中文,简洁,医生 30 秒能扫完

结构:
# 就诊摘要 — {{INTENT_LABEL_CN}}
## 主诉
## 患者背景
## 近期症状(按时间倒序)
## 关键指标(异常项优先)
## 在服药与过敏
## 患者疑问

数据:
{{SERIALIZED_DATA_JSON}}

患者原话:{{USER_PROMPT}}

现在生成:

SERIALIZED_DATA_JSON 结构(给 LLM 看的精简结构):

{
  "profile": {
    "age": 38, "sex": "男", "height_cm": 172,
    "allergies": ["青霉素"],
    "chronic": ["高血压(2 年)"],
    "family_history": ["父亲冠心病"],
    "current_meds": ["缬沙坦 80mg qd"]
  },
  "symptoms": [
    {"name": "感冒", "started": "2026-05-24", "severity": 2,
     "ongoing": true, "note": "鼻塞、低烧"}
  ],
  "indicators": [
    {"name": "收缩压", "value": 142, "unit": "mmHg", "range": "<140",
     "status": "high", "date": "2026-05-26"}
  ],
  "reports": [
    {"title": "年度体检", "type": "physical", "date": "2026-04-12",
     "institution": "瑞金医院"}
  ],
  "diaries": [
    {"date": "2026-05-25", "excerpt": "夜里两点醒了一次,头痛 7/10"}
  ]
}

8. UI 详细设计

8.1 ArchiveListView 改动

  • toolbar trailing 加按钮:Image(systemName: "doc.text.below.ecg") "导出"
  • List 顶部插入 HealthExportRecentStrip()(若 @Query HealthExport 非空)
  • 横向卡区,3 条最近导出 + 末尾「查看全部 →」卡,点击进入 HealthExportListView

8.2 HealthExportSheet (full-screen cover)

┌──────────────────────────────────────────────┐
│  ✕  导出身体档案          本地·永不上传     │  Header
├──────────────────────────────────────────────┤
│  例:我感冒3天了,把最近一个月给医生看      │  Hint
│ ┌──────────────────────────────────────────┐ │
│ │ (多行 TextEditor,~6 行)               │ │
│ └──────────────────────────────────────────┘ │
│              [ 生成报告 ]                    │  TjPrimaryButton
├──────────────────────────────────────────────┤
│  ●─○─○   理解意图                          │  Phase pill,
│                                              │  生成时显示
│  本地推理 · Qwen3 · 24.3 tok/s              │
├──────────────────────────────────────────────┤
│  # 就诊摘要 — 感冒就诊                      │
│  ## 主诉                                     │  Markdown 流式
│  患者男,38 岁……                            │  渲染(原生
│  …(打字机效果)…                          │  Text(LocalizedStringKey))
│                                              │
├──────────────────────────────────────────────┤
│  [ 复制 ]  [ 分享 ]  [ 重新生成 ]          │  完成后才显示
└──────────────────────────────────────────────┘
  • 「分享」用系统 ShareLink(item: content),导出纯文本
  • 「重新生成」复用同一 prompt + inferred* 字段,跳过意图抽取,直接走 retrieving + generating
  • 持久化时机:.completed 事件触发时由 Service 立即 insert + save;sheet 关闭只是 dismiss 视图,不再写库
  • 生成中按 ✕ → 取消 stream → 不入库;已生成完成后按 ✕ → 仅 dismiss(数据已在库中)

8.3 HealthExportListView

简单的 List + @Query(sort: \.createdAt, order: .reverse),每条显示:

  • 标题:HealthExport.prompt 截断到 60 字
  • 副标题:relativeDate(createdAt) + tok/s 标签
  • 滑动删除

8.4 HealthExportDetailView

  • 只读 Markdown(复用 sheet 的渲染组件)
  • 顶部信息条:生成时间 / 模型 tag / tok/s
  • toolbar:复制 / 分享 / 删除
  • W3 再补:referenced*IDs 转 Pill,点击跳源记录(此 spec 不阻塞)

9. 错误处理

情况 行为
模型未就绪 toolbar 按钮置灰 + 副标题「模型未就绪,前往下载」(对齐 §4)
意图抽取 JSON 解析失败 默认 {30 days, [], []} 兜底,流程继续,不报错给用户
SwiftData 查询为空 数据段填 "无记录",LLM 仍生成结构化"无明显异常"摘要
生成 stream 中途取消 Service 抛 CancellationError,UI 显示「已取消」,不入库
生成超时 (>120s) Task.withTimeout 超时取消,UI 同取消逻辑
LLM 抛错(显存等) UI 显示「生成失败:{msg}」+ 重试按钮
modelContext.save 失败 仅日志,UI 仍展示文本,提示「保存失败,请重试」

安全: 全程不调用任何网络;HealthExport 持久化继承 §6 的 .completeFileProtection


10. 测试策略

单元(HealthExportServiceTests):

  • mock AIRuntime 协议(新增 protocol AIRuntimeProtocol,actor 单例符合该协议)
  • 给定固定 SwiftData in-memory + 已知 Indicator/Symptom → 验证 referencedIDs 正确
  • 意图抽取返回非 JSON → 验证回退到默认 30 天
  • 验证 Phase 转换顺序:.extractingIntent → .retrieving → .generating → .completed
  • 取消语义:在 .generating 阶段取消 → 不入库

Preview:

  • HealthExportSheet 用 mock service 吐预设 Markdown(打字机视效在 Preview 即可看到)
  • HealthExportListView 用 3 条 fake HealthExport

真机验收(W3 末):

  • 在 16 inch M3 Max 模拟器上跑通(simulator 走 CPU,慢但能跑通流程)
  • 真机 iPhone 15 Pro:首字 ≤ 10s,完整生成 ≤ 60s,tok/s ≥ 20
  • 关 WiFi + 飞行模式仍能正常生成(隐私三件套 demo 关键)

11. 与现有/未来代码的关系

  • 复用:AIRuntime / LLMSession / TokenChunk / Tj.* Design System
  • 铺路:HealthExportService 的两段式 RAG 工程模式直接复用给 W3 的 AskService(只需替换 generation prompt + 输出形态)
  • 不冲突:CaptureService 在 AIRuntime 队列里和本服务串行;两者不会同时占 GPU
  • 不影响:ArchiveListView / RecordSheet / 现有 7 个 @Model 都不需要重构

12. 取舍记录

决策 选择 拒绝的方案 理由
入口位置 「记录」Tab toolbar RecordSheet 加一项 语义:RecordSheet 是「写入」,导出是「读出」
数据范围 Indicator+Report+Symptom+Profile+Diary 仅 Indicator+Report 「感冒 3 天」需要 Symptom;医生需要 Profile;Diary 由 LLM 关键词过滤后入 prompt,降低隐私风险
历史位置 ArchiveListView 顶部横向卡区 + 查看全部 「我的」Tab 加历史入口 路径更短;符合「记录 Tab=身体档案」语义
Pipeline 严格两段式 RAG 单段 LLM / 模板化 准确性 + 复用给 AskService + demo 卖点 #3
Markdown 渲染 SwiftUI 原生 Text(LocalizedStringKey) 第三方 Markdown 库 YAGNI;W6 polish 时再评估
referenced 关联 [UUID] 弱关联 SwiftData 关系 历史快照 vs 源记录可被永久删除
Live Activity 此版只在 Service 暴露 decodeRate,UI 显示数字 此版直接接 ActivityKit W5 真机阶段统一接,与 AskService 共用一套 Activity

13. 排期估算(放在 W2 末 ~ W3 初)

步骤 工作量
HealthExport @Model + Schema 注册 0.5h
HealthExportPrompts(两个 prompt + few-shot 调试) 2h
HealthExportService(状态机 + 两段调用 + 检索) 4h
HealthExportSheet(输入 + Phase + 流式渲染 + 三按钮) 3h
ArchiveListView toolbar + RecentStrip 1.5h
HealthExportListView + DetailView 1.5h
单元测试 + 真机验收 2h
合计 ~14h ≈ 2 个工作日

也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。


14. 修订记录:防编造加固(2026-05-30)

现象:导出摘要出现整份虚构病例(疲劳/盗汗/血红蛋白98/阿司匹林…),不符任何真实记录。

根因(双重):① §数据范围里「Diary 由关键词过滤后入 prompt」在泛化请求(无症状词,如「最近身体异常」)下把日记全部清空 → 真实记录没进 prompt;② 数据稀疏时,1.7B 在固定 6 段模板上凭训练先验脑补完整病例(对「只用数据/缺失写无记录」这类约束遵循差)。

修复(三层,客户端硬保证为主):

  1. 检索:retrieve 改为——有症状词→按词过滤(保留隐私);无症状词→纳入时间窗内最近 5 条日记,确保真实记录进 prompt。
  2. 空数据硬兜底:isEffectivelyEmpty 判定无任何记录且 profile 空时,跳过 LLM,用 fallbackReport 产出确定性「6 段全无记录、主诉仅照搬原话」的摘要,从根上杜绝空数据编造。
  3. prompt 重写:从「撰写」改为「抽取/搬运」框架;反编造铁律首尾各一遍;加一条稀疏 few-shot 教模型「缺失写无记录、数值原样照搬」。

残留限制:部分数据(如仅 1 条日记)仍走 LLM,强约束 + few-shot 大幅降低但不能 100% 杜绝小模型臆造;后续可加生成后数值校验。