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:
link2026
2026-05-26 11:18:00 +08:00
parent 39edc25dc1
commit 1b01923c8e
27 changed files with 3128 additions and 29 deletions

View 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
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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])

View 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) // ""
}
}