import Foundation /// 「健康记录」AI 辅助:让 LLM 从医生角度提 3-4 个追问问题。 /// /// 设计上和 HealthExportService 同款门面,但输出量小(< 400 token), /// 不流式 —— 直接 await 收完整结果再解析。 /// /// 调用方:DiaryQuickSheet。 @MainActor struct DiaryAssistService { static let shared = DiaryAssistService() private init() {} /// 单条追问。fill 是带方括号占位符的模板,采纳时追加到原文末尾。 /// `dim` 是问诊维度(取自 `DiaryAssistPrompts.dimensions`),用于跨轮按维度去重。 /// `adopted` 由 UI 标记;`round` 由 UI 在 append 前打戳,用于多轮分组显示。 struct Question: Identifiable, Hashable { let id: UUID let q: String let fill: String let dim: String var adopted: Bool var round: Int init(id: UUID = UUID(), q: String, fill: String, dim: String = "", adopted: Bool = false, round: Int = 0) { self.id = id self.q = q self.fill = fill self.dim = dim self.adopted = adopted self.round = round } } enum AssistError: Error, LocalizedError { case modelNotReady case empty case parseFailed(String) var errorDescription: String? { switch self { case .modelNotReady: return String(appLoc: "AI 模型尚未准备好") case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试") case .parseFailed(let m): return String(appLoc: "结果解析失败:\(m)") } } } /// 返回 3-4 条追问。 /// - coveredDimensions: 多轮场景下,把之前各轮已覆盖的维度名(取自 question.dim)传进来, /// prompt 会明确要求本轮避开这些维度。第一轮传空数组。 /// 注意:本方法在 AIRuntime 的 actor 队列里串行排队,与 Capture / Export 互不抢占 GPU。 func suggest(content: String, coveredDimensions: [String] = []) async throws -> (questions: [Question], decodeRate: Double) { do { try await AIRuntime.shared.prepare() } catch { throw AssistError.modelNotReady } let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions) var collected = "" var lastRate: Double = 0 let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 400) for try await chunk in stream { collected += chunk.text if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } } // 1. 去 ...(复用 HealthExportService 的兜底) let stripped = HealthExportService.stripThinkBlocks(collected) // 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject) let jsonStr = CaptureService.extractJSONObject(from: stripped) guard let data = jsonStr.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), let dict = obj as? [String: Any] else { throw AssistError.parseFailed("非 JSON 输出") } guard let rawQuestions = dict["questions"] as? [[String: Any]] else { throw AssistError.parseFailed("缺少 questions 字段") } let questions = rawQuestions.compactMap { d -> Question? in guard let q = (d["q"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else { return nil } let fill = (d["fill"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let dim = (d["dim"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return Question(q: q, fill: fill, dim: dim) } guard !questions.isEmpty else { throw AssistError.empty } return (Array(questions.prefix(4)), lastRate) } }