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 个回归测试,编译 + 测试全部通过
This commit is contained in:
@@ -13,7 +13,9 @@ struct ParsedReport: Sendable {
|
||||
var pageCount: Int
|
||||
var indicators: [ParsedIndicator]
|
||||
|
||||
struct ParsedIndicator: Sendable {
|
||||
struct ParsedIndicator: Sendable, Identifiable {
|
||||
// 稳定身份:供可编辑列表 ForEach 用,避免按 indices 作 id 在增删时错配输入。
|
||||
let id = UUID()
|
||||
var name: String
|
||||
var value: String
|
||||
var unit: String
|
||||
@@ -71,8 +73,8 @@ actor CaptureService {
|
||||
}
|
||||
|
||||
/// 异常项快拍:对一张**局部照片**(JPEG data)跑 VL,只抽 indicators,不建 Report、不留图。
|
||||
/// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtection`),推理后 `defer` 删除 —— 符合
|
||||
/// 「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。
|
||||
/// - 临时文件落 `NSTemporaryDirectory`(`.completeFileProtectionUnlessOpen`),推理后 `defer` 删除 ——
|
||||
/// 符合「最后只存参数和异常值」(§ 需求)与隐私基线(§6),全程不写 Vault、不建 Asset。
|
||||
/// - 失败抛 `CaptureError`,UI 回退手动录入(§3.2 失败回退红线)。
|
||||
/// 调用方(MainActor)负责把识别结果落成独立 Indicator。
|
||||
func recognizeRegion(imageData: Data) async throws -> [ParsedReport.ParsedIndicator] {
|
||||
@@ -85,7 +87,10 @@ actor CaptureService {
|
||||
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("region-\(UUID().uuidString).jpg")
|
||||
do {
|
||||
try imageData.write(to: tmpURL, options: [.completeFileProtection, .atomic])
|
||||
// 用 .completeFileProtectionUnlessOpen 而非 .complete:VL 推理可能持续数秒,
|
||||
// 期间设备若锁屏,.complete 会让读/写抛 EPERM 使快拍在锁屏下必失败;
|
||||
// unlessOpen 允许已打开句柄继续访问,与 Vault(completeUnlessOpen)一致。
|
||||
try imageData.write(to: tmpURL, options: [.completeFileProtectionUnlessOpen, .atomic])
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("临时图片写入失败:\(error.localizedDescription)")
|
||||
}
|
||||
@@ -145,7 +150,10 @@ actor CaptureService {
|
||||
/// 解析不到任何 indicator 也算成功,但 ParsedReport.isEmpty = true,
|
||||
/// UI 走「手动录入」分支。
|
||||
static func parseReportJSON(_ raw: String, pageCount: Int = 1) throws -> ParsedReport {
|
||||
let jsonString = extractJSONObject(from: raw)
|
||||
// 用 extractBalancedJSON(而非只认 {} 的 extractJSONObject):VL 多项时偶尔直接吐
|
||||
// 裸数组 [{...},{...}],只认对象会从第一个 { 配对,只截出第一个 indicator、静默丢掉
|
||||
// 其余 —— 这是影像档案核心卖点上的数据丢失。顶层是数组时整体视作 indicators。
|
||||
let jsonString = extractBalancedJSON(from: raw)
|
||||
guard let data = jsonString.data(using: .utf8) else {
|
||||
throw CaptureError.parseFailed("非 UTF-8 输出")
|
||||
}
|
||||
@@ -155,8 +163,13 @@ actor CaptureService {
|
||||
} catch {
|
||||
throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)")
|
||||
}
|
||||
guard let dict = obj as? [String: Any] else {
|
||||
throw CaptureError.parseFailed("根节点不是对象")
|
||||
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) ?? ""
|
||||
@@ -310,8 +323,15 @@ actor CaptureService {
|
||||
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)
|
||||
// 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? {
|
||||
@@ -357,8 +377,14 @@ extension Report {
|
||||
if !parsed.institution.isEmpty {
|
||||
self.institution = parsed.institution
|
||||
}
|
||||
// 旧 indicators 全删(cascade 会一起清)
|
||||
// 旧 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()
|
||||
|
||||
Reference in New Issue
Block a user