```
feat: 添加拍药盒功能和语音直达入口 - 实现拍药盒扫描流程,支持本地OCR识别药品信息 - 在日记页面添加拍药盒和记症状的三选一入口 - 优化按钮点击区域,确保符合苹果HIG最小命中区标准 - 添加用药记录到时间线的独立分类显示 - 实现长按+号语音直达功能,支持语音意图分类跳转 - 更新项目配置文件,启用代码分析和死代码剥离选项 - 增加多项本地化字符串支持新功能 ```
This commit is contained in:
114
康康/Services/MedicationScanService.swift
Normal file
114
康康/Services/MedicationScanService.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
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<String>()
|
||||
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 []
|
||||
}
|
||||
}
|
||||
96
康康/Services/VoiceIntentService.swift
Normal file
96
康康/Services/VoiceIntentService.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
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<T: Sendable>(
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user