feat(capture): 统一报告捕获流程并集成视觉语言模型识别
- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程 - 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理 - 实现 AIRuntime 中 VL 模型的准备和分析功能 - 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板 - 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面 - 集成 VisionKit 文档扫描器支持真机多页文档扫描 - 为模拟器实现 PhotosPicker 回退方案选择已有照片 - 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程 - 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除 - 扩展 KangkangApp 模型配置以支持新数据类型 - 实现档案列表中症状结束功能通过时间线行点击触发
This commit is contained in:
112
康康Tests/CaptureServiceJSONTests.swift
Normal file
112
康康Tests/CaptureServiceJSONTests.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import 康康
|
||||
|
||||
struct CaptureServiceJSONTests {
|
||||
|
||||
@Test func parsesCleanJSON() throws {
|
||||
let raw = """
|
||||
{"title":"春检","type":"checkup","report_date":"2026-04-12","institution":"协和","page_count":2,"summary":"血脂偏高","indicators":[{"name":"LDL-C","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]}
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.title == "春检")
|
||||
#expect(parsed.typeRaw == ReportType.checkup.rawValue)
|
||||
#expect(parsed.institution == "协和")
|
||||
#expect(parsed.pageCount == 2)
|
||||
#expect(parsed.indicators.count == 1)
|
||||
#expect(parsed.indicators.first?.status == .high)
|
||||
}
|
||||
|
||||
@Test func stripsMarkdownCodeFence() throws {
|
||||
let raw = """
|
||||
```json
|
||||
{"title":"x","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]}
|
||||
```
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.title == "x")
|
||||
#expect(parsed.typeRaw == ReportType.lab.rawValue)
|
||||
#expect(parsed.indicators.isEmpty)
|
||||
}
|
||||
|
||||
@Test func extractsObjectAfterLeadingText() throws {
|
||||
let raw = """
|
||||
好的,识别结果如下:
|
||||
{"title":"y","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]}
|
||||
以上。
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.title == "y")
|
||||
}
|
||||
|
||||
@Test func handlesNestedBraces() throws {
|
||||
let raw = """
|
||||
{"title":"y","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"含嵌套{x}对象","indicators":[]}
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.summary == "含嵌套{x}对象")
|
||||
}
|
||||
|
||||
@Test func handlesEscapedQuotesInStrings() throws {
|
||||
let raw = #"{"title":"y \"内嵌\" 引号","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]}"#
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.title == #"y "内嵌" 引号"#)
|
||||
}
|
||||
|
||||
@Test func fillsDefaultsForMissingFields() throws {
|
||||
// 缺 title / type / report_date / institution / summary / page_count
|
||||
let raw = """
|
||||
{"indicators":[{"name":"X","value":"1","unit":"","range":"","status":"normal"}]}
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.title == "拍摄识别") // 默认值
|
||||
#expect(parsed.typeRaw == ReportType.other.rawValue)
|
||||
#expect(parsed.indicators.count == 1)
|
||||
}
|
||||
|
||||
@Test func skipsIndicatorsWithEmptyName() throws {
|
||||
let raw = """
|
||||
{"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[
|
||||
{"name":"","value":"1","unit":"","range":"","status":"normal"},
|
||||
{"name":" ","value":"1","unit":"","range":"","status":"normal"},
|
||||
{"name":"OK","value":"1","unit":"","range":"","status":"normal"}
|
||||
]}
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.indicators.count == 1)
|
||||
#expect(parsed.indicators.first?.name == "OK")
|
||||
}
|
||||
|
||||
@Test func malformedJSONThrows() {
|
||||
let raw = "完全不是 JSON"
|
||||
#expect(throws: CaptureError.self) {
|
||||
_ = try CaptureService.parseReportJSON(raw)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func valueAsNumberStillParses() throws {
|
||||
let raw = """
|
||||
{"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[{"name":"X","value":3.84,"unit":"","range":"","status":"high"}]}
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.indicators.first?.value == "3.84")
|
||||
}
|
||||
|
||||
@Test func unknownStatusFallsBackToNormal() throws {
|
||||
let raw = """
|
||||
{"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[{"name":"X","value":"1","unit":"","range":"","status":"abnormal"}]}
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
#expect(parsed.indicators.first?.status == .normal)
|
||||
}
|
||||
|
||||
@Test func badReportDateFallsBackToNow() throws {
|
||||
let raw = """
|
||||
{"title":"t","type":"lab","report_date":"昨天","institution":"","page_count":1,"summary":"","indicators":[]}
|
||||
"""
|
||||
let parsed = try CaptureService.parseReportJSON(raw)
|
||||
let now = Date()
|
||||
let diff = abs(parsed.reportDate.timeIntervalSince(now))
|
||||
#expect(diff < 5) // 5 秒内算 .now
|
||||
}
|
||||
}
|
||||
145
康康Tests/CustomMonitorMetricTests.swift
Normal file
145
康康Tests/CustomMonitorMetricTests.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import Testing
|
||||
import SwiftData
|
||||
import Foundation
|
||||
@testable import 康康
|
||||
|
||||
struct CustomMonitorMetricTests {
|
||||
|
||||
private func makeContainer() throws -> ModelContainer {
|
||||
let schema = Schema([
|
||||
CustomMonitorMetric.self,
|
||||
Indicator.self,
|
||||
])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
}
|
||||
|
||||
@Test func seriesKeyAutoPrefixedWithCustom() {
|
||||
let m = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||
#expect(m.seriesKey.hasPrefix("custom."))
|
||||
#expect(m.seriesKey.count > "custom.".count)
|
||||
}
|
||||
|
||||
@Test func seriesKeyUniquePerInstance() {
|
||||
let a = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||
let b = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||
#expect(a.seriesKey != b.seriesKey)
|
||||
}
|
||||
|
||||
@Test func referenceRangeNilWhenBoundsAbsentOrInverted() {
|
||||
let none = CustomMonitorMetric(name: "x", unit: "")
|
||||
#expect(none.referenceRange == nil)
|
||||
|
||||
let inverted = CustomMonitorMetric(name: "x", unit: "", lowerBound: 100, upperBound: 50)
|
||||
#expect(inverted.referenceRange == nil)
|
||||
|
||||
let valid = CustomMonitorMetric(name: "x", unit: "", lowerBound: 60, upperBound: 100)
|
||||
#expect(valid.referenceRange == 60...100)
|
||||
}
|
||||
|
||||
@Test func rangeTextFormattingDropsTrailingZero() {
|
||||
let intRange = CustomMonitorMetric(name: "x", unit: "cm",
|
||||
lowerBound: 70, upperBound: 90)
|
||||
#expect(intRange.rangeText == "70 - 90")
|
||||
|
||||
let decimalRange = CustomMonitorMetric(name: "y", unit: "kg",
|
||||
lowerBound: 60.5, upperBound: 65.5)
|
||||
#expect(decimalRange.rangeText == "60.5 - 65.5")
|
||||
}
|
||||
|
||||
@Test func roundtripsThroughSwiftData() throws {
|
||||
let container = try makeContainer()
|
||||
let ctx = ModelContext(container)
|
||||
let m = CustomMonitorMetric(name: "腰围", unit: "cm",
|
||||
lowerBound: 70, upperBound: 90,
|
||||
icon: "flame.fill")
|
||||
ctx.insert(m)
|
||||
try ctx.save()
|
||||
|
||||
let fetched = try #require(try ctx.fetch(FetchDescriptor<CustomMonitorMetric>()).first)
|
||||
#expect(fetched.name == "腰围")
|
||||
#expect(fetched.unit == "cm")
|
||||
#expect(fetched.lowerBound == 70)
|
||||
#expect(fetched.upperBound == 90)
|
||||
#expect(fetched.icon == "flame.fill")
|
||||
#expect(fetched.seriesKey.hasPrefix("custom."))
|
||||
}
|
||||
|
||||
@Test func seriesBucketResolvesCustomTitleAndRange() {
|
||||
let custom = CustomMonitorMetric(name: "腰围", unit: "cm",
|
||||
lowerBound: 70, upperBound: 90)
|
||||
let key = custom.seriesKey
|
||||
let now = Date()
|
||||
let day = { (offset: Int) -> Date in
|
||||
Calendar.current.date(byAdding: .day, value: offset, to: now)!
|
||||
}
|
||||
let items = [
|
||||
Indicator(name: "腰围", value: "80", unit: "cm", range: "70-90",
|
||||
status: .normal, capturedAt: day(-2), seriesKey: key),
|
||||
Indicator(name: "腰围", value: "82", unit: "cm", range: "70-90",
|
||||
status: .normal, capturedAt: day(-1), seriesKey: key),
|
||||
]
|
||||
let buckets = SeriesBucket.build(from: items, customMetrics: [custom])
|
||||
|
||||
#expect(buckets.count == 1)
|
||||
let b = try! #require(buckets.first)
|
||||
#expect(b.title == "腰围")
|
||||
#expect(b.unit == "cm")
|
||||
#expect(b.lines.first?.referenceRange == 70...90)
|
||||
}
|
||||
|
||||
@Test func nameConflictEmptyNameYieldsNone() {
|
||||
let result = detectNameConflict(candidate: " ", customs: [])
|
||||
#expect(result == .none)
|
||||
}
|
||||
|
||||
@Test func nameConflictDetectsBuiltinMatch() {
|
||||
let result = detectNameConflict(candidate: "血压", customs: [])
|
||||
#expect(result == .builtin("血压"))
|
||||
}
|
||||
|
||||
@Test func nameConflictBuiltinIgnoresWhitespace() {
|
||||
let result = detectNameConflict(candidate: " 空腹血糖 ", customs: [])
|
||||
#expect(result == .builtin("空腹血糖"))
|
||||
}
|
||||
|
||||
@Test func nameConflictDetectsExistingCustom() {
|
||||
let existing = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||
let result = detectNameConflict(candidate: "腰围", customs: [existing])
|
||||
#expect(result == .existingCustom("腰围"))
|
||||
}
|
||||
|
||||
@Test func nameConflictAllowsRenamingSelf() {
|
||||
// 编辑自己时,即使没改名也不应该报冲突
|
||||
let me = CustomMonitorMetric(name: "腰围", unit: "cm")
|
||||
let result = detectNameConflict(
|
||||
candidate: "腰围",
|
||||
customs: [me],
|
||||
excludingSeriesKey: me.seriesKey
|
||||
)
|
||||
#expect(result == .none)
|
||||
}
|
||||
|
||||
@Test func nameConflictUnique() {
|
||||
let result = detectNameConflict(candidate: "步数", customs: [])
|
||||
#expect(result == .none)
|
||||
}
|
||||
|
||||
@Test func seriesBucketFallsBackToIndicatorNameWhenCustomMissing() {
|
||||
// 用户删了 CustomMonitorMetric 但 Indicator 还在 → title fallback 到 indicator.name
|
||||
let orphanKey = "custom.deleted-xxxxx"
|
||||
let now = Date()
|
||||
let items = [
|
||||
Indicator(name: "睡眠时长", value: "7", unit: "h", range: "",
|
||||
status: .normal, capturedAt: now, seriesKey: orphanKey),
|
||||
Indicator(name: "睡眠时长", value: "8", unit: "h", range: "",
|
||||
status: .normal, capturedAt: now.addingTimeInterval(60),
|
||||
seriesKey: orphanKey),
|
||||
]
|
||||
let buckets = SeriesBucket.build(from: items, customMetrics: [])
|
||||
let b = try! #require(buckets.first)
|
||||
#expect(b.title == "睡眠时长")
|
||||
#expect(b.unit == "h")
|
||||
#expect(b.lines.first?.referenceRange == nil)
|
||||
}
|
||||
}
|
||||
79
康康Tests/MetricReminderTests.swift
Normal file
79
康康Tests/MetricReminderTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Testing
|
||||
import SwiftData
|
||||
import Foundation
|
||||
@testable import 康康
|
||||
|
||||
struct MetricReminderTests {
|
||||
|
||||
private func makeContainer() throws -> ModelContainer {
|
||||
let schema = Schema([MetricReminder.self])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
}
|
||||
|
||||
@Test func defaultsToEveryDayAt8AM() {
|
||||
let r = MetricReminder(metricId: "bloodPressure", displayName: "血压")
|
||||
#expect(r.hour == 8)
|
||||
#expect(r.minute == 0)
|
||||
#expect(r.weekdays == [1, 2, 3, 4, 5, 6, 7])
|
||||
#expect(r.enabled == true)
|
||||
#expect(r.isEveryDay)
|
||||
#expect(r.frequencyLabel == "每天")
|
||||
}
|
||||
|
||||
@Test func hourMinuteClampedToValidRange() {
|
||||
let early = MetricReminder(metricId: "x", displayName: "x", hour: -5, minute: 80)
|
||||
#expect(early.hour == 0)
|
||||
#expect(early.minute == 59)
|
||||
let late = MetricReminder(metricId: "y", displayName: "y", hour: 25, minute: -3)
|
||||
#expect(late.hour == 23)
|
||||
#expect(late.minute == 0)
|
||||
}
|
||||
|
||||
@Test func weekdaysRoundtripThroughSwiftData() throws {
|
||||
let container = try makeContainer()
|
||||
let ctx = ModelContext(container)
|
||||
let r = MetricReminder(
|
||||
metricId: "bloodPressure",
|
||||
displayName: "血压",
|
||||
hour: 7, minute: 30,
|
||||
weekdays: [2, 4, 6]
|
||||
)
|
||||
ctx.insert(r)
|
||||
try ctx.save()
|
||||
|
||||
let fetched = try #require(try ctx.fetch(FetchDescriptor<MetricReminder>()).first)
|
||||
#expect(fetched.weekdays == [2, 4, 6])
|
||||
#expect(fetched.isEveryDay == false)
|
||||
#expect(fetched.frequencyLabel == "每周 一三五")
|
||||
#expect(fetched.timeLabel == "07:30")
|
||||
}
|
||||
|
||||
@Test func disabledFrequencyLabel() {
|
||||
let r = MetricReminder(metricId: "x", displayName: "x", enabled: false)
|
||||
#expect(r.frequencyLabel == "已关闭")
|
||||
}
|
||||
|
||||
@Test func emptyWeekdaysNotEveryDay() {
|
||||
let r = MetricReminder(metricId: "x", displayName: "x", weekdays: [])
|
||||
#expect(!r.isEveryDay)
|
||||
#expect(r.frequencyLabel == "未选日")
|
||||
}
|
||||
|
||||
@Test func metricIdUniquenessEnforced() throws {
|
||||
let container = try makeContainer()
|
||||
let ctx = ModelContext(container)
|
||||
let r1 = MetricReminder(metricId: "bp", displayName: "血压")
|
||||
ctx.insert(r1)
|
||||
try ctx.save()
|
||||
|
||||
let r2 = MetricReminder(metricId: "bp", displayName: "血压重复")
|
||||
ctx.insert(r2)
|
||||
try ctx.save()
|
||||
|
||||
// SwiftData @Attribute(.unique) 在冲突时合并/拒绝(具体行为版本依赖);
|
||||
// 至少 fetch 总数 ≤ 1。
|
||||
let all = try ctx.fetch(FetchDescriptor<MetricReminder>())
|
||||
#expect(all.count == 1)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ struct ModelsSchemaTests {
|
||||
ChatTurn.self,
|
||||
Symptom.self,
|
||||
UserProfile.self,
|
||||
MetricReminder.self,
|
||||
CustomMonitorMetric.self,
|
||||
])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
|
||||
107
康康Tests/SeriesBucketTests.swift
Normal file
107
康康Tests/SeriesBucketTests.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import 康康
|
||||
|
||||
struct SeriesBucketTests {
|
||||
|
||||
private func makeIndicator(
|
||||
name: String = "测试",
|
||||
value: String,
|
||||
unit: String = "mmol/L",
|
||||
range: String = "",
|
||||
status: IndicatorStatus = .normal,
|
||||
capturedAt: Date,
|
||||
seriesKey: String?
|
||||
) -> Indicator {
|
||||
Indicator(name: name, value: value, unit: unit, range: range,
|
||||
status: status, capturedAt: capturedAt,
|
||||
seriesKey: seriesKey)
|
||||
}
|
||||
|
||||
@Test func skipsIndicatorsWithoutSeriesKey() {
|
||||
let now = Date()
|
||||
let items = [
|
||||
makeIndicator(value: "5.0", capturedAt: now, seriesKey: nil),
|
||||
makeIndicator(value: "5.2", capturedAt: now, seriesKey: nil),
|
||||
]
|
||||
let buckets = SeriesBucket.build(from: items)
|
||||
#expect(buckets.isEmpty)
|
||||
}
|
||||
|
||||
@Test func filtersOutSeriesWithFewerThanMinPoints() {
|
||||
let now = Date()
|
||||
let items = [
|
||||
makeIndicator(value: "5.0", capturedAt: now, seriesKey: "glucose.fasting"),
|
||||
]
|
||||
let buckets = SeriesBucket.build(from: items, minPoints: 2)
|
||||
#expect(buckets.isEmpty)
|
||||
}
|
||||
|
||||
@Test func singleSeriesBucketSortedAscending() {
|
||||
let day = { (offset: Int) -> Date in
|
||||
Calendar.current.date(byAdding: .day, value: offset, to: .now)!
|
||||
}
|
||||
let items = [
|
||||
makeIndicator(value: "5.5", capturedAt: day(-3), seriesKey: "glucose.fasting"),
|
||||
makeIndicator(value: "5.2", capturedAt: day(-1), seriesKey: "glucose.fasting"),
|
||||
makeIndicator(value: "5.8", capturedAt: day(-2), seriesKey: "glucose.fasting"),
|
||||
]
|
||||
let buckets = SeriesBucket.build(from: items)
|
||||
#expect(buckets.count == 1)
|
||||
let line = try! #require(buckets.first?.lines.first)
|
||||
// sorted ascending → -3, -2, -1
|
||||
let values = line.points.map(\.value)
|
||||
#expect(values == [5.5, 5.8, 5.2])
|
||||
}
|
||||
|
||||
@Test func bloodPressureMergesIntoSingleBucket() {
|
||||
let now = Date()
|
||||
let day = { (offset: Int) -> Date in
|
||||
Calendar.current.date(byAdding: .day, value: offset, to: now)!
|
||||
}
|
||||
let items = [
|
||||
makeIndicator(value: "125", capturedAt: day(-2), seriesKey: "bp.systolic"),
|
||||
makeIndicator(value: "82", capturedAt: day(-2), seriesKey: "bp.diastolic"),
|
||||
makeIndicator(value: "130", capturedAt: day(-1), seriesKey: "bp.systolic"),
|
||||
makeIndicator(value: "85", capturedAt: day(-1), seriesKey: "bp.diastolic"),
|
||||
]
|
||||
let buckets = SeriesBucket.build(from: items)
|
||||
let bp = try! #require(buckets.first { $0.id == "bp" })
|
||||
#expect(bp.lines.count == 2)
|
||||
#expect(bp.title == "血压")
|
||||
#expect(bp.lines.contains { $0.seriesKey == "bp.systolic" })
|
||||
#expect(bp.lines.contains { $0.seriesKey == "bp.diastolic" })
|
||||
}
|
||||
|
||||
@Test func mixedSeriesProducesMultipleBucketsSortedByRecency() {
|
||||
let cal = Calendar.current
|
||||
let day = { (offset: Int) -> Date in
|
||||
cal.date(byAdding: .day, value: offset, to: .now)!
|
||||
}
|
||||
let items = [
|
||||
// weight 较旧
|
||||
makeIndicator(value: "68", capturedAt: day(-10), seriesKey: "weight"),
|
||||
makeIndicator(value: "67", capturedAt: day(-7), seriesKey: "weight"),
|
||||
// glucose 较新
|
||||
makeIndicator(value: "5.1", capturedAt: day(-2), seriesKey: "glucose.fasting"),
|
||||
makeIndicator(value: "5.3", capturedAt: day(-1), seriesKey: "glucose.fasting"),
|
||||
]
|
||||
let buckets = SeriesBucket.build(from: items)
|
||||
#expect(buckets.count == 2)
|
||||
// 最新的 glucose 排前面
|
||||
#expect(buckets.first?.id == "glucose.fasting")
|
||||
#expect(buckets.last?.id == "weight")
|
||||
}
|
||||
|
||||
@Test func nonNumericValueDropped() {
|
||||
let now = Date()
|
||||
let items = [
|
||||
makeIndicator(value: "高", capturedAt: now, seriesKey: "weight"),
|
||||
makeIndicator(value: "68", capturedAt: now, seriesKey: "weight"),
|
||||
makeIndicator(value: "67", capturedAt: now.addingTimeInterval(60), seriesKey: "weight"),
|
||||
]
|
||||
let buckets = SeriesBucket.build(from: items)
|
||||
let line = try! #require(buckets.first?.lines.first)
|
||||
#expect(line.points.count == 2) // "高" 被丢
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user