将「异常项快拍」从复用整页报告归档流程,改造成独立的局部识别路径: 小框拍局部 → Qwen-VL 只抽 indicators → 用户确认逐项编辑 → 存成独立 Indicator(不建 Report、不留原图,与「记录指标」统一落库)。 - RegionCameraView: AVFoundation 实时预览 + 居中小框,快门后按 metadataOutputRectConverted 裁剪到框内区域;含裁剪纯函数与权限态。 - VLPrompts.regionExtraction(): 局部识别 prompt,严格 JSON 只要 indicators。 - CaptureService.recognizeRegion(): 临时文件推理后即删,不写 Vault; 新增 parseIndicatorsJSON / extractBalancedJSON 解析容错。 - QuickRegionConfirmView: 异常项高亮置顶、默认勾选,可编辑/增删/选纳入。 - QuickRegionCaptureFlow: 状态机 idle→analyzing→confirm,30s 超时回退手动。 - RootView: .quick 路由改指向新流程(.archive 仍走 UnifiedCaptureFlow)。 - 删除 5 个无引用的旧 mockup(A1/A2/A3/SmartFramer/QuickCaptureFlow)。 模拟器无相机退化为相册整图;小框裁剪坐标需真机验证。 设计见 docs/superpowers/specs/2026-05-31-abnormal-quick-capture-design.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
381 lines
15 KiB
Swift
381 lines
15 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 {
|
|
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`(`.completeFileProtection`),推理后 `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 {
|
|
try imageData.write(to: tmpURL, options: [.completeFileProtection, .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 {
|
|
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 ? 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")
|
|
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)
|
|
}
|
|
}
|
|
|
|
// MARK: - Report ↔ CaptureService 桥接(MainActor 侧)
|
|
//
|
|
// CaptureService 是 actor,不能直接收 Report(@Model 非 Sendable)。
|
|
// C2「重新解读」UI 走这条路径:
|
|
// ```
|
|
// 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 全删(cascade 会一起清)
|
|
for old in indicators {
|
|
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()
|
|
}
|
|
}
|