feat(AI): 集成MNN推理引擎替换MLX作为主AI运行时 - 引入MNN(alibaba) + Arm SME2 + CPU作为主AI运行时,支持A19/iPhone17的 SME2和A17的NEON加速 - 添加MLX Swift作为兜底GPU推理方案,实现双后端切换机制 - 使用单一Qwen3.5-2B多模态模型(1.2GB),替代原有的LLM+VL分离架构 - 实现InferenceEngine.current引擎选择逻辑,真机默认MNN,模拟器回退MLX - 更新AIAgent架构,通过MNNLLMBridge(ObjC++) → MNNBackend进行推理 - 修改队列机制防止并发推理导致OOM,使用信号量闸门控制显存占用 - 更新文档中的技术栈说明、模块边界和周次交付计划 ```
789 lines
34 KiB
Swift
789 lines
34 KiB
Swift
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: "已完成")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 检索结果摘要 —— 把「本地 RAG 找到了什么」拿给 UI 演出来(§12 卖点 3)。
|
||
struct RetrievalSummary: Sendable, Equatable {
|
||
var chips: [String]
|
||
var indicatorCount: Int
|
||
var reportCount: Int
|
||
var symptomCount: Int
|
||
var diaryCount: Int
|
||
|
||
var totalCount: Int { indicatorCount + reportCount + symptomCount + diaryCount }
|
||
|
||
/// 同名指标合并计数(保持检索的新→旧顺序),超出 cap 折叠成 "+N"。纯函数,单测覆盖。
|
||
static func groupedChips(_ names: [String], cap: Int = 8) -> [String] {
|
||
var order: [String] = []
|
||
var counts: [String: Int] = [:]
|
||
for n in names {
|
||
if counts[n] == nil { order.append(n) }
|
||
counts[n, default: 0] += 1
|
||
}
|
||
var chips = order.map { name -> String in
|
||
let c = counts[name] ?? 1
|
||
return c > 1 ? "\(name) ×\(c)" : name
|
||
}
|
||
if chips.count > cap {
|
||
let overflow = chips.count - cap
|
||
chips = Array(chips.prefix(cap)) + ["+\(overflow)"]
|
||
}
|
||
return chips
|
||
}
|
||
|
||
@MainActor
|
||
static func from(snapshot: Snapshot) -> RetrievalSummary {
|
||
var chips = groupedChips(snapshot.indicators.map(\.name), cap: 8)
|
||
chips += snapshot.reports.prefix(3).map(\.title)
|
||
chips += snapshot.symptoms.prefix(3).map(\.name)
|
||
if !snapshot.diaries.isEmpty {
|
||
chips.append(String(appLoc: "日记 ×\(snapshot.diaries.count)"))
|
||
}
|
||
return RetrievalSummary(
|
||
chips: chips,
|
||
indicatorCount: snapshot.indicators.count,
|
||
reportCount: snapshot.reports.count,
|
||
symptomCount: snapshot.symptoms.count,
|
||
diaryCount: snapshot.diaries.count
|
||
)
|
||
}
|
||
}
|
||
|
||
enum Event {
|
||
case phaseChanged(Phase)
|
||
case retrieved(RetrievalSummary)
|
||
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)
|
||
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||
try Task.checkCancellation()
|
||
|
||
// —— Phase 3: 生成 ——
|
||
continuation.yield(.phaseChanged(.generating))
|
||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||
|
||
var generated = ""
|
||
var lastRate: Double = 0
|
||
|
||
if Self.isEffectivelyEmpty(snapshot) {
|
||
// 没有任何真实记录:跳过 LLM,直接产出确定性「无记录」摘要,
|
||
// 从根上杜绝小模型在空数据上编造病例(用户红线:严格按历史信息)。
|
||
generated = Self.fallbackReport(label: intent.labelCN, userPrompt: prompt)
|
||
continuation.yield(.token(TokenChunk(text: generated, decodeRate: 0)))
|
||
} else {
|
||
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 = ""
|
||
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("模型未输出任何内容")
|
||
}
|
||
|
||
// —— 追加确定性趋势段(不经 LLM,零编造) ——
|
||
let trendBlock = Self.trendSection(snapshot.trends)
|
||
if !trendBlock.isEmpty {
|
||
generated += trendBlock
|
||
continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0)))
|
||
}
|
||
|
||
// —— 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,
|
||
inferredLabelCN: intent.labelCN,
|
||
modelTag: ModelKind.llm.rawValue, // 取实际加载的 LLM tag,而非写死默认值(本地推理凭证 §12#6)
|
||
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() }
|
||
}
|
||
}
|
||
|
||
/// 多轮导出页的单轮问答。只回答,不入库。
|
||
/// 事件流:先 .retrieved(检索可视化),后 .token 流式正文;不发 .phaseChanged / .completed。
|
||
func answer(question: String,
|
||
conversation: [HealthExportDialogueTurn],
|
||
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
|
||
}
|
||
|
||
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
|
||
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
|
||
let prompt = HealthExportPrompts.dialogueAnswer(
|
||
latestQuestion: question,
|
||
transcript: transcript,
|
||
dataJSON: dataJSON
|
||
)
|
||
|
||
var displayed = ""
|
||
var rawAccum = ""
|
||
let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 480)
|
||
for try await chunk in stream {
|
||
try Task.checkCancellation()
|
||
rawAccum += chunk.text
|
||
let clean = Self.stripThinkBlocks(rawAccum)
|
||
if clean.count > displayed.count, clean.hasPrefix(displayed) {
|
||
let delta = String(clean.dropFirst(displayed.count))
|
||
displayed = clean
|
||
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
|
||
} else if clean != displayed {
|
||
displayed = clean
|
||
}
|
||
}
|
||
|
||
guard !displayed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
throw ServiceError.generationFailed("模型未输出任何内容")
|
||
}
|
||
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() }
|
||
}
|
||
}
|
||
|
||
/// 多轮导出页的最终报告生成。保存为现有 HealthExport 历史。
|
||
func export(conversation: [HealthExportDialogueTurn],
|
||
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
|
||
}
|
||
|
||
continuation.yield(.phaseChanged(.retrieving))
|
||
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
|
||
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
|
||
try Task.checkCancellation()
|
||
|
||
continuation.yield(.phaseChanged(.generating))
|
||
let genPrompt = HealthExportPrompts.dialogueReportGeneration(
|
||
transcript: transcript,
|
||
dataJSON: dataJSON
|
||
)
|
||
|
||
var generated = ""
|
||
var rawAccum = ""
|
||
var lastRate: Double = 0
|
||
let stream = await AIRuntime.shared.generate(prompt: genPrompt, maxTokens: 1200)
|
||
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 {
|
||
generated = clean
|
||
}
|
||
}
|
||
|
||
guard !generated.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
throw ServiceError.generationFailed("模型未输出任何内容")
|
||
}
|
||
|
||
// —— 追加确定性趋势段(不经 LLM,零编造) ——
|
||
let trendBlock = Self.trendSection(snapshot.trends)
|
||
if !trendBlock.isEmpty {
|
||
generated += trendBlock
|
||
continuation.yield(.token(TokenChunk(text: trendBlock, decodeRate: 0)))
|
||
}
|
||
|
||
let export = HealthExport(
|
||
prompt: transcript,
|
||
content: generated,
|
||
referencedIndicatorIDs: snapshot.indicators.map { Self.idString($0.persistentModelID) },
|
||
referencedReportIDs: [],
|
||
referencedSymptomIDs: [],
|
||
referencedDiaryIDs: snapshot.diaries.map { Self.idString($0.persistentModelID) },
|
||
inferredTimeFromDate: snapshot.fromDate,
|
||
inferredTimeToDate: snapshot.toDate,
|
||
inferredIntent: "dialogue_export",
|
||
inferredLabelCN: "对话整理",
|
||
modelTag: ModelKind.llm.rawValue,
|
||
decodeRate: lastRate
|
||
)
|
||
modelContext.insert(export)
|
||
do { try modelContext.save() } catch {
|
||
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
|
||
/// 药品库(用户「我有哪些药」清单)→ AI 背景 current_meds。空 → 不写该字段。
|
||
var medications: [Medication] = []
|
||
/// 相关指标的趋势行(确定性计算,不进 LLM)。空 → 不渲染「## 指标趋势」段。
|
||
var trends: [ExportTrend] = []
|
||
}
|
||
|
||
/// 同步 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)]
|
||
)
|
||
let allInWindow = (try? ctx.fetch(indDesc)) ?? []
|
||
var indicators = allInWindow
|
||
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 ——
|
||
// 有具体症状词 → 按词过滤(targeted,保留隐私);
|
||
// 无症状词(泛化请求,如「最近身体异常」)→ 纳入时间窗内最近 5 条日记。
|
||
// 之前「无词即清空」会让真实记录完全不进 prompt → 数据为空 → 小模型编造,是本次 bug 主因之一。
|
||
let diaryDesc = FetchDescriptor<DiaryEntry>(
|
||
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
|
||
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
|
||
)
|
||
let allDiaries = (try? ctx.fetch(diaryDesc)) ?? []
|
||
let diaries: [DiaryEntry]
|
||
if intent.symptomKeywords.isEmpty {
|
||
diaries = Array(allDiaries.prefix(5))
|
||
} else {
|
||
diaries = Array(
|
||
allDiaries.filter { d in
|
||
intent.symptomKeywords.contains { kw in
|
||
d.content.localizedCaseInsensitiveContains(kw)
|
||
}
|
||
}.prefix(5)
|
||
)
|
||
}
|
||
|
||
// —— Profile(单例) ——
|
||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||
|
||
// —— 药品库(全量,作为 AI 背景 current_meds) ——
|
||
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
|
||
|
||
// —— 趋势(确定性,不进 LLM) ——
|
||
// 用全量 in-window 还原完整序列;裁剪后的 indicators 决定哪些 series 相关。
|
||
let trends = ExportTrendBuilder.build(
|
||
allInWindow: allInWindow,
|
||
relevant: indicators,
|
||
profile: profile
|
||
)
|
||
|
||
return Snapshot(
|
||
fromDate: fromDate,
|
||
toDate: toDate,
|
||
indicators: indicators,
|
||
symptoms: symptoms,
|
||
reports: reports,
|
||
diaries: diaries,
|
||
profile: profile,
|
||
medications: medications,
|
||
trends: trends
|
||
)
|
||
}
|
||
|
||
/// 多轮导出使用全量指标 + 健康日记作为上下文。为控制 prompt 体积,日记正文在序列化阶段截断。
|
||
static func retrieveDialogueSnapshot(ctx: ModelContext) -> Snapshot {
|
||
let indicatorDesc = FetchDescriptor<Indicator>(
|
||
sortBy: [SortDescriptor(\.capturedAt, order: .reverse)]
|
||
)
|
||
let diaryDesc = FetchDescriptor<DiaryEntry>(
|
||
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
|
||
)
|
||
let indicators = (try? ctx.fetch(indicatorDesc)) ?? []
|
||
let diaries = (try? ctx.fetch(diaryDesc)) ?? []
|
||
let profile = UserProfileStore.loadOrCreate(in: ctx)
|
||
let medications = (try? ctx.fetch(FetchDescriptor<Medication>())) ?? []
|
||
|
||
let dates = indicators.map(\.capturedAt) + diaries.map(\.createdAt)
|
||
let fromDate = dates.min() ?? Date()
|
||
let toDate = dates.max() ?? Date()
|
||
|
||
// 多轮导出用全量指标,全部视为相关。
|
||
let trends = ExportTrendBuilder.build(
|
||
allInWindow: indicators,
|
||
relevant: indicators,
|
||
profile: profile
|
||
)
|
||
|
||
return Snapshot(
|
||
fromDate: fromDate,
|
||
toDate: toDate,
|
||
indicators: indicators,
|
||
symptoms: [],
|
||
reports: [],
|
||
diaries: diaries,
|
||
profile: profile,
|
||
medications: medications,
|
||
trends: trends
|
||
)
|
||
}
|
||
|
||
// 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 }
|
||
// current_meds 改读药品库(Medication);旧 profile.currentMedications 已停用。
|
||
let medNames = snapshot.medications.map { m in
|
||
m.detailLine.isEmpty ? m.name : "\(m.name) \(m.detailLine)"
|
||
}
|
||
if !medNames.isEmpty { profDict["current_meds"] = medNames }
|
||
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: - 空数据兜底(杜绝编造)
|
||
|
||
/// 检索结果是否「实质为空」:无症状/指标/报告/日记,且 profile 也没有任何可写字段。
|
||
/// 为真时跳过 LLM,改用确定性「无记录」摘要,避免小模型凭先验编造病例。
|
||
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
|
||
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty,
|
||
s.diaries.isEmpty, s.medications.isEmpty else {
|
||
return false
|
||
}
|
||
let p = s.profile
|
||
return p.age == nil
|
||
&& p.sex == .undisclosed
|
||
&& p.heightCM == nil
|
||
&& p.weightKG == nil
|
||
&& p.bloodTypeRaw.isEmpty
|
||
&& p.allergies.isEmpty
|
||
&& p.chronicConditions.isEmpty
|
||
&& p.familyHistory.isEmpty
|
||
}
|
||
|
||
/// 无真实记录时的确定性摘要:6 段全「无记录」,主诉仅照搬本人原话,不做任何推断。
|
||
static func fallbackReport(label: String, userPrompt: String) -> String {
|
||
let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)"
|
||
let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let complaintLine = complaint.isEmpty ? "无记录" : complaint
|
||
return """
|
||
\(title)
|
||
|
||
> 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据本人原话,未做任何推断。
|
||
|
||
## 主诉
|
||
\(complaintLine)
|
||
|
||
## 本人背景
|
||
无记录
|
||
|
||
## 近期症状(按时间倒序)
|
||
无记录
|
||
|
||
## 关键指标(异常项优先)
|
||
无记录
|
||
|
||
## 在服药与过敏
|
||
无记录
|
||
|
||
## 本人疑问
|
||
无记录
|
||
"""
|
||
}
|
||
|
||
/// 把趋势行拼成追加到 LLM 输出末尾的「## 指标趋势」段。空 → 返回空串(整段省略)。
|
||
static func trendSection(_ trends: [ExportTrend]) -> String {
|
||
guard !trends.isEmpty else { return "" }
|
||
return "\n\n## 指标趋势\n" + trends.map { $0.line() }.joined(separator: "\n")
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|