import Foundation /// 「长按 + 语音直达」可路由到的新建入口。rawValue 与 IntentPrompts 的分类 token 一致。 enum VoiceIntent: String, CaseIterable, Sendable { case diary, medication, symptom, indicator, archive, export, reminder } /// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。 /// 两路都不中返回 nil,UI 走「没听懂 → 再说一次 / 打开新建菜单」。 /// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。 /// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。 nonisolated enum VoiceIntentService { static func classify(_ utterance: String) async -> VoiceIntent? { let text = utterance.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return nil } // 模型冷启动可能要载入十几秒,语音直达等不起:6s 拿不到就走关键词。 if let intent = try? await withTimeout(seconds: 6, operation: { try await classifyWithLLM(text) }) { return intent } return keywordMatch(text) } // MARK: - LLM 分类 private static func classifyWithLLM(_ text: String) async throws -> VoiceIntent { try await AIRuntime.shared.prepare() let stream = await AIRuntime.shared.generate(prompt: IntentPrompts.classify(text), maxTokens: 48) var collected = "" for try await chunk in stream { collected += chunk.text } guard let intent = parseIntent(from: collected) else { throw CaptureError.parseFailed("intent") } return intent } /// 从模型输出抠 `{"intent":"…"}`。容错:think 块、围栏、裸词。"unknown"/未知值返回 nil。 static func parseIntent(from raw: String) -> VoiceIntent? { let cleaned = CaptureService.stripThink(raw) let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: cleaned)) if let data = jsonString.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let token = obj["intent"] as? String { return VoiceIntent(rawValue: token.trimmingCharacters(in: .whitespaces).lowercased()) } // 兜底:模型偶尔只吐裸词(diary / symptom …) let bare = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`。.")) .lowercased() return VoiceIntent(rawValue: bare) } // MARK: - 关键词回退(纯函数,单测覆盖) /// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。 static func keywordMatch(_ text: String) -> VoiceIntent? { let t = text.lowercased() let rules: [(VoiceIntent, [String])] = [ (.reminder, ["提醒", "别忘", "闹钟"]), (.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]), (.archive, ["报告", "化验单", "体检", "归档"]), (.export, ["身体档案", "给医生", "健康总结", "导出"]), (.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标", "高压", "低压"]), (.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "疼", "痛", "咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]), (.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]), ] for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) { return intent } return nil } } /// 简单超时竞速:operation 与 sleep 赛跑,超时抛 CancellationError 并取消未完成方。 nonisolated private func withTimeout( seconds: Double, operation: @escaping @Sendable () async throws -> T ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw CancellationError() } guard let result = try await group.next() else { throw CancellationError() } group.cancelAll() return result } }