# 导出身体档案 — 设计文档

**日期**: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`:

```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>`:

```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% 杜绝小模型臆造;后续可加生成后数值校验。
