feat: 国际化(i18n) en/ja/ko + App 内语言切换
主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
+ pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存
注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftData
|
||||
|
||||
/// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。
|
||||
/// 与 Indicator/Report 字段近似但解耦 —— 这样 prompt schema 调整不污染数据层。
|
||||
@@ -40,16 +41,14 @@ struct ParsedReport: Sendable {
|
||||
/// CaptureService 错误 — UI 决定怎么呈现(回退表单 vs 重试)。
|
||||
enum CaptureError: Error, LocalizedError {
|
||||
case modelNotReady
|
||||
case writeAssetFailed
|
||||
case inferenceFailed(String)
|
||||
case parseFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelNotReady: return "VL 模型尚未就绪"
|
||||
case .writeAssetFailed: return "图片保存失败"
|
||||
case .inferenceFailed(let m): return "识别失败:\(m)"
|
||||
case .parseFailed(let m): return "结构化失败:\(m)"
|
||||
case .modelNotReady: return String(appLoc: "VL 模型尚未就绪")
|
||||
case .inferenceFailed(let m): return String(appLoc: "识别失败:\(m)")
|
||||
case .parseFailed(let m): return String(appLoc: "结构化失败:\(m)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,38 +59,36 @@ actor CaptureService {
|
||||
static let shared = CaptureService()
|
||||
private init() {}
|
||||
|
||||
/// 写图 + VL 推理 + 解析 → ParsedReport。
|
||||
/// 任何阶段失败,都抛 CaptureError;UI 接住后切到「手动录入」表单。
|
||||
/// - Returns: (ParsedReport, [FileVault.SavedAsset]) 元组,
|
||||
/// SavedAsset 列表用于后续构造 Asset @Model。
|
||||
func analyze(images: [UIImage]) async throws
|
||||
-> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) {
|
||||
/// 对已写入 Vault 的 Asset 跑 VL,返回结构化 ParsedReport。
|
||||
/// 用于:
|
||||
/// - UnifiedCaptureFlow 的初次识别(UI 先写图、再调本方法,失败/取消都能保留 assets 走手动录入)
|
||||
/// - 录入表单顶部的「重新识别」按钮
|
||||
/// - C2「重新解读」(W5)
|
||||
/// SwiftData 写回由调用方(MainActor)负责,见 `Report.applyReanalyzed(_:in:)`。
|
||||
/// 不直接接 @Model 类型,避免把非 Sendable 引用抛过 actor 边界。
|
||||
func reanalyze(assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||
try await runVL(on: assets)
|
||||
}
|
||||
|
||||
// 1. 写图到 Vault(全程加密目录)
|
||||
let assets: [FileVault.SavedAsset]
|
||||
/// VL 推理 + JSON 解析的纯阶段。assets 必须已写入 Vault。
|
||||
private func runVL(on assets: [FileVault.SavedAsset]) async throws -> ParsedReport {
|
||||
do {
|
||||
assets = try images.map { try FileVault.shared.writeJPEG($0) }
|
||||
try await AIRuntime.shared.prepareVL()
|
||||
} catch {
|
||||
throw CaptureError.writeAssetFailed
|
||||
throw CaptureError.modelNotReady
|
||||
}
|
||||
|
||||
// 2. VL 推理
|
||||
try await AIRuntime.shared.prepareVL()
|
||||
let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) }
|
||||
let raw: String
|
||||
do {
|
||||
raw = try await AIRuntime.shared.analyzeReport(
|
||||
imageURLs: urls,
|
||||
prompt: VLPrompts.reportExtraction
|
||||
prompt: VLPrompts.reportExtraction()
|
||||
)
|
||||
} catch {
|
||||
throw CaptureError.inferenceFailed("\(error)")
|
||||
}
|
||||
|
||||
// 3. JSON 解析(带容错:可能包含围栏 / 前后文字)
|
||||
do {
|
||||
let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count)
|
||||
return (parsed, assets)
|
||||
return try CaptureService.parseReportJSON(raw, pageCount: assets.count)
|
||||
} catch let CaptureError.parseFailed(msg) {
|
||||
throw CaptureError.parseFailed(msg)
|
||||
} catch {
|
||||
@@ -136,7 +133,7 @@ actor CaptureService {
|
||||
}
|
||||
|
||||
return ParsedReport(
|
||||
title: title.isEmpty ? "拍摄识别" : title,
|
||||
title: title.isEmpty ? String(appLoc: "拍摄识别") : title,
|
||||
typeRaw: typeRaw,
|
||||
reportDate: reportDate,
|
||||
institution: institution,
|
||||
@@ -216,3 +213,53 @@ actor CaptureService {
|
||||
return .init(name: name, value: value, unit: unit, range: range, status: status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Report ↔ CaptureService 桥接(MainActor 侧)
|
||||
//
|
||||
// CaptureService 是 actor,不能直接收 Report(@Model 非 Sendable)。
|
||||
// C2「重新解读」UI 走这条路径:
|
||||
// ```
|
||||
// let assets = report.savedAssets
|
||||
// let parsed = try await CaptureService.shared.reanalyze(assets: assets)
|
||||
// report.applyReanalyzed(parsed, in: ctx)
|
||||
// ```
|
||||
|
||||
extension Report {
|
||||
/// 关联 Asset 转 SavedAsset,直接喂 CaptureService.reanalyze。
|
||||
var savedAssets: [FileVault.SavedAsset] {
|
||||
assets.map { .init(relativePath: $0.relativePath, bytes: $0.bytes) }
|
||||
}
|
||||
|
||||
/// 把 VL 重新识别结果写回 Report。
|
||||
/// - indicators:旧的全删,新的整批插入并维持关联(cascade delete 会清缓存)
|
||||
/// - summary / institution:非空才覆盖,避免空摘要把好结果清掉
|
||||
/// 必须在 MainActor / SwiftData 主上下文里调用。
|
||||
@MainActor
|
||||
func applyReanalyzed(_ parsed: ParsedReport, in ctx: ModelContext) {
|
||||
if !parsed.summary.isEmpty {
|
||||
self.summary = parsed.summary
|
||||
}
|
||||
if !parsed.institution.isEmpty {
|
||||
self.institution = parsed.institution
|
||||
}
|
||||
// 旧 indicators 全删(cascade 会一起清)
|
||||
for old in indicators {
|
||||
ctx.delete(old)
|
||||
}
|
||||
indicators.removeAll()
|
||||
// 新 indicators 重新插入
|
||||
for p in parsed.indicators {
|
||||
let i = Indicator(
|
||||
name: p.name,
|
||||
value: p.value,
|
||||
unit: p.unit,
|
||||
range: p.range,
|
||||
status: p.status,
|
||||
capturedAt: reportDate,
|
||||
report: self
|
||||
)
|
||||
ctx.insert(i)
|
||||
}
|
||||
try? ctx.save()
|
||||
}
|
||||
}
|
||||
|
||||
101
康康/Services/DiaryAssistService.swift
Normal file
101
康康/Services/DiaryAssistService.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
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)
|
||||
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.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 输出")
|
||||
}
|
||||
guard let rawQuestions = dict["questions"] as? [[String: Any]] else {
|
||||
throw AssistError.parseFailed("缺少 questions 字段")
|
||||
}
|
||||
let questions = 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)
|
||||
}
|
||||
guard !questions.isEmpty else { throw AssistError.empty }
|
||||
return (Array(questions.prefix(4)), lastRate)
|
||||
}
|
||||
}
|
||||
460
康康/Services/HealthExportService.swift
Normal file
460
康康/Services/HealthExportService.swift
Normal file
@@ -0,0 +1,460 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// 「导出身体档案」的服务层。
|
||||
///
|
||||
/// 流程(对齐 spec §6):
|
||||
/// prepare → extractingIntent → retrieving → generating → completed
|
||||
///
|
||||
/// 红线对齐:
|
||||
/// - UI 只通过本服务调用 AI(§3.1)
|
||||
/// - 两次 LLM 调用都进 `AIRuntime.shared` 的 actor 队列,与 CaptureService 串行(§3.1)
|
||||
/// - 意图 JSON 解析失败 → 用 30 天 + 空关键词兜底,流程不中断(§3.2 / spec §9)
|
||||
/// - 不引入云、不写密码学、不重构现有结构(§10)
|
||||
@MainActor
|
||||
struct HealthExportService {
|
||||
|
||||
static let shared = HealthExportService()
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public types
|
||||
|
||||
enum Phase: String, Sendable {
|
||||
case extractingIntent
|
||||
case retrieving
|
||||
case generating
|
||||
case completed
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .extractingIntent: return String(appLoc: "理解意图")
|
||||
case .retrieving: return String(appLoc: "检索数据")
|
||||
case .generating: return String(appLoc: "撰写报告")
|
||||
case .completed: return String(appLoc: "已完成")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Event {
|
||||
case phaseChanged(Phase)
|
||||
case token(TokenChunk)
|
||||
case completed(persistentID: PersistentIdentifier)
|
||||
// .failed 走 stream throw,不在 Event 里
|
||||
}
|
||||
|
||||
enum ServiceError: Error, LocalizedError {
|
||||
case modelNotReady
|
||||
case generationFailed(String)
|
||||
case cancelled
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelNotReady: return String(appLoc: "AI 模型尚未准备好,请先到「我的 · 模型管理」下载。")
|
||||
case .generationFailed(let m): return String(appLoc: "生成失败:\(m)")
|
||||
case .cancelled: return String(appLoc: "已取消")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry point
|
||||
|
||||
/// 主入口。返回事件流;UI 关闭 sheet → stream 取消 → Service 不入库。
|
||||
/// 调用方需在 MainActor。
|
||||
func export(prompt: String,
|
||||
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task { @MainActor in
|
||||
do {
|
||||
// —— 预热模型(幂等) ——
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
} catch {
|
||||
throw ServiceError.modelNotReady
|
||||
}
|
||||
|
||||
// —— Phase 1: 抽意图 ——
|
||||
continuation.yield(.phaseChanged(.extractingIntent))
|
||||
let intent = await Self.extractIntent(userPrompt: prompt)
|
||||
try Task.checkCancellation()
|
||||
|
||||
// —— Phase 2: 检索 ——
|
||||
continuation.yield(.phaseChanged(.retrieving))
|
||||
let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
|
||||
try Task.checkCancellation()
|
||||
|
||||
// —— Phase 3: 生成 ——
|
||||
continuation.yield(.phaseChanged(.generating))
|
||||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||||
let genPrompt = HealthExportPrompts.reportGeneration(
|
||||
userPrompt: prompt,
|
||||
intentLabelCN: intent.labelCN,
|
||||
dataJSON: dataJSON
|
||||
)
|
||||
|
||||
// —— 流式去 <think>...</think> 兜底 ——
|
||||
// Prompt 里已加 Qwen3 的 `/no_think`,但模型偶尔仍带 thinking。
|
||||
// 用「全文累计 + 每 chunk 重清 + diff yield」:
|
||||
// - thinking 阶段,UI 看到的 generated 始终为空
|
||||
// - 看到 </think> 后,真实内容流式出现
|
||||
var rawAccum = ""
|
||||
var generated = ""
|
||||
var lastRate: Double = 0
|
||||
let stream = await AIRuntime.shared.generate(
|
||||
prompt: genPrompt,
|
||||
maxTokens: 1024
|
||||
)
|
||||
for try await chunk in stream {
|
||||
try Task.checkCancellation()
|
||||
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
|
||||
rawAccum += chunk.text
|
||||
let clean = Self.stripThinkBlocks(rawAccum)
|
||||
if clean.count > generated.count, clean.hasPrefix(generated) {
|
||||
let delta = String(clean.dropFirst(generated.count))
|
||||
generated = clean
|
||||
continuation.yield(.token(TokenChunk(
|
||||
text: delta,
|
||||
decodeRate: chunk.decodeRate
|
||||
)))
|
||||
} else if clean != generated {
|
||||
// 极少:清理后比上次还短(模型补了开标签)。让 UI 不要回退,
|
||||
// 直接对齐 generated = clean 但不 yield(避免显示倒退)。
|
||||
generated = clean
|
||||
}
|
||||
}
|
||||
|
||||
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw ServiceError.generationFailed("模型未输出任何内容")
|
||||
}
|
||||
|
||||
// —— Phase 4: 持久化 ——
|
||||
let export = HealthExport(
|
||||
prompt: prompt,
|
||||
content: generated,
|
||||
referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
|
||||
referencedReportIDs: snapshot.reports.map { Self.idString($0.persistentModelID) },
|
||||
referencedSymptomIDs: snapshot.symptoms.map { Self.idString($0.persistentModelID) },
|
||||
referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) },
|
||||
inferredTimeFromDate: snapshot.fromDate,
|
||||
inferredTimeToDate: snapshot.toDate,
|
||||
inferredIntent: intent.intent,
|
||||
decodeRate: lastRate
|
||||
)
|
||||
modelContext.insert(export)
|
||||
do { try modelContext.save() } catch {
|
||||
// 保存失败不阻塞 UI 显示文本;仅记日志(W6 可接 telemetry)
|
||||
print("[HealthExportService] save failed: \(error)")
|
||||
}
|
||||
continuation.yield(.phaseChanged(.completed))
|
||||
continuation.yield(.completed(persistentID: export.persistentModelID))
|
||||
continuation.finish()
|
||||
} catch is CancellationError {
|
||||
continuation.finish(throwing: ServiceError.cancelled)
|
||||
} catch let e as ServiceError {
|
||||
continuation.finish(throwing: e)
|
||||
} catch {
|
||||
continuation.finish(throwing: ServiceError.generationFailed("\(error)"))
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 1: intent extraction
|
||||
|
||||
struct Intent: Sendable {
|
||||
var timeRangeDays: Int
|
||||
var keywords: [String]
|
||||
var symptomKeywords: [String]
|
||||
var intent: String
|
||||
var labelCN: String
|
||||
|
||||
/// 兜底:抽不出 → 30 天 + 空关键词。
|
||||
static let fallback = Intent(
|
||||
timeRangeDays: 30,
|
||||
keywords: [],
|
||||
symptomKeywords: [],
|
||||
intent: "general_review",
|
||||
labelCN: "近期健康摘要"
|
||||
)
|
||||
}
|
||||
|
||||
/// 调一次 LLM 拿 JSON,失败用 `Intent.fallback`。
|
||||
/// 不流式 —— 直接拼成完整字符串再解析。
|
||||
private static func extractIntent(userPrompt: String) async -> Intent {
|
||||
let prompt = HealthExportPrompts.intentExtraction(userPrompt: userPrompt)
|
||||
var collected = ""
|
||||
do {
|
||||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 200)
|
||||
for try await chunk in stream {
|
||||
collected += chunk.text
|
||||
}
|
||||
} catch {
|
||||
return .fallback
|
||||
}
|
||||
return parseIntent(collected) ?? .fallback
|
||||
}
|
||||
|
||||
/// 解析 JSON。容错:抠出第一段 `{…}`,缺字段填默认值。
|
||||
/// 公开 (internal) 给单测调用。
|
||||
static func parseIntent(_ raw: String) -> Intent? {
|
||||
let jsonString = CaptureService.extractJSONObject(from: raw)
|
||||
guard let data = jsonString.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]),
|
||||
let dict = obj as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
let days = clampDays(dict["time_range_days"])
|
||||
let keywords = stringArray(dict["keywords"])
|
||||
let symptomKeywords = stringArray(dict["symptom_keywords"])
|
||||
let intent = (dict["intent"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "general_review"
|
||||
let labelCN = (dict["intent_label_cn"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "近期健康摘要"
|
||||
return Intent(
|
||||
timeRangeDays: days,
|
||||
keywords: keywords,
|
||||
symptomKeywords: symptomKeywords,
|
||||
intent: intent.isEmpty ? "general_review" : intent,
|
||||
labelCN: labelCN.isEmpty ? "近期健康摘要" : labelCN
|
||||
)
|
||||
}
|
||||
|
||||
private static func clampDays(_ raw: Any?) -> Int {
|
||||
if let n = raw as? Int { return max(1, min(365, n)) }
|
||||
if let n = raw as? Double { return max(1, min(365, Int(n))) }
|
||||
if let s = raw as? String, let n = Int(s) { return max(1, min(365, n)) }
|
||||
return 30
|
||||
}
|
||||
|
||||
private static func stringArray(_ raw: Any?) -> [String] {
|
||||
guard let arr = raw as? [Any] else { return [] }
|
||||
return arr.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - Phase 2: retrieve
|
||||
|
||||
struct Snapshot {
|
||||
var fromDate: Date
|
||||
var toDate: Date
|
||||
var indicators: [Indicator]
|
||||
var symptoms: [Symptom]
|
||||
var reports: [Report]
|
||||
var diaries: [DiaryEntry]
|
||||
var profile: UserProfile
|
||||
}
|
||||
|
||||
/// 同步 SwiftData 查询。@MainActor。
|
||||
private static func retrieve(intent: Intent, ctx: ModelContext) -> Snapshot {
|
||||
let toDate = Date()
|
||||
let fromDate = Calendar.current.date(
|
||||
byAdding: .day, value: -intent.timeRangeDays, to: toDate
|
||||
) ?? toDate.addingTimeInterval(-30 * 86400)
|
||||
|
||||
// —— Indicators(时间窗 + 关键词软过滤) ——
|
||||
let indDesc = FetchDescriptor<Indicator>(
|
||||
predicate: #Predicate { $0.capturedAt >= fromDate && $0.capturedAt <= toDate },
|
||||
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
|
||||
)
|
||||
var indicators = (try? ctx.fetch(indDesc)) ?? []
|
||||
if !intent.keywords.isEmpty {
|
||||
let filtered = indicators.filter { ind in
|
||||
intent.keywords.contains { kw in
|
||||
ind.name.localizedCaseInsensitiveContains(kw)
|
||||
}
|
||||
}
|
||||
// 关键词命中为主,但保留所有异常项(避免漏掉医生关心的)
|
||||
let abnormal = indicators.filter { $0.status != .normal }
|
||||
let combined = (filtered + abnormal).reduce(into: [Indicator]()) { acc, x in
|
||||
if !acc.contains(where: { $0.persistentModelID == x.persistentModelID }) {
|
||||
acc.append(x)
|
||||
}
|
||||
}
|
||||
indicators = combined.isEmpty ? indicators : combined
|
||||
}
|
||||
indicators = Array(indicators.prefix(20))
|
||||
|
||||
// —— Symptoms(时间窗有交叠) ——
|
||||
let symptomDesc = FetchDescriptor<Symptom>(
|
||||
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
|
||||
)
|
||||
let allSymptoms = (try? ctx.fetch(symptomDesc)) ?? []
|
||||
let symptoms = Array(
|
||||
allSymptoms.filter { sym in
|
||||
let overlapsStart = sym.startedAt <= toDate
|
||||
let overlapsEnd = (sym.endedAt ?? Date.distantFuture) >= fromDate
|
||||
return overlapsStart && overlapsEnd
|
||||
}.prefix(10)
|
||||
)
|
||||
|
||||
// —— Reports(时间窗) ——
|
||||
let reportDesc = FetchDescriptor<Report>(
|
||||
predicate: #Predicate { $0.reportDate >= fromDate && $0.reportDate <= toDate },
|
||||
sortBy: [SortDescriptor(\.reportDate, order: .reverse)]
|
||||
)
|
||||
let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))
|
||||
|
||||
// —— Diary(隐私过滤:必须有 symptom_keyword 命中,否则不入 prompt) ——
|
||||
let diaries: [DiaryEntry]
|
||||
if intent.symptomKeywords.isEmpty {
|
||||
diaries = []
|
||||
} else {
|
||||
let diaryDesc = FetchDescriptor<DiaryEntry>(
|
||||
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
|
||||
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
|
||||
)
|
||||
let all = (try? ctx.fetch(diaryDesc)) ?? []
|
||||
diaries = Array(
|
||||
all.filter { d in
|
||||
intent.symptomKeywords.contains { kw in
|
||||
d.content.localizedCaseInsensitiveContains(kw)
|
||||
}
|
||||
}.prefix(5)
|
||||
)
|
||||
}
|
||||
|
||||
// —— Profile(单例) ——
|
||||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||||
|
||||
return Snapshot(
|
||||
fromDate: fromDate,
|
||||
toDate: toDate,
|
||||
indicators: indicators,
|
||||
symptoms: symptoms,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
profile: profile
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Phase 3: serialize data for prompt
|
||||
|
||||
/// 把 Snapshot 序列化成给 LLM 的精简 JSON。
|
||||
/// 不用 Codable —— 字段命名要保持 prompt 里描述的英文 key,顺序也要稳定。
|
||||
static func serializeData(snapshot: Snapshot) -> String {
|
||||
let df = DateFormatter()
|
||||
df.locale = Locale(identifier: "en_US_POSIX")
|
||||
df.dateFormat = "yyyy-MM-dd"
|
||||
|
||||
let profile = snapshot.profile
|
||||
var root: [String: Any] = [:]
|
||||
|
||||
// profile
|
||||
var profDict: [String: Any] = [:]
|
||||
if let age = profile.age { profDict["age"] = age }
|
||||
let sexLabel = profile.sex.label
|
||||
if profile.sex != .undisclosed { profDict["sex"] = sexLabel }
|
||||
if let h = profile.heightCM { profDict["height_cm"] = h }
|
||||
if let w = profile.weightKG {
|
||||
profDict["weight_kg"] = w.truncatingRemainder(dividingBy: 1) == 0
|
||||
? Int(w) : Double(round(w * 10) / 10)
|
||||
}
|
||||
if !profile.bloodTypeRaw.isEmpty { profDict["blood_type"] = profile.bloodTypeRaw }
|
||||
if !profile.allergies.isEmpty { profDict["allergies"] = profile.allergies }
|
||||
if !profile.chronicConditions.isEmpty { profDict["chronic"] = profile.chronicConditions }
|
||||
if !profile.familyHistory.isEmpty { profDict["family_history"] = profile.familyHistory }
|
||||
if !profile.currentMedications.isEmpty { profDict["current_meds"] = profile.currentMedications }
|
||||
root["profile"] = profDict
|
||||
|
||||
// symptoms
|
||||
root["symptoms"] = snapshot.symptoms.map { s -> [String: Any] in
|
||||
var d: [String: Any] = [
|
||||
"name": s.name,
|
||||
"started": df.string(from: s.startedAt),
|
||||
"severity": s.severity,
|
||||
"ongoing": s.isOngoing
|
||||
]
|
||||
if let ended = s.endedAt { d["ended"] = df.string(from: ended) }
|
||||
if let note = s.note, !note.isEmpty { d["note"] = note }
|
||||
return d
|
||||
}
|
||||
|
||||
// indicators
|
||||
root["indicators"] = snapshot.indicators.map { i -> [String: Any] in
|
||||
[
|
||||
"name": i.name,
|
||||
"value": i.value,
|
||||
"unit": i.unit,
|
||||
"range": i.range,
|
||||
"status": i.status.rawValue,
|
||||
"date": df.string(from: i.capturedAt)
|
||||
]
|
||||
}
|
||||
|
||||
// reports
|
||||
root["reports"] = snapshot.reports.map { r -> [String: Any] in
|
||||
var d: [String: Any] = [
|
||||
"title": r.title,
|
||||
"type": r.type.label,
|
||||
"date": df.string(from: r.reportDate)
|
||||
]
|
||||
if let inst = r.institution, !inst.isEmpty { d["institution"] = inst }
|
||||
if let sum = r.summary, !sum.isEmpty { d["summary"] = sum }
|
||||
return d
|
||||
}
|
||||
|
||||
// diaries
|
||||
root["diaries"] = snapshot.diaries.map { d -> [String: Any] in
|
||||
let excerpt = String(d.content.prefix(80))
|
||||
return [
|
||||
"date": df.string(from: d.createdAt),
|
||||
"excerpt": excerpt
|
||||
]
|
||||
}
|
||||
|
||||
// 时间窗也给 LLM 看
|
||||
root["time_window"] = [
|
||||
"from": df.string(from: snapshot.fromDate),
|
||||
"to": df.string(from: snapshot.toDate)
|
||||
]
|
||||
|
||||
guard let data = try? JSONSerialization.data(
|
||||
withJSONObject: root,
|
||||
options: [.prettyPrinted, .sortedKeys]
|
||||
),
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return "{}"
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// 把 SwiftData persistentModelID 编成稳定字符串。
|
||||
/// W3 引用回链跳源记录时,用这个字符串反查(暂未实现)。
|
||||
private static func idString(_ id: PersistentIdentifier) -> String {
|
||||
String(describing: id)
|
||||
}
|
||||
|
||||
// MARK: - <think> 标签清理
|
||||
|
||||
/// 在全文累计上做一次性清理,返回应展示给用户的干净文本。
|
||||
/// 用「累计 + 重清 + diff yield」方式调用,确保:
|
||||
/// - 配对 `<think>...</think>` 整段移除(包括空 think 块)
|
||||
/// - 未闭合 `<think>...`(还没等到闭标签)→ 全部暂存,等闭标签出现再放
|
||||
/// - Qwen3 偶尔只吐 `</think>` 闭标签 → 它之前的内容也当 thinking 丢弃
|
||||
/// - 头部空白 trim,避免 `## 标题` 前面有多余空行
|
||||
static func stripThinkBlocks(_ raw: String) -> String {
|
||||
var s = raw
|
||||
|
||||
// 1. 反复删配对 <think>...</think>(包括 think 块体为空的情况)
|
||||
while let openR = s.range(of: "<think>"),
|
||||
let closeR = s.range(of: "</think>", range: openR.upperBound..<s.endIndex) {
|
||||
s.removeSubrange(openR.lowerBound..<closeR.upperBound)
|
||||
}
|
||||
|
||||
// 2. 未闭合的开标签:开标签之后的全部当未完成的思考,先不显示
|
||||
if let openR = s.range(of: "<think>") {
|
||||
s = String(s[..<openR.lowerBound])
|
||||
}
|
||||
|
||||
// 3. 孤立闭标签(Qwen3 偶尔无开标签):闭标签之前全部当思考丢弃
|
||||
if let closeR = s.range(of: "</think>") {
|
||||
s = String(s[closeR.upperBound...])
|
||||
}
|
||||
|
||||
// 4. 顶部空白 trim
|
||||
while let first = s.first, first.isWhitespace {
|
||||
s.removeFirst()
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ final class ModelDownloadService {
|
||||
states[kind] = DownloadState(phase: .ready, receivedBytes: total,
|
||||
totalBytes: total, bytesPerSecond: 0)
|
||||
} else {
|
||||
states[kind] = DownloadState(phase: .failed(message ?? "下载失败"),
|
||||
states[kind] = DownloadState(phase: .failed(message ?? String(appLoc: "下载失败")),
|
||||
receivedBytes: completedBytes(for: kind),
|
||||
totalBytes: total, bytesPerSecond: 0)
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ enum ReminderService {
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "该测\(reminder.displayName)了"
|
||||
content.body = "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"
|
||||
content.title = String(appLoc: "该测\(reminder.displayName)了")
|
||||
content.body = String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次")
|
||||
content.sound = .default
|
||||
content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user