Files
kangkang/康康/Services/HealthExportService.swift
link2026 40155de709 ```
feat(AI): 优化AIRuntime任务取消机制并增强安全保护

- 在AI推理流中添加Task.checkCancellation()检查,使消费者取消时能快速退出
- 为异步流添加onTermination回调以取消内部Task,与LLMSession一致
- 实现SwiftData store的completeUnlessOpen文件保护,提升数据安全性
- 在store备份过程中同样应用加密保护

feat(home): 优化主页交互体验并统一详情查看功能

- 在主页"最近记录"中点击任意条目可打开只读详情sheet
- 将时间线详情解析逻辑统一收敛到TimelineDetail.resolve方法
- 修复血压条目的精确反查逻辑,避免时间窗匹配错误

feat(archive): 新增提醒任务汇总卡并完善档案库功能

- 在档案库页面新增提醒任务汇总卡,显示总数和启用状态
- 添加按更新时间倒序合并的提醒标题预览功能
- 实现RemindersListView导航路由,统一管理提醒任务
- 优化导出列表显示,优先使用中文标签展示

feat(me): 优化个人中心界面并改进语言设置体验

- 将个人中心标题改为内容文字渲染,解决导航栏背景问题
- 为语言选择器添加个性化图标,使用本族语代表字区分
- 修复语言设置视图的图标显示逻辑

feat(timeline): 新增记录详情页删除功能并优化图表显示

- 在时间线详情页添加永久删除按钮和确认弹窗
- 实现完整的删除逻辑,包括SwiftData硬删和Vault原图unlink
- 修复系列图表的数值范围计算,处理同值数据的对称留白
- 优化血压图表合并逻辑,只保留有数据点的线条

refactor(calendar): 修复DST切换导致的月份天数计算错误

- 使用calendar.range(of:.day,in:.month)替代日期间隔计算
- 避免在夏令时切换月份出现天数偏差问题

fix(ui): 修复多个UI组件的交互响应区域问题

- 为纯描边按钮和胶囊添加contentShape以扩大点击区域
- 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐
```
2026-05-31 09:25:49 +08:00

524 lines
22 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: "已完成")
}
}
}
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)
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("模型未输出任何内容")
}
// 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,
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
// (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)
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: - ()
/// :///, profile
/// LLM,,
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, s.diaries.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
&& p.currentMedications.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)
## 患者背景
无记录
## 近期症状(按时间倒序)
无记录
## 关键指标(异常项优先)
无记录
## 在服药与过敏
无记录
## 患者疑问
无记录
"""
}
// 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
}
}