Files
kangkang/康康/Services/CaptureService.swift
link2026 1b01923c8e feat(capture): 统一报告捕获流程并集成视觉语言模型识别
- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程
- 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理
- 实现 AIRuntime 中 VL 模型的准备和分析功能
- 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板
- 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面
- 集成 VisionKit 文档扫描器支持真机多页文档扫描
- 为模拟器实现 PhotosPicker 回退方案选择已有照片
- 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程
- 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除
- 扩展 KangkangApp 模型配置以支持新数据类型
- 实现档案列表中症状结束功能通过时间线行点击触发
2026-05-26 11:18:00 +08:00

219 lines
8.1 KiB
Swift

import Foundation
import UIKit
/// VL (, SwiftData )
/// Indicator/Report prompt schema
struct ParsedReport: Sendable {
var title: String
var typeRaw: String
var reportDate: Date
var institution: String
var summary: String
var pageCount: Int
var indicators: [ParsedIndicator]
struct ParsedIndicator: Sendable {
var name: String
var value: String
var unit: String
var range: String
var status: IndicatorStatus
}
/// = ,UI 退
var isEmpty: Bool { indicators.isEmpty }
/// ,退 UI
static func empty(date: Date = .now) -> ParsedReport {
ParsedReport(
title: "",
typeRaw: ReportType.other.rawValue,
reportDate: date,
institution: "",
summary: "",
pageCount: 1,
indicators: []
)
}
}
/// CaptureService UI (退 vs )
enum CaptureError: Error, LocalizedError {
case modelNotReady
case writeAssetFailed
case inferenceFailed(String)
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return "VL 模型尚未就绪"
case .writeAssetFailed: return "图片保存失败"
case .inferenceFailed(let m): return "识别失败:\(m)"
case .parseFailed(let m): return "结构化失败:\(m)"
}
}
}
/// `CaptureService` actor AIRuntime( actor),
/// stateless, §3.1 ""
actor CaptureService {
static let shared = CaptureService()
private init() {}
/// + VL + ParsedReport
/// , CaptureError;UI
/// - Returns: (ParsedReport, [FileVault.SavedAsset]) ,
/// SavedAsset Asset @Model
func analyze(images: [UIImage]) async throws
-> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) {
// 1. Vault()
let assets: [FileVault.SavedAsset]
do {
assets = try images.map { try FileVault.shared.writeJPEG($0) }
} catch {
throw CaptureError.writeAssetFailed
}
// 2. VL
try await AIRuntime.shared.prepareVL()
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
let raw: String
do {
raw = try await AIRuntime.shared.analyzeReport(
imageURLs: urls,
prompt: VLPrompts.reportExtraction
)
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
// 3. JSON (: / )
do {
let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count)
return (parsed, assets)
} catch let CaptureError.parseFailed(msg) {
throw CaptureError.parseFailed(msg)
} catch {
throw CaptureError.parseFailed("\(error)")
}
}
// MARK: - JSON parse(static + 便)
/// VL JSON
/// :
/// - ```json``` markdown
/// - JSON
/// -
/// indicator , ParsedReport.isEmpty = true,
/// UI
static func parseReportJSON(_ raw: String, pageCount: Int = 1) throws -> ParsedReport {
let jsonString = extractJSONObject(from: raw)
guard let data = jsonString.data(using: .utf8) else {
throw CaptureError.parseFailed("非 UTF-8 输出")
}
let obj: Any
do {
obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
} catch {
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
}
guard let dict = obj as? [String: Any] else {
throw CaptureError.parseFailed("根节点不是对象")
}
let title = (dict["title"] as? String)?.trimmingCharacters(in: .whitespaces) ?? ""
let typeRaw = parseReportType(dict["type"] as? String)
let reportDate = parseDate(dict["report_date"] as? String) ?? .now
let institution = (dict["institution"] as? String) ?? ""
let summary = (dict["summary"] as? String) ?? ""
let pages = (dict["page_count"] as? Int) ?? pageCount
let indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? []
let indicators: [ParsedReport.ParsedIndicator] = indicatorsRaw.compactMap {
parseIndicator($0)
}
return ParsedReport(
title: title.isEmpty ? "拍摄识别" : title,
typeRaw: typeRaw,
reportDate: reportDate,
institution: institution,
summary: summary,
pageCount: max(pages, pageCount),
indicators: indicators
)
}
/// {...} markdown
/// ( JSONSerialization )
static func extractJSONObject(from raw: String) -> String {
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
// markdown
if s.hasPrefix("```") {
// ```json ```
if let firstNewline = s.firstIndex(of: "\n") {
s = String(s[s.index(after: firstNewline)...])
}
// ```
if let endRange = s.range(of: "```", options: .backwards) {
s = String(s[..<endRange.lowerBound])
}
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
}
// {, }
guard let start = s.firstIndex(of: "{") else { return s }
var depth = 0
var inString = false
var escape = false
var idx = start
while idx < s.endIndex {
let ch = s[idx]
if escape { escape = false }
else if ch == "\\" { escape = true }
else if ch == "\"" { inString.toggle() }
else if !inString {
if ch == "{" { depth += 1 }
else if ch == "}" {
depth -= 1
if depth == 0 {
return String(s[start...idx])
}
}
}
idx = s.index(after: idx)
}
return String(s[start...])
}
private static func parseReportType(_ raw: String?) -> String {
guard let raw = raw?.lowercased() else { return ReportType.other.rawValue }
return ReportType(rawValue: raw)?.rawValue ?? ReportType.other.rawValue
}
private static func parseDate(_ raw: String?) -> Date? {
guard let s = raw?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return nil }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd"
return f.date(from: s)
}
private static func parseIndicator(_ d: [String: Any]) -> ParsedReport.ParsedIndicator? {
guard let name = (d["name"] as? String)?.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 }
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
return .init(name: name, value: value, unit: unit, range: range, status: status)
}
}