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:
link2026
2026-06-01 07:43:49 +08:00
parent 32e7c25ed7
commit bff7cfd4b6
16 changed files with 185 additions and 1204 deletions

View File

@@ -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()

View File

@@ -147,6 +147,7 @@ struct HealthExportService {
inferredTimeToDate: snapshot.toDate,
inferredIntent: intent.intent,
inferredLabelCN: intent.labelCN,
modelTag: ModelKind.llm.rawValue, // LLM tag,( §12#6)
decodeRate: lastRate
)
modelContext.insert(export)