docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档 补充了关于导出摘要出现虚构病例问题的详细分析和修复方案, 包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。 ```
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.swift 的 ModelContainer(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 条 fakeHealthExport
真机验收(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 段模板上凭训练先验脑补完整病例(对「只用数据/缺失写无记录」这类约束遵循差)。
修复(三层,客户端硬保证为主):
- 检索:
retrieve改为——有症状词→按词过滤(保留隐私);无症状词→纳入时间窗内最近 5 条日记,确保真实记录进 prompt。 - 空数据硬兜底:
isEffectivelyEmpty判定无任何记录且 profile 空时,跳过 LLM,用fallbackReport产出确定性「6 段全无记录、主诉仅照搬原话」的摘要,从根上杜绝空数据编造。 - prompt 重写:从「撰写」改为「抽取/搬运」框架;反编造铁律首尾各一遍;加一条稀疏 few-shot 教模型「缺失写无记录、数值原样照搬」。
残留限制:部分数据(如仅 1 条日记)仍走 LLM,强约束 + few-shot 大幅降低但不能 100% 杜绝小模型臆造;后续可加生成后数值校验。