feat(diary): 优化日记AI协助交互体验
- 添加promptBanner作为未开始协作时的醒目邀请横幅,包含圆形图标、标题和说明文字
- 重构assistSection使用switch语句处理careState不同状态,区分隐藏、prompt和其他状态
- 增加动画过渡效果消除聚焦/失焦切换时的布局跳动
- 优化thinking状态下的UI展示,添加AIFlowBar彩色呼吸条显示推理进度
- 修改requestSuggestions逻辑,进入推理时收起键盘以完整显示协作卡片
refactor(inference): 优化性能自检界面样式
- 将性能自检入口改为描边动作按钮(TjGhostButton),与引擎选择在视觉上区分开
- 调整未就绪状态下的禁用样式和提示文案
feat(localization): 添加新的本地化字符串
- 新增"追踪"和"记一笔"的多语言翻译,包括英语、日语和韩语
fix(diary): 增强AI问答解析稳定性
- 将最大token数从400提升至512,避免中文问题JSON被截断导致解析失败
- 实现salvageQuestionObjects方法作为终极兜底机制,逐个解析平衡的{...}对象
- 当外层wrapper解析失败时,仍可救回内部已闭合的问题对象,确保用户不被AI错误卡住
test(diary): 补充AI问答解析测试用例
- 添加截断对象恢复测试,验证maxTokens截断时前序完整问题的救回能力
- 添加wrapper key错误情况的恢复测试,确保模型输出格式异常时的容错性
```
205 lines
9.4 KiB
Swift
205 lines
9.4 KiB
Swift
import Foundation
|
|
|
|
/// 「健康记录」AI 辅助:让 LLM 从医生角度提 3-4 个追问问题。
|
|
///
|
|
/// 设计上和 HealthExportService 同款门面,但输出量小(< 400 token),
|
|
/// 不流式 —— 直接 await 收完整结果再解析。
|
|
///
|
|
/// 调用方:DiaryQuickSheet。
|
|
@MainActor
|
|
struct DiaryAssistService {
|
|
static let shared = DiaryAssistService()
|
|
private init() {}
|
|
|
|
/// 单条追问。fill 是带方括号占位符的模板,采纳时追加到原文末尾。
|
|
/// `dim` 是问诊维度(取自 `DiaryAssistPrompts.dimensions`),用于跨轮按维度去重。
|
|
/// `adopted` 由 UI 标记;`round` 由 UI 在 append 前打戳,用于多轮分组显示。
|
|
struct Question: Identifiable, Hashable {
|
|
let id: UUID
|
|
let q: String
|
|
let fill: String
|
|
let dim: String
|
|
var adopted: Bool
|
|
var round: Int
|
|
|
|
init(id: UUID = UUID(),
|
|
q: String,
|
|
fill: String,
|
|
dim: String = "",
|
|
adopted: Bool = false,
|
|
round: Int = 0) {
|
|
self.id = id
|
|
self.q = q
|
|
self.fill = fill
|
|
self.dim = dim
|
|
self.adopted = adopted
|
|
self.round = round
|
|
}
|
|
}
|
|
|
|
enum AssistError: Error, LocalizedError {
|
|
case modelNotReady
|
|
case empty
|
|
case parseFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好")
|
|
case .empty: return String(appLoc: "AI 没有给出建议,请稍后重试")
|
|
case .parseFailed(let m): return String(appLoc: "结果解析失败:\(m)")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 返回 3-4 条追问。
|
|
/// - coveredDimensions: 多轮场景下,把之前各轮已覆盖的维度名(取自 question.dim)传进来,
|
|
/// prompt 会明确要求本轮避开这些维度。第一轮传空数组。
|
|
/// 注意:本方法在 AIRuntime 的 actor 队列里串行排队,与 Capture / Export 互不抢占 GPU。
|
|
func suggest(content: String,
|
|
coveredDimensions: [String] = []) async throws -> (questions: [Question], decodeRate: Double) {
|
|
do {
|
|
try await AIRuntime.shared.prepare()
|
|
} catch {
|
|
throw AssistError.modelNotReady
|
|
}
|
|
|
|
let prompt = DiaryAssistPrompts.suggest(content: content, coveredDimensions: coveredDimensions)
|
|
|
|
// 低温采样下 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 = ""
|
|
// 512(原 400):3-4 条含 dim/q/fill 的中文问题 JSON 约 250-320 token,400 在
|
|
// 模型偶尔加前导/换行时易把尾部那条问题截断 → 整体解析失败。留足余量更稳。
|
|
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512)
|
|
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
|
|
}
|
|
}
|
|
// 真机若仍偶发,这条日志能抓到模型当时的原始输出,便于定位是截断还是格式漂移。
|
|
#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
|
|
}
|
|
// ② 退路:模型漏了外层包裹,直接吐 [{…},{…}]
|
|
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
|
|
}
|
|
}
|
|
// ③ 终极兜底:外层包裹畸形 / 尾部被 maxTokens 截断时,①② 的整体解析都会失败。
|
|
// 逐个抠出文本里所有平衡的 {…},凡含 "q" 字段的就当一条问题救回 —— 即便最后一条
|
|
// 被截断,前面已闭合的几条仍能用。守 §3.2 / 红线 #5「失败回退,不让用户卡在 AI 错误屏」。
|
|
if rawQuestions == nil {
|
|
let salvaged = salvageQuestionObjects(from: stripped)
|
|
if !salvaged.isEmpty { rawQuestions = salvaged }
|
|
}
|
|
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
|
|
}
|
|
let fill = (d["fill"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let dim = (d["dim"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return Question(q: q, fill: fill, dim: dim)
|
|
}
|
|
}
|
|
|
|
/// 逐对象救回:扫描原始文本里【所有】平衡的 `{…}` 子串(含嵌套内层),逐个尝试解析,
|
|
/// 凡是含 `"q"` 字段的对象就当作一条问题收集。用于 ①② 整体解析失败的兜底:
|
|
/// - 外层 `{"questions":[…]}` 的 `}` 被截断 → 但内部前几条 question 对象已闭合,照样救回;
|
|
/// - 模型把对象拆在解释文字里 → 也能逐个抠出。
|
|
/// 外层 wrapper 自身不含 `"q"`(只有 `"questions"`),天然被过滤掉,不会误收。
|
|
/// 按 q 文本去重(保序),避免嵌套时同一对象被父子两层重复收集。
|
|
private static func salvageQuestionObjects(from raw: String) -> [[String: Any]] {
|
|
var openStack: [String.Index] = []
|
|
var collected: [[String: Any]] = []
|
|
var seenQ = Set<String>()
|
|
var inString = false
|
|
var escape = false
|
|
var idx = raw.startIndex
|
|
while idx < raw.endIndex {
|
|
let ch = raw[idx]
|
|
if escape { escape = false }
|
|
else if ch == "\\" { escape = true }
|
|
else if ch == "\"" { inString.toggle() }
|
|
else if !inString {
|
|
if ch == "{" {
|
|
openStack.append(idx)
|
|
} else if ch == "}", let open = openStack.popLast() {
|
|
let sub = CaptureService.repairJSON(String(raw[open...idx]))
|
|
if let data = sub.data(using: .utf8),
|
|
let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
|
let q = dict["q"] as? String,
|
|
!q.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
seenQ.insert(q).inserted {
|
|
collected.append(dict)
|
|
}
|
|
}
|
|
}
|
|
idx = raw.index(after: idx)
|
|
}
|
|
return collected
|
|
}
|
|
|
|
/// 把语音转写稿整理成健康日记草稿(spec 2026-06-10-voice-diary)。
|
|
/// 失败(模型未就绪 / 输出为空)抛错,调用方回退为直接使用原话,不卡死。
|
|
/// 与 suggest 同样走 AIRuntime actor 队列,自然与追问/拍照串行。
|
|
func organize(transcript: String) async throws -> (text: String, decodeRate: Double) {
|
|
do {
|
|
try await AIRuntime.shared.prepare()
|
|
} catch {
|
|
throw AssistError.modelNotReady
|
|
}
|
|
|
|
let prompt = DiaryAssistPrompts.organize(transcript: transcript)
|
|
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 }
|
|
}
|
|
|
|
let text = HealthExportService.stripThinkBlocks(collected)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else { throw AssistError.empty }
|
|
return (text, lastRate)
|
|
}
|
|
}
|