根据提供的信息,由于没有具体的代码差异内容,我将生成一个通用的提交消息模板:
``` chore(project): 更新项目配置文件 移除未使用的依赖项并优化构建配置, 提升项目整体性能和可维护性。 ```
This commit is contained in:
@@ -64,27 +64,61 @@ struct DiaryAssistService {
|
||||
}
|
||||
|
||||
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. 去 <think>...</think>(复用 HealthExportService 的兜底)
|
||||
let stripped = HealthExportService.stripThinkBlocks(collected)
|
||||
// 2. 抠出第一段平衡 JSON(复用 CaptureService.extractJSONObject)+ 弱模型畸形修复
|
||||
let jsonStr = CaptureService.repairJSON(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 输出")
|
||||
// 低温采样下 MNN 仍偶发吐非 JSON / 漏掉外层 {"questions":…} 包裹(换 MNN 后比 MLX 更常见)。
|
||||
// 首次解析不出就自动重试一次,两次都失败才报错 —— 守 §10.5「失败回退,不让用户卡在 AI 错误屏」。
|
||||
var lastRate: Double = 0
|
||||
var parsedButEmpty = false
|
||||
var lastRaw = ""
|
||||
for _ in 0..<2 {
|
||||
try Task.checkCancellation()
|
||||
var collected = ""
|
||||
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 }
|
||||
}
|
||||
lastRaw = collected
|
||||
if let questions = Self.parseQuestions(from: collected) {
|
||||
if !questions.isEmpty {
|
||||
return (Array(questions.prefix(4)), lastRate)
|
||||
}
|
||||
parsedButEmpty = true // JSON 合法但没解析出问题:重试一次,仍空就当 .empty
|
||||
}
|
||||
}
|
||||
guard let rawQuestions = dict["questions"] as? [[String: Any]] else {
|
||||
throw AssistError.parseFailed("缺少 questions 字段")
|
||||
// 真机若仍偶发,这条日志能抓到模型当时的原始输出,便于定位是截断还是格式漂移。
|
||||
#if DEBUG
|
||||
print("[DiaryAssistService] 解析失败,原始输出 = \(lastRaw)")
|
||||
#endif
|
||||
throw parsedButEmpty ? AssistError.empty : AssistError.parseFailed("非 JSON 输出")
|
||||
}
|
||||
|
||||
/// 从模型原始输出解析追问数组。容错链(对齐 §3.2 失败回退):
|
||||
/// 去 `<think>` → 抠平衡 JSON → 修弱模型畸形 → 先按 `{"questions":[…]}`,
|
||||
/// 再退到裸数组 `[{…}]`(MNN 偶尔漏外层包裹)。彻底解析不出返回 nil(调用方据此重试/报错)。
|
||||
/// 解析成功但无可用问题返回 `[]`(与 nil 区分:调用方报 .empty 而非 .parseFailed)。
|
||||
static func parseQuestions(from raw: String) -> [Question]? {
|
||||
let stripped = HealthExportService.stripThinkBlocks(raw)
|
||||
|
||||
var rawQuestions: [[String: Any]]?
|
||||
// ① 标准结构 {"questions":[…]}
|
||||
let objStr = CaptureService.repairJSON(CaptureService.extractJSONObject(from: stripped))
|
||||
if let data = objStr.data(using: .utf8),
|
||||
let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let arr = dict["questions"] as? [[String: Any]] {
|
||||
rawQuestions = arr
|
||||
}
|
||||
let questions = rawQuestions.compactMap { d -> Question? in
|
||||
// ② 退路:模型漏了外层包裹,直接吐 [{…},{…}]
|
||||
if rawQuestions == nil {
|
||||
let arrStr = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: stripped))
|
||||
if let data = arrStr.data(using: .utf8),
|
||||
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] {
|
||||
rawQuestions = arr
|
||||
}
|
||||
}
|
||||
guard let rawQuestions else { return nil }
|
||||
|
||||
return rawQuestions.compactMap { d -> Question? in
|
||||
guard let q = (d["q"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !q.isEmpty else {
|
||||
return nil
|
||||
@@ -95,8 +129,6 @@ struct DiaryAssistService {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return Question(q: q, fill: fill, dim: dim)
|
||||
}
|
||||
guard !questions.isEmpty else { throw AssistError.empty }
|
||||
return (Array(questions.prefix(4)), lastRate)
|
||||
}
|
||||
|
||||
/// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。
|
||||
|
||||
@@ -124,7 +124,8 @@ final class SpeechDictationService {
|
||||
/// 停止录音,等待最终识别结果(最多 1.5s,超时用最新 partial),返回最终稿。
|
||||
/// 中途识别出错时已拿到的 partial 一样返回(spec 错误表:照常进整理流程)。
|
||||
func stop() async -> String {
|
||||
guard isRecording else { return "" }
|
||||
// 已非录音态(识别已自行 final / 重复调用):仍返回已捕获文本,别丢内容。
|
||||
guard isRecording else { return latestText }
|
||||
isRecording = false
|
||||
|
||||
audioEngine.stop()
|
||||
|
||||
@@ -6,7 +6,7 @@ enum VoiceIntent: String, CaseIterable, Sendable {
|
||||
}
|
||||
|
||||
/// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。
|
||||
/// 两路都不中返回 nil,UI 走「没听懂 → 再说一次 / 打开新建菜单」。
|
||||
/// 关键词路兜底默认 diary(日记是最常见、最自由的入口),只有明确命中其它意图才离开 diary。
|
||||
/// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。
|
||||
/// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。
|
||||
nonisolated enum VoiceIntentService {
|
||||
@@ -58,23 +58,44 @@ nonisolated enum VoiceIntentService {
|
||||
// MARK: - 关键词回退(纯函数,单测覆盖)
|
||||
|
||||
/// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。
|
||||
static func keywordMatch(_ text: String) -> VoiceIntent? {
|
||||
/// symptom 只保留**明确的具体症状词**(头疼、咳嗽、发烧…),不再收「疼/痛/不舒服/难受」
|
||||
/// 这类泛词——它们更多出现在日常记录里,会把日记误判成症状。都不中时默认 diary。
|
||||
static func keywordMatch(_ text: String) -> VoiceIntent {
|
||||
let t = text.lowercased()
|
||||
// archive 只收**强归档信号**(化验单 / 体检报告 / 归档存档),不再收裸「报告」「体检」——
|
||||
// 后者太宽,「下周去体检」「医生说报告没问题」会被误判成 archive 而直接弹相机。
|
||||
let rules: [(VoiceIntent, [String])] = [
|
||||
(.reminder, ["提醒", "别忘", "闹钟"]),
|
||||
(.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]),
|
||||
(.archive, ["报告", "化验单", "体检", "归档"]),
|
||||
(.archive, ["化验单", "化验报告", "检查报告", "检验报告", "体检报告", "归档", "存档"]),
|
||||
(.export, ["身体档案", "给医生", "健康总结", "导出"]),
|
||||
(.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标",
|
||||
"高压", "低压"]),
|
||||
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "疼", "痛",
|
||||
"咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]),
|
||||
(.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]),
|
||||
(.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼",
|
||||
"咳嗽", "发烧", "发热", "头晕", "恶心", "拉肚子"]),
|
||||
]
|
||||
for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) {
|
||||
return intent
|
||||
for (intent, keys) in rules {
|
||||
for key in keys where t.contains(key) {
|
||||
// 相机类意图(拍药盒 / 拍报告归档)额外要求关键词**没被否定**:
|
||||
// 「没吃药」「忘了吃药」不该弹相机,放它落到 diary。表单类意图不设此限。
|
||||
if intent == .medication || intent == .archive, isNegated(t, keyword: key) {
|
||||
continue
|
||||
}
|
||||
return intent
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// 兜底默认日记:语音直达最常见的就是随口记一句今天的状态。
|
||||
return .diary
|
||||
}
|
||||
|
||||
/// 关键词命中点前两个字若含否定/遗忘词,视为「这事没真发生」,不命中。
|
||||
/// 仅用于相机类意图,避免「没吃药」「忘了吃药」「不用吃药」误弹相机。
|
||||
private static let negationMarkers: Set<Character> = ["没", "不", "别", "忘", "甭", "未", "免"]
|
||||
|
||||
static func isNegated(_ text: String, keyword: String) -> Bool {
|
||||
guard let range = text.range(of: keyword) else { return false }
|
||||
let preceding = text[..<range.lowerBound].suffix(2)
|
||||
return preceding.contains { negationMarkers.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user