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[.. 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) } }