diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..786d909 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,290 @@ +# 康康 —— 工程前提 + +> 这是一个 6 周决赛 demo 项目。今天是 2026-05-25,处于 W1末/W2初。 +> 任何 IDE/Codex 会话开始干活前,先读这份文件。 + +--- + +## 1. 产品定位 + +- **名字**:康康(对内代号 Kangkang) +- **形态**:iOS 原生 App,SwiftUI + SwiftData +- **核心卖点**:**100% 本地推理**的个人健康影像档案 + 大白话解读 + 本地 RAG 问答 +- **目标用户**:不愿把体检/化验报告交给云端的普通人 +- **明确不做**:医疗诊断、剂量推荐、急诊判断、医生预约、社交、广告、内购、数据上云、账号系统 + +--- + +## 2. 技术栈 / 选型(已锁定,不要再讨论) + +| 项 | 选型 | 备注 | +|---|---|---| +| UI | SwiftUI | iOS 17+,用 `@Observable` / `@Model` | +| 持久化 | SwiftData | 见 §5 数据模型 | +| 图表 | Swift Charts | iOS 16+ 原生 | +| **AI 运行时** | **MLX Swift (Apple 官方)** | 不要建议 Core ML / llama.cpp / Ollama | +| LLM | Qwen3-1.7B 4bit (HF: `mlx-community/Qwen3-1.7B-4bit`) | ~1.0GB,负责文本生成、关键词抽取、趋势解读 | +| VL | Qwen2.5-VL-3B-Instruct 4bit (HF: `mlx-community/Qwen2.5-VL-3B-Instruct-4bit`) | ~2.0GB,负责拍照→结构化指标 | +| 文档扫描 | VisionKit `VNDocumentCameraView` | 不要自己写透视校正 | +| Face ID | LocalAuthentication | | +| Live Activity | ActivityKit + WidgetExtension | demo 杀手锏,真机才能测 | + +**不引入**:任何云服务 SDK、任何 embedding 模型(RAG 用结构化检索,不用语义)、任何账号系统、任何分析 SDK。 + +--- + +## 3. AI 链路核心规则 + +### 3.1 模块边界(强制) + +``` +UI → CaptureService / AskService / TrendService → AIRuntime → MLX + ↓ + Persistence +``` + +- **UI 永远不直接调 `AIRuntime`**。所有 AI 调用必须经过 `*Service` 层,这样 UI 可以注入 mock、可以预览。 +- **`AIRuntime` 是 `actor` 单例,串行化**。同一时刻只允许一个推理任务,MLX 共享显存,并发会 OOM。CaptureService 拍照时如果 AskService 正在流式生成,要在队列里排队。 +- **`*Service` 不直接读写 SwiftData 主上下文**。要么传入 `ModelContext`,要么走 ServiceLocator,方便测试。 + +### 3.2 VL pipeline(拍一张 = 一条流程) + +**重要**:快拍(1.x) 和 报告归档(2.x) 已经合并成统一 `CaptureService`,UI 不再有 A1-A3 和 B1-B4 两条独立路径。流程: + +``` +拍照 → 写 Vault(加密目录) → VL 推理(要求输出 JSON,含 kind=single|report) + → 解析容错(失败回退到手动录入,不卡死) + → 单项走 A2ConfirmView,整份走 B3MetaView + → 保存到 Indicator/Report + 关联 Asset +``` + +VL prompt 必须: +- 明确要求"只输出 JSON,不要解释" +- 带 2 个 few-shot 示例(单项 + 多项) +- 异常状态由 VL 模型基于参考范围直接判断,不要再二次调用 LLM + +### 3.3 RAG(结构化检索,不做 embedding) + +**两段式调用**: +1. 用 Qwen3-1.7B 抽取意图 + 关键词,输出 JSON `{indicators, time_range, intent}`,~50 token,<1s +2. SwiftData 按关键词检索 ≤ 10 条记录,拼 `ChatRAG` prompt,流式生成回答 + +**第 1 步失败时**回退到"近 30 天全表扫描",不卡死。 +**引用回链**:回答中 `[1][2]` 后处理为可点击 Pill,点击跳源记录详情。 + +### 3.4 Live Activity + +- VL 推理 / RAG 生成开始时启动 Activity +- 每 0.5s 通过 `AIRuntime.lastDecodeRate` 推送 tok/s +- 推理完成保留 2s 显示"已完成 · 0.8s"再 dismiss +- **只能真机测**,模拟器不显示。W5 末预留时间。 + +--- + +## 4. 模型分发 + +- 模型放 `Application Support/Models/`,首启动用 `URLSession.downloadTask` 拉,带断点续传 + 进度条 +- 总体积 ~4GB(LLM ~1.0GB + VL ~3.1GB),WiFi 提示必须有 +- App 在模型未就绪时**仍可启动**,但所有 AI 入口显示"模型未就绪,前往下载" +- `ModelStore` 必须提供**旁路接口**:允许把模型预拷进沙盒(demo 现场重装时用) + +--- + +## 5. 数据模型(SwiftData) + +**当前 schema(2026-05-26)**:7 个 @Model。 + +```swift +@Model class Indicator { + name, value, unit, range, statusRaw, note, capturedAt, + report: Report?, asset: Asset?, + pinned: Bool, // 长期监测自动 true,Trends 默认展示 + seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... 长期指标分组 key +} +@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt, + indicators: [Indicator] cascade, + assets: [Asset] cascade } +@Model class DiaryEntry { content, createdAt, tags: [String] } +@Model class Symptom { name, startedAt, endedAt?, note?, severity 1-5, tags, createdAt } +@Model class Asset { relativePath, mimeType, bytes, createdAt } +@Model class ChatTurn { question, answer, referencedIndicatorIDs, referencedReportIDs, createdAt, decodeRate } + +@Model class UserProfile { // 全 App 单例(UserProfileStore.loadOrCreate) + birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw, + allergies, chronicConditions, familyHistory, currentMedications, + updatedAt +} +``` + +**原图存储**: `Asset` 只存元数据 + 相对路径,真实 JPEG 落在 `Application Support/Vault/`,目录用 `.completeFileProtection`(iOS 硬件级加密,不要自己造 AES 轮子)。 + +--- + +## 6. 安全 / 隐私(已收敛 — 不要扩展) + +| 做 | 不做 | +|---|---| +| `Application Support/Vault/` 全目录 `.completeFileProtection` | 自实现 AES 加密 | +| SwiftData store 文件 `.completeFileProtection` | | +| Face ID 启动锁(可选开关,默认关) | | +| 永久删除(SwiftData 硬删 + Asset 文件 unlink) | | +| 离线运行(自然结果,不用单独做) | | +| | 截屏黑屏防护(iOS 没有官方 API,不做) | +| | 加密 ZIP 导出 | + +唯一的"导出"是 **9.4 分享文字摘要**(只分享解读文本,不带原图)。 + +--- + +## 7. 信息架构 + +``` +TabBar: [主页] [记录] [+ 新建] [趋势] [我的] + │ │ │ │ │ + │ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于 + │ │ │ └─ 折线图 + AI 一句话解读 + │ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状 + │ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组) + └─ 问候 + 今日摘要 + 进行中症状 + 最近时间线 +``` + +- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab +- "+ 新建" 是 sheet 不是 Tab +- AI 问答以 Modal Sheet 形式出现,**不占 Tab** +- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势), + 下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入 +- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页 + +### 7.1 档案库 C1 / C2 导航(看的一半) + +录入流程(拍照→VL→编辑→存)只是"录的一半"。**"看的一半"由 C1 列表 + C2 详情承担**——这是 demo 的核心看点之一,不能砍。 + +``` +首页 "我的报告档案" 卡 ──push──► C1 ArchiveListView + │ + │ 分类 chip:全部/体检/化验/影像/处方 + │ 按 reportDate 年份分组,卡片显示异常 chip + │ + └──push──► C2 ReportDetailView + │ + ├─ Tab "原图":TabView(.page) 翻页 + 长按保存 + ├─ Tab "解读":数字摘要(总/高/低/正常) + │ + AI 整体摘要 + │ + 对比上次(同类型上一份 Report diff) + └─ Tab "指标":Indicator 列表,异常优先 + +C2 底部两个动作: + ├─ "关联到趋势" ──► 把本报告内未 pinned 的 Indicator 批量 pinned = true,Trends 默认展示 + └─ "重新解读" ──► CaptureService.reanalyze(report:),重跑 VL 覆盖 summary/indicators + +其他进入 C2 的入口: + • ChatTurn 引用 Pill 点击(referencedReportIDs) + • 趋势页数据点 tap → 跳到该点来源 Report 的 C2 + • HomeView 时间线点报告类条目 +``` + +### 7.2 对比上次("对比上次"=报告对比,已加回) + +C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算,不再调 LLM**: + +- 找出"同 `typeRaw` 的上一份 Report"(`reportDate < current AND ORDER BY DESC LIMIT 1`) +- 同名 `Indicator` 配对,数值 diff:`Δ` 绝对值 + 百分比 + 升/降箭头 +- 标红:跨越参考范围边界(原本正常→偏高,或反过来) +- 文案模板拼装,不走 LLM,响应即时 +- 若无上一份,该区块隐藏 + +--- + +## 8. 现有代码状态(2026-05-25) + +``` +康康/ +├── App/KangkangApp.swift ✅ SwiftData container 已建 +├── RootView.swift ✅ 3 Tab + RecordSheet 已建 +├── Models/Models.swift ✅ Indicator / Report / DiaryEntry,缺 Asset / ChatTurn +├── DesignSystem/ ✅ Tokens + Components,沿用 +└── Features/ + ├── Home/ ✅ HomeView 静态 UI,数据未接 + ├── Quick/A1-A3 🔧 待合并进 UnifiedCaptureFlow + ├── Archive/B1-B4 🔧 B1 砍,B2 改用 VisionKit DocumentCamera + ├── Record/RecordSheet ✅ 入口选择 UI + ├── Trends/ ❌ 只有 placeholder + └── Me/ ❌ 只有 placeholder + +待建: +├── AI/ ⚠️ AIRuntime + LLMSession + ModelStore + TokenChunk ✅;VLSession + Prompts/ ❌ +├── Debug/DebugAIRunner.swift ✅ DEBUG-only AI 自检入口 +├── Services/ ❌ CaptureService, AskService, TrendService, ReportCompareService +├── Persistence/FileVault.swift ✅ 原图加密目录管理 +├── Security/AppLock.swift ❌ Face ID 启动锁 +├── Features/Ask/ ❌ AskSheet (RAG 问答 UI) +├── Features/Archive/ +│ ├── ArchiveListView ❌ C1 档案列表(分类 chip + 年份分组) +│ └── ReportDetailView ❌ C2 报告详情(三 Tab:原图/解读/指标 + 对比上次) +├── Features/Capture/ +│ └── UnifiedCaptureFlow ❌ 替代 QuickCaptureFlow,状态机驱动 A1→VL→A2/B3 +├── Features/Onboarding/ ❌ 首启动隐私承诺 + 模型下载 +└── LiveActivity/ ❌ WidgetExtension target +``` + +--- + +## 9. 设计系统约束 + +- **不要新增颜色 token**。所有颜色走 `Tj.Palette.*` (sand / paper / ink / brick / leaf / line / text / text3) +- **不要新增字体大小**。走 `Font.tjTitle()` / `tjH2()` / `tjSerifBody()` / 系统 size +- **圆角走 `Tj.Radius.*`**,卡片走 `.tjCard()` modifier +- 按钮走 `TjPrimaryButton` / `TjGhostButton` + +新加 View 时先看 `DesignSystem/Components.swift`,有现成的不要复刻。 + +--- + +## 10. 不能跨越的红线 + +写代码前必读: + +1. **不引入云服务**——任何 SDK 都不行,包括崩溃上报、分析、灰度 +2. **不自己实现密码学**——`.completeFileProtection` 已经够 +3. **UI 不直接调 AIRuntime**——必须经过 Service +4. **AIRuntime 必须 actor 化**——禁止 class + lock +5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏 +6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2) +7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来 +8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架 + +--- + +## 11. 6 周时间表 + +| 周次 | 必交付 | +|---|---| +| W1 末 / W2 当前 | 项目结构、MLX 跑通 Qwen3-1.7B、首个 token 在设备吐出 | +| W2-W3 | AIRuntime + LLMSession,文字日记 + 基础 RAG 问答(打字机效果)(W2 进行中) | +| W3-W4 | VLSession + 统一拍照流程(单项 + 整份)、Asset / FileVault | +| W4 末 | **C1 ArchiveListView**(分类 chip + 年份分组,接 @Query) | +| W4-W5 | 趋势(Swift Charts + AI 解读)、**C2 ReportDetailView**(三 Tab + 重新解读) | +| W5 中 | **ReportCompareService** + C2 解读 Tab "对比上次" 区块 | +| W5 末 | Face ID、永久删除、首页时间线接入真数据、Live Activity(真机) | +| W6 | 模型管理页、首启动下载流程、UI polish、demo 视频、PPT | + +**P0 新增项**(从 C 区视觉稿补回):C1 档案列表、C2 报告详情三 Tab、对比上次、关联到趋势、重新解读 + +**P1**(必须做完):Live Activity、分享文字摘要(9.4)、首启动隐私承诺页 + +**P2**(余力做):—— 任何 P2/P3 都暂时不做,清单里 11/12/13/14/15/17/18/19 全部 deferred(注意:16.1 报告对比已升 P0) + +**砍 P1 决策顺序**(任何一周延期触发):Live Activity → Onboarding 简化 → 分享摘要 → 模型管理页 polish。**绝不动 C1/C2/对比上次**——视觉稿都做了,demo PPT 也要展示,这是核心卖点之一。 + +--- + +## 12. 评委 PPT 卖点排序(写代码时记住为什么这么做) + +1. 影像档案系统(统一 VL 拍照 + 归档) — 核心创意 +2. 100% 本地 + SME2 加速 — 技术亮点 +3. 本地 RAG 长期记忆 — 端侧不可替代性 +4. 隐私三件套(系统级加密 + Face ID + 永久删除) — 信任建立 +5. AI 趋势解读 — 长期价值 +6. Live Activity 实时 tok/s — 现场记忆点 + +每写一个功能,问自己:这条提升了上面哪一项?如果都没有,就别做。 diff --git a/scripts/build-launch.sh b/scripts/build-launch.sh new file mode 100755 index 0000000..d5cf1b2 --- /dev/null +++ b/scripts/build-launch.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT="${PROJECT:-$ROOT_DIR/康康.xcodeproj}" +SCHEME="${SCHEME:-康康}" +APP_NAME="${APP_NAME:-$SCHEME}" +CONFIGURATION="${CONFIGURATION:-Debug}" +BUNDLE_ID="${BUNDLE_ID:-com.xuhuayong.kangkang}" +DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$ROOT_DIR/build/DerivedData}" +SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 16 Pro}" +SCREENSHOT_PATH="${SCREENSHOT_PATH:-$ROOT_DIR/build/screenshots/${SCHEME}-launch.png}" + +require_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: required tool not found: $1" >&2 + exit 1 + fi +} + +require_full_xcode() { + local developer_dir + developer_dir="$(xcode-select -p 2>/dev/null || true)" + if [[ "$developer_dir" != *"/Xcode.app/Contents/Developer"* ]]; then + cat >&2 <} + +Select Xcode before running this script: + sudo xcode-select -s /Applications/Xcode.app/Contents/Developer +EOF + exit 1 + fi +} + +extract_udid() { + sed -n 's/.*(\([0-9A-Fa-f-]\{36\}\)).*/\1/p' | head -n 1 +} + +find_simulator_udid() { + if [[ -n "${SIMULATOR_UDID:-}" ]]; then + echo "$SIMULATOR_UDID" + return + fi + + local udid + udid="$(xcrun simctl list devices available | grep -F "$SIMULATOR_NAME" | extract_udid || true)" + if [[ -n "$udid" ]]; then + echo "$udid" + return + fi + + udid="$( + xcrun simctl list devices available | + awk '/-- iOS / { in_ios = 1; next } /-- / { in_ios = 0 } in_ios && /iPhone/ { print; exit }' | + extract_udid || true + )" + if [[ -n "$udid" ]]; then + echo "$udid" + return + fi + + echo "error: no available iOS simulator found. Install an iPhone simulator in Xcode." >&2 + exit 1 +} + +main() { + require_tool xcode-select + require_tool xcodebuild + require_tool xcrun + require_full_xcode + + local simulator_udid app_path + simulator_udid="$(find_simulator_udid)" + + echo "Project: $PROJECT" + echo "Scheme: $SCHEME" + echo "Configuration: $CONFIGURATION" + echo "Simulator: ${SIMULATOR_UDID:-$SIMULATOR_NAME} ($simulator_udid)" + + xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination "id=$simulator_udid" \ + -derivedDataPath "$DERIVED_DATA_PATH" \ + build + + app_path="$DERIVED_DATA_PATH/Build/Products/${CONFIGURATION}-iphonesimulator/${APP_NAME}.app" + if [[ ! -d "$app_path" ]]; then + echo "error: built app not found at $app_path" >&2 + exit 1 + fi + + xcrun simctl boot "$simulator_udid" >/dev/null 2>&1 || true + xcrun simctl bootstatus "$simulator_udid" -b + if [[ "${OPEN_SIMULATOR:-1}" == "1" ]]; then + open -a Simulator --args -CurrentDeviceUDID "$simulator_udid" + fi + + xcrun simctl install "$simulator_udid" "$app_path" + xcrun simctl launch "$simulator_udid" "$BUNDLE_ID" + + mkdir -p "$(dirname "$SCREENSHOT_PATH")" + sleep "${SCREENSHOT_DELAY_SECONDS:-2}" + xcrun simctl io "$simulator_udid" screenshot "$SCREENSHOT_PATH" + + echo "Launched $BUNDLE_ID" + echo "Screenshot: $SCREENSHOT_PATH" +} + +main "$@" diff --git a/康康/Features/Me/AboutView.swift b/康康/Features/Me/AboutView.swift index c7bc41f..1272d12 100644 --- a/康康/Features/Me/AboutView.swift +++ b/康康/Features/Me/AboutView.swift @@ -22,7 +22,7 @@ struct AboutView: View { section(icon: "sparkles", title: String(appLoc: "这是什么")) { paragraph( - String(appLoc: "康康是一款以本地优先为设计原则的个人健康影像档案工具。") + + String(appLoc: "康康是一款以本地优先为设计原则的个人健康随记工具。") + String(appLoc: "你可以拍下体检报告、化验单和影像资料,图片与数据默认保存在本机;") + String(appLoc: "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。") ) @@ -107,7 +107,7 @@ struct AboutView: View { .font(.tjH2()) .foregroundStyle(Tj.Palette.text) - Text("本地优先的个人健康影像档案") + Text("本地优先的个人健康随记") .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text2) diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 310c6cb..f406c5b 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -5609,24 +5609,24 @@ } } }, - "康康是一款以本地优先为设计原则的个人健康影像档案工具。" : { + "康康是一款以本地优先为设计原则的个人健康随记工具。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kangkang is a personal health imaging archive tool designed with a local-first principle." + "value" : "Kangkang is a personal health journal tool designed with a local-first principle." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "Kangkangは、ローカルファーストを設計原則とする個人向け健康画像アーカイブツールです。" + "value" : "Kangkangは、ローカルファーストを設計原則とする個人向け健康ジャーナルツールです。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Kangkang은 로컬 우선을 설계 원칙으로 하는 개인 건강 영상 아카이브 도구입니다." + "value" : "Kangkang은 로컬 우선을 설계 원칙으로 하는 개인 건강 저널 도구입니다." } } } @@ -8138,24 +8138,24 @@ } } }, - "本地优先的个人健康影像档案" : { + "本地优先的个人健康随记" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Local-first personal health imaging archive" + "value" : "Local-first personal health journal" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ローカル優先の個人健康画像アーカイブ" + "value" : "ローカル優先の個人健康ジャーナル" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로컬 우선 개인 건강 영상 아카이브" + "value" : "로컬 우선 개인 건강 저널" } } } @@ -12207,4 +12207,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift index 37819ee..e5ac6a6 100644 --- a/康康/Services/CaptureService.swift +++ b/康康/Services/CaptureService.swift @@ -179,7 +179,7 @@ actor CaptureService { let summary = (dict["summary"] as? String) ?? "" let pages = (dict["page_count"] as? Int) ?? pageCount - let indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? [] + let indicatorsRaw = arrayValue(dict, keys: ["indicators", "indicator", "items", "指标", "指标列表", "项目"]) let indicators: [ParsedReport.ParsedIndicator] = indicatorsRaw.compactMap { parseIndicator($0) } @@ -212,7 +212,7 @@ actor CaptureService { // 兼容两种形态:{"indicators":[...]} 或直接 [...](模型偶尔省外层 key) let indicatorsRaw: [[String: Any]] if let dict = obj as? [String: Any] { - indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? [] + indicatorsRaw = arrayValue(dict, keys: ["indicators", "indicator", "items", "指标", "指标列表", "项目"]) } else if let arr = obj as? [[String: Any]] { indicatorsRaw = arr } else { @@ -335,18 +335,100 @@ actor CaptureService { } private static func parseIndicator(_ d: [String: Any]) -> ParsedReport.ParsedIndicator? { - guard let name = (d["name"] as? String)?.trimmingCharacters(in: .whitespaces), + guard let name = stringValue(d, keys: ["name", "item", "indicator", "test", "项目", "指标", "指标名", "指标名称", "检查项目", "检验项目"])?.trimmingCharacters(in: .whitespaces), !name.isEmpty else { return nil } let value: String - if let v = d["value"] as? String { value = v } - else if let v = d["value"] as? NSNumber { value = v.stringValue } + if let v = stringValue(d, keys: ["value", "result", "reading", "结果", "数值", "检测值", "测定值"]) { value = v } else { value = "" } - let unit = (d["unit"] as? String) ?? "" - let range = (d["range"] as? String) ?? "" - let statusRaw = (d["status"] as? String)?.lowercased() ?? "normal" - let status = IndicatorStatus(rawValue: statusRaw) ?? .normal + let unit = stringValue(d, keys: ["unit", "单位"]) ?? "" + let range = stringValue(d, keys: ["range", "reference", "reference_range", "ref", "参考", "参考值", "参考范围", "正常范围"]) ?? "" + let statusRaw = stringValue(d, keys: ["status", "flag", "abnormal", "异常", "提示", "标记"]) + let status = parseIndicatorStatus(raw: statusRaw, value: value, range: range) return .init(name: name, value: value, unit: unit, range: range, status: status) } + + private static func stringValue(_ d: [String: Any], keys: [String]) -> String? { + for key in keys { + if let s = d[key] as? String { + return s + } + if let n = d[key] as? NSNumber { + return n.stringValue + } + } + return nil + } + + private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] { + for key in keys { + if let arr = d[key] as? [[String: Any]] { + return arr + } + } + return [] + } + + private static func parseIndicatorStatus(raw: String?, value: String, range: String) -> IndicatorStatus { + let normalized = raw? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + + if ["high", "h", "hi", "above", "up", "↑", "⬆", "+", "偏高", "高", "增高", "升高", "偏高↑", "h↑"].contains(normalized) { + return .high + } + if ["low", "l", "lo", "below", "down", "↓", "⬇", "-", "偏低", "低", "降低", "偏低↓", "l↓"].contains(normalized) { + return .low + } + if ["normal", "n", "ok", "正常", "阴性", "无异常"].contains(normalized) { + return .normal + } + + return inferStatus(value: value, range: range) ?? .normal + } + + private static func inferStatus(value: String, range: String) -> IndicatorStatus? { + guard let v = firstNumber(in: value) else { return nil } + let compact = range + .replacingOccurrences(of: "—", with: "-") + .replacingOccurrences(of: "–", with: "-") + .replacingOccurrences(of: "~", with: "-") + .replacingOccurrences(of: "~", with: "-") + .replacingOccurrences(of: "至", with: "-") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !compact.isEmpty else { return nil } + + let numbers = numbers(in: compact) + if compact.contains("<") || compact.contains("≤") || compact.contains("<") { + guard let upper = numbers.first else { return nil } + return v > upper ? .high : .normal + } + if compact.contains(">") || compact.contains("≥") || compact.contains(">") { + guard let lower = numbers.first else { return nil } + return v < lower ? .low : .normal + } + if numbers.count >= 2 { + let lower = numbers[0] + let upper = numbers[1] + if v < lower { return .low } + if v > upper { return .high } + return .normal + } + return nil + } + + private static func firstNumber(in text: String) -> Double? { + numbers(in: text).first + } + + private static func numbers(in text: String) -> [Double] { + let pattern = #"-?\d+(?:\.\d+)?"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let ns = text as NSString + let range = NSRange(location: 0, length: ns.length) + return regex.matches(in: text, range: range).compactMap { + Double(ns.substring(with: $0.range)) + } + } } // MARK: - Report ↔ CaptureService 桥接(MainActor 侧) diff --git a/康康Tests/CaptureServiceJSONTests.swift b/康康Tests/CaptureServiceJSONTests.swift index 02adf26..571d64c 100644 --- a/康康Tests/CaptureServiceJSONTests.swift +++ b/康康Tests/CaptureServiceJSONTests.swift @@ -100,6 +100,42 @@ struct CaptureServiceJSONTests { #expect(parsed.indicators.first?.status == .normal) } + @Test func parsesRegionJSONWithChineseKeysAndArrowStatus() throws { + let raw = """ + {"指标":[{"项目":"尿酸","结果":"486","单位":"μmol/L","参考范围":"208 - 428","异常":"↑"}]} + """ + let indicators = try CaptureService.parseIndicatorsJSON(raw) + #expect(indicators.count == 1) + #expect(indicators.first?.name == "尿酸") + #expect(indicators.first?.value == "486") + #expect(indicators.first?.unit == "μmol/L") + #expect(indicators.first?.range == "208 - 428") + #expect(indicators.first?.status == .high) + } + + @Test func parsesReportJSONWithChineseIndicatorArrayKey() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"2026-05-01","指标":[{"项目":"尿酸","结果":"486","单位":"μmol/L","参考范围":"208 - 428","异常":"偏高"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.count == 1) + #expect(parsed.indicators.first?.name == "尿酸") + #expect(parsed.indicators.first?.status == .high) + } + + @Test func infersStatusFromValueAndReferenceRangeWhenStatusMissing() throws { + let raw = """ + {"indicators":[ + {"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40"}, + {"name":"白细胞","value":"2.8","unit":"10^9/L","range":"3.5 - 9.5"} + ]} + """ + let indicators = try CaptureService.parseIndicatorsJSON(raw) + #expect(indicators.count == 2) + #expect(indicators[0].status == .high) + #expect(indicators[1].status == .low) + } + @Test func badReportDateFallsBackToNow() throws { let raw = """ {"title":"t","type":"lab","report_date":"昨天","institution":"","page_count":1,"summary":"","indicators":[]}