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错误情况的恢复测试,确保模型输出格式异常时的容错性
```
77 lines
3.8 KiB
Swift
77 lines
3.8 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import 康康
|
|
|
|
/// 写日记 AI 辅助的纯解析函数 `DiaryAssistService.parseQuestions`。
|
|
/// 这是「非 JSON 输出」回归的修复重点:解析要对 MNN 的各种畸形输出健壮,
|
|
/// 彻底解析不出才返回 nil(由 suggest 重试/报错),解析成功但无问题返回 []。
|
|
/// parseQuestions 随 @MainActor struct 隔离,测试整体标 @MainActor。
|
|
@MainActor
|
|
struct DiaryAssistParseTests {
|
|
|
|
@Test func parsesStandardWrappedJSON() {
|
|
let raw = #"{"questions":[{"dim":"起病诱因","q":"什么时候开始的?","fill":"从[时间]开始,"},{"dim":"症状性质","q":"是哪种不适?","fill":"性质是[]"}]}"#
|
|
let qs = DiaryAssistService.parseQuestions(from: raw)
|
|
#expect(qs?.count == 2)
|
|
#expect(qs?.first?.dim == "起病诱因")
|
|
}
|
|
|
|
@Test func parsesMarkdownFenced() {
|
|
let raw = """
|
|
```json
|
|
{"questions":[{"dim":"持续频率","q":"持续多久了?","fill":"已持续[时长]"}]}
|
|
```
|
|
"""
|
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
|
|
}
|
|
|
|
@Test func parsesThinkWrapped() {
|
|
let raw = "<think>用户头痛,该问起病诱因</think>{\"questions\":[{\"dim\":\"起病诱因\",\"q\":\"何时开始?\",\"fill\":\"从[时间]\"}]}"
|
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
|
|
}
|
|
|
|
@Test func parsesBareArrayWithoutWrapper() {
|
|
// MNN 偶尔漏掉外层 {"questions":…},直接吐数组 —— 必须能兜住
|
|
let raw = #"[{"dim":"加重缓解","q":"做什么会加重?","fill":"[活动]时加重"},{"dim":"生活方式","q":"睡眠如何?","fill":"近期睡眠[]"}]"#
|
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 2)
|
|
}
|
|
|
|
@Test func repairsTrailingCommaAndSmartQuotes() {
|
|
// 尾逗号 + 中文弯引号:repairJSON 应修好后正常解析
|
|
let raw = "{“questions”:[{“dim”:“用药过敏”,“q”:“在吃什么药?”,“fill”:“在服[药名],”},]}"
|
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
|
|
}
|
|
|
|
@Test func emptyQuestionsArrayReturnsEmptyNotNil() {
|
|
// 合法 JSON 但没有问题:返回 [](调用方报 .empty,不是 .parseFailed)
|
|
let qs = DiaryAssistService.parseQuestions(from: #"{"questions":[]}"#)
|
|
#expect(qs != nil)
|
|
#expect(qs?.isEmpty == true)
|
|
}
|
|
|
|
@Test func proseReturnsNil() {
|
|
#expect(DiaryAssistService.parseQuestions(from: "我觉得你可以多问问睡眠情况。") == nil)
|
|
}
|
|
|
|
@Test func unterminatedThinkOnlyReturnsNil() {
|
|
// 整段都在思考、没吐 JSON 就被截断:strip 后为空 → nil(交给 suggest 重试)
|
|
#expect(DiaryAssistService.parseQuestions(from: "<think>嗯,用户写了头痛,我应该问") == nil)
|
|
}
|
|
|
|
@Test func salvagesTruncatedTailObject() {
|
|
// maxTokens 截断:外层 } 和最后一条 question 都没闭合,但前两条已完整。
|
|
// ①② 整体解析必失败,③ 逐对象救回前两条 —— 用户仍拿到可用追问,不卡错误屏。
|
|
let raw = #"{"questions":[{"dim":"起病诱因","q":"何时开始?","fill":"从[时间]"},{"dim":"症状性质","q":"哪种不适?","fill":"性质[]"},{"dim":"伴随症状","q":"还有别的不"#
|
|
let qs = DiaryAssistService.parseQuestions(from: raw)
|
|
#expect(qs?.count == 2)
|
|
#expect(qs?.first?.q == "何时开始?")
|
|
#expect(qs?.last?.dim == "症状性质")
|
|
}
|
|
|
|
@Test func salvagesWhenWrapperKeyWrong() {
|
|
// 模型把外层 key 写错(items 而非 questions)且没吐裸数组:①② 都不命中 → ③ 抠内层对象救回
|
|
let raw = #"{"items":[{"dim":"持续频率","q":"持续多久了?","fill":"已持续[时长]"}]}"#
|
|
#expect(DiaryAssistService.parseQuestions(from: raw)?.count == 1)
|
|
}
|
|
}
|