import Foundation /// 药盒识别结果(结构化,与 UserProfile.currentMedications 的字符串条目解耦)。 struct ParsedMedication: Sendable, Identifiable { let id = UUID() var name: String var strength: String // 规格,如 "80mg×7粒" var usage: String // 用法,如 "口服,一次1片,一日2次" /// 写入 UserProfile.currentMedications 的单行文本, /// 与手动录入习惯一致(placeholder "如:缬沙坦 80mg qd")。 var entryText: String { var s = name.trimmingCharacters(in: .whitespaces) let st = strength.trimmingCharacters(in: .whitespaces) let u = usage.trimmingCharacters(in: .whitespaces) if !st.isEmpty { s += " \(st)" } if !u.isEmpty { s += " · \(u)" } return s } } /// 「拍药盒入档」服务:OCR 文本 → LLM(MNN/SME2 主链路)结构化抽药品。 /// 与 CaptureService.recognizeIndicators 同构:UI 不直接碰 AIRuntime(§3.1), /// 失败抛 CaptureError,UI 回退手动录入(§3.2)。 /// actor 原因同 CaptureService:方法要等 AIRuntime(actor),自身无可变状态。 actor MedicationScanService { static let shared = MedicationScanService() private init() {} /// 药盒/说明书/处方的 OCR 文本 → [ParsedMedication]。 /// 调用方(MainActor)先做 OCR 再传文本进来,避免 UIImage 跨 actor。 func recognizeMedications(fromOCRText text: String) async throws -> [ParsedMedication] { do { try await AIRuntime.shared.prepare() // 载 LLM(与 VL 互斥卸载由 AIRuntime 闸门处理) } catch { throw CaptureError.modelNotReady } let prompt = MedicationPrompts.medicationsFromText(text) var collected = "" do { // 药盒一般 1-2 种药,512 token 足够;与其他推理由 AIRuntime 闸门串行。 let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512) for try await chunk in stream { collected += chunk.text } } catch { throw CaptureError.inferenceFailed("\(error)") } let cleaned = CaptureService.stripThink(collected) do { return try Self.parseMedicationsJSON(cleaned) } catch let CaptureError.parseFailed(msg) { let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60)) throw CaptureError.parseFailed("\(msg)〔前缀:\(preview)〕") } catch { throw CaptureError.parseFailed("\(error)") } } // MARK: - JSON parse(static 纯函数 → 方便单测) /// 兼容 `{"medications":[...]}` 与裸数组 `[...]`。 /// 解析不到任何药品返回空数组(不抛),UI 据此走「手动补充」分支;JSON 不合法才抛。 static func parseMedicationsJSON(_ raw: String) throws -> [ParsedMedication] { let jsonString = CaptureService.repairJSON(CaptureService.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)") } let rawList: [[String: Any]] if let dict = obj as? [String: Any] { rawList = arrayValue(dict, keys: ["medications", "meds", "drugs", "药品", "用药", "items"]) } else if let arr = obj as? [[String: Any]] { rawList = arr } else { throw CaptureError.parseFailed("根节点既不是对象也不是数组") } var seen = Set() return rawList.compactMap { parseMedication($0) }.filter { seen.insert($0.name).inserted } } private static func parseMedication(_ d: [String: Any]) -> ParsedMedication? { guard let name = stringValue(d, keys: ["name", "drug", "medication", "药名", "药品", "名称"])? .trimmingCharacters(in: .whitespaces), !name.isEmpty else { return nil } let strength = stringValue(d, keys: ["strength", "spec", "specification", "规格", "剂量"]) ?? "" let usage = stringValue(d, keys: ["usage", "dosage", "用法", "用量", "用法用量"]) ?? "" return ParsedMedication(name: name, strength: strength.trimmingCharacters(in: .whitespaces), usage: usage.trimmingCharacters(in: .whitespaces)) } private static func stringValue(_ d: [String: Any], keys: [String]) -> String? { for key in keys { if let s = d[key] as? String { return s } if let n = d[key] as? NSNumber { return n.stringValue } } return nil } private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] { for key in keys { if let arr = d[key] as? [[String: Any]] { return arr } } return [] } }