Files
kangkang/康康/Services/CaptureService.swift
link2026 bff7cfd4b6 fix(core): 代码审查修复 AI 并发/隐私/解析等多处缺陷
- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因)
- prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃
- applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺)
- parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标
- 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位
- TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论)
- FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值
- 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案)
- 补 3 个回归测试,编译 + 测试全部通过
2026-06-01 08:16:14 +08:00

407 lines
17 KiB
Swift

import Foundation
import UIKit
import SwiftData
/// 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, Identifiable {
// : ForEach , indices id
let id = UUID()
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 inferenceFailed(String)
case parseFailed(String)
var errorDescription: String? {
switch self {
case .modelNotReady: return String(appLoc: "VL 模型尚未就绪")
case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)")
case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)")
}
}
}
/// `CaptureService` actor AIRuntime( actor),
/// stateless, §3.1 ""
actor CaptureService {
static let shared = CaptureService()
private init() {}
/// Vault Asset VL, ParsedReport
/// :
/// - UnifiedCaptureFlow (UI ,/ assets )
/// -
/// - C2(W5)
/// SwiftData (MainActor), `Report.applyReanalyzed(_:in:)`
/// @Model , Sendable actor
func reanalyze(assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
try await runVL(on: assets)
}
/// :****(JPEG data) VL, indicators, Report
/// - `NSTemporaryDirectory`(`.completeFileProtectionUnlessOpen`), `defer`
/// (§ )线(§6), Vault Asset
/// - `CaptureError`,UI 退(§3.2 退线)
/// (MainActor) Indicator
func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] {
do {
try await AIRuntime.shared.prepareVL()
} catch {
throw CaptureError.modelNotReady
}
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("region-\(UUID().uuidString).jpg")
do {
// .completeFileProtectionUnlessOpen .complete:VL ,
// ,.complete / EPERM 使;
// unlessOpen 访, Vault(completeUnlessOpen)
try imageData.write(to: tmpURL, options: [.completeFileProtectionUnlessOpen, .atomic])
} catch {
throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)")
}
defer { try? FileManager.default.removeItem(at: tmpURL) }
let raw: String
do {
raw = try await AIRuntime.shared.analyzeReport(
imageURLs: [tmpURL],
prompt: VLPrompts.regionExtraction()
)
} catch {
throw CaptureError.inferenceFailed("\(error)")
}
do {
return try CaptureService.parseIndicatorsJSON(raw)
} catch let CaptureError.parseFailed(msg) {
throw CaptureError.parseFailed(msg)
} catch {
throw CaptureError.parseFailed("\(error)")
}
}
/// VL + JSON assets Vault
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
do {
try await AIRuntime.shared.prepareVL()
} catch {
throw CaptureError.modelNotReady
}
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)")
}
do {
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
} 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 {
// extractBalancedJSON( {} extractJSONObject):VL
// [{...},{...}], { , indicator
// indicators
let jsonString = extractBalancedJSON(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)")
}
let dict: [String: Any]
if let d = obj as? [String: Any] {
dict = d
} else if let arr = obj as? [[String: Any]] {
dict = ["indicators": arr]
} 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 ? String(appLoc: "拍摄识别") : title,
typeRaw: typeRaw,
reportDate: reportDate,
institution: institution,
summary: summary,
pageCount: max(pages, pageCount),
indicators: indicators
)
}
/// :VL `{"indicators":[...]}`, indicators
/// `extractJSONObject` + `parseIndicator` indicator (),
/// UI ,JSON `parseFailed`
static func parseIndicatorsJSON(_ raw: String) throws -> [ParsedReport.ParsedIndicator] {
let jsonString = extractBalancedJSON(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)")
}
// :{"indicators":[...]} [...]( key)
let indicatorsRaw: [[String: Any]]
if let dict = obj as? [String: Any] {
indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? []
} else if let arr = obj as? [[String: Any]] {
indicatorsRaw = arr
} else {
throw CaptureError.parseFailed("根节点既不是对象也不是数组")
}
return indicatorsRaw.compactMap { parseIndicator($0) }
}
/// {...} 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...])
}
/// JSON ,`{...}` `[...]`
/// ( `{"indicators":[...]}` `[...]`)
/// ( JSONSerialization )
static func extractBalancedJSON(from raw: String) -> String {
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if s.hasPrefix("```") {
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)
}
let firstBrace = s.firstIndex(of: "{")
let firstBracket = s.firstIndex(of: "[")
let start: String.Index
let open: Character
let close: Character
switch (firstBrace, firstBracket) {
case let (b?, k?):
if b < k { start = b; open = "{"; close = "}" }
else { start = k; open = "["; close = "]" }
case let (b?, nil): start = b; open = "{"; close = "}"
case let (nil, k?): start = k; open = "["; close = "]"
default: 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 == open { depth += 1 }
else if ch == close {
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")
// VL ;,退(parseReportJSON
// ?? .now) reportDate (C1)
let patterns = ["yyyy-MM-dd", "yyyy/MM/dd", "yyyy.MM.dd",
"yyyy年MM月dd日", "yyyy年M月d日", "yyyy年MM月", "yyyy-MM", "yyyy/MM"]
for p in patterns {
f.dateFormat = p
if let d = f.date(from: s) { return d }
}
return nil
}
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)
}
}
// MARK: - Report CaptureService (MainActor )
//
// CaptureService actor, Report(@Model Sendable)
// C2UI :
// ```
// let assets = report.savedAssets
// let parsed = try await CaptureService.shared.reanalyze(assets: assets)
// report.applyReanalyzed(parsed, in: ctx)
// ```
extension Report {
/// Asset SavedAsset, CaptureService.reanalyze
var savedAssets: [FileVault.SavedAsset] {
assets.map { .init(relativePath: $0.relativePath, bytes: $0.bytes) }
}
/// VL Report
/// - indicators:,(cascade delete )
/// - summary / institution:,
/// MainActor / SwiftData
@MainActor
func applyReanalyzed(_ parsed: ParsedReport, in ctx: ModelContext) {
if !parsed.summary.isEmpty {
self.summary = parsed.summary
}
if !parsed.institution.isEmpty {
self.institution = parsed.institution
}
// indicators Asset() nullify cascade,
// unlink Vault + Asset ,( §6 )
// TimelineEntryDetailView.deleteIndicator
for old in indicators {
if let asset = old.asset {
try? FileVault.shared.remove(relativePath: asset.relativePath)
ctx.delete(asset)
}
ctx.delete(old)
}
indicators.removeAll()
// indicators
for p in parsed.indicators {
let i = Indicator(
name: p.name,
value: p.value,
unit: p.unit,
range: p.range,
status: p.status,
capturedAt: reportDate,
report: self
)
ctx.insert(i)
}
try? ctx.save()
}
}