feat: 添加拍药盒功能和语音直达入口

- 实现拍药盒扫描流程,支持本地OCR识别药品信息
- 在日记页面添加拍药盒和记症状的三选一入口
- 优化按钮点击区域,确保符合苹果HIG最小命中区标准
- 添加用药记录到时间线的独立分类显示
- 实现长按+号语音直达功能,支持语音意图分类跳转
- 更新项目配置文件,启用代码分析和死代码剥离选项
- 增加多项本地化字符串支持新功能
```
This commit is contained in:
link2026
2026-06-13 09:16:25 +08:00
parent f58d6064ba
commit 6c6a950140
30 changed files with 1856 additions and 64 deletions

View 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 []
}
}

View 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
}
}