import Foundation import SwiftData /// 报告大白话摘要预生成(§3.1:流程经本服务碰 AIRuntime,UI 不直接调)。 /// 时机:归档保存后立即后台跑(用户继续操作时完成);详情页打开时兜底重试。 /// 写回策略:只在 summary 为空时生成 —— 绝不覆盖 VL 已给出或用户编辑过的摘要。 @MainActor final class ReportInsightService { static let shared = ReportInsightService() private init() {} /// 进行中的报告 ID,防止「保存后台任务」与「详情页兜底」重复触发。 private var inFlight: Set = [] func pregenerateIfNeeded(report: Report, in ctx: ModelContext) async { guard (report.summary ?? "").isEmpty, !report.indicators.isEmpty else { return } let key = String(describing: report.persistentModelID) guard !inFlight.contains(key) else { return } inFlight.insert(key) defer { inFlight.remove(key) } do { try await AIRuntime.shared.prepare() } catch { return // 模型未就绪:静默放弃,详情页下次打开再试 } let prompt = InsightPrompts.reportPlainSummary( title: report.title, typeLabel: report.type.label, indicatorLines: Self.indicatorLines(for: report.indicators) ) var collected = "" do { let stream = await AIRuntime.shared.generate( prompt: prompt, maxTokens: 200, priority: .background) for try await chunk in stream { collected += chunk.text } } catch { return // 被前台任务抢占(CancellationError)或推理失败:放弃,兜底路径再试 } let text = HealthExportService.stripThinkBlocks(collected) .trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, (report.summary ?? "").isEmpty else { return } report.summary = text try? ctx.save() } /// 「名 值 单位(参考 range)status」每指标一行;异常项排前,上限 15 行控 prompt 体积。 static func indicatorLines(for indicators: [Indicator]) -> String { let sorted = indicators.sorted { ($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0) } return sorted.prefix(15).map { i in var line = "\(i.name) \(i.value)" if !i.unit.isEmpty { line += " \(i.unit)" } if !i.range.isEmpty { line += "(参考 \(i.range))" } line += " \(i.status.rawValue)" return line }.joined(separator: "\n") } }