# 导出身体档案 — 设计文档 **日期**: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 = .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`: ```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`: ```swift 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 输出) ```text 你是健康数据助手。读用户的请求,只输出严格 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) ```text 你正在帮患者撰写一份给社区医生看的就诊摘要。 要求: - 输出 Markdown,严格按下方结构 - 只用「数据」中提供的信息,数据缺失就写"无记录" - 不要给诊断意见、不要给用药建议、不要写"建议就医" - 引用具体数值时保留单位和参考范围 - 全文中文,简洁,医生 30 秒能扫完 结构: # 就诊摘要 — {{INTENT_LABEL_CN}} ## 主诉 ## 患者背景 ## 近期症状(按时间倒序) ## 关键指标(异常项优先) ## 在服药与过敏 ## 患者疑问 数据: {{SERIALIZED_DATA_JSON}} 患者原话:{{USER_PROMPT}} 现在生成: ``` **SERIALIZED_DATA_JSON 结构**(给 LLM 看的精简结构): ```json { "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% 杜绝小模型臆造;后续可加生成后数值校验。