docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档

补充了关于导出摘要出现虚构病例问题的详细分析和修复方案,
包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。
```
This commit is contained in:
link2026
2026-05-30 20:06:12 +08:00
parent dad9d43486
commit 7ad41c5f09
26 changed files with 9062 additions and 7697 deletions

View File

@@ -85,40 +85,49 @@ struct HealthExportService {
// 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
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
}
}
}
@@ -292,18 +301,21 @@ struct HealthExportService {
)
let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))
// Diary(: symptom_keyword , prompt)
// 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 = []
diaries = Array(allDiaries.prefix(5))
} 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
allDiaries.filter { d in
intent.symptomKeywords.contains { kw in
d.content.localizedCaseInsensitiveContains(kw)
}
@@ -416,6 +428,56 @@ struct HealthExportService {
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

View File

@@ -53,14 +53,16 @@ enum ReminderService {
static func sync(_ reminder: MetricReminder) async {
cancel(metricId: reminder.metricId)
guard reminder.enabled else { return }
let slots = reminder.weekdays.map { wd in
Slot(suffix: "w\(wd)",
dc: DateComponents(hour: reminder.hour, minute: reminder.minute, weekday: wd))
}
await schedule(
idBase: "\(idPrefix)\(reminder.metricId)",
title: String(appLoc: "该测\(reminder.displayName)"),
body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"),
hour: reminder.hour,
minute: reminder.minute,
weekdays: reminder.weekdays,
thread: "kangkang.reminder.\(reminder.metricId)"
thread: "kangkang.reminder.\(reminder.metricId)",
slots: slots
)
}
@@ -77,14 +79,28 @@ enum ReminderService {
guard reminder.enabled else { return }
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
let h = reminder.hour, m = reminder.minute
let slots: [Slot]
switch reminder.frequency {
case .daily:
slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))]
case .weekly:
slots = reminder.weekdays.map { wd in
Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd))
}
case .monthly:
slots = [Slot(suffix: "monthly",
dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))]
case .yearly:
slots = [Slot(suffix: "yearly",
dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))]
}
await schedule(
idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
title: title.isEmpty ? String(appLoc: "提醒") : title,
body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body,
hour: reminder.hour,
minute: reminder.minute,
weekdays: reminder.weekdays,
thread: "\(customIdPrefix)\(reminder.id.uuidString)"
thread: "\(customIdPrefix)\(reminder.id.uuidString)",
slots: slots
)
}
@@ -100,16 +116,20 @@ enum ReminderService {
// MARK: -
/// weekdays N weekly-repeats
/// `idBase` `.w<weekday>` ;
/// :`suffix` id(`<idBase>.<suffix>`,
/// `.daily` / `.w2` / `.monthly` / `.yearly`),`dc`
private struct Slot {
let suffix: String
let dc: DateComponents
}
/// `Slot` N repeats ///
private static func schedule(idBase: String,
title: String,
body: String,
hour: Int,
minute: Int,
weekdays: [Int],
thread: String) async {
guard !weekdays.isEmpty else { return }
thread: String,
slots: [Slot]) async {
guard !slots.isEmpty else { return }
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = title
@@ -117,23 +137,20 @@ enum ReminderService {
content.sound = .default
content.threadIdentifier = thread
for weekday in weekdays {
var comps = DateComponents()
comps.hour = hour
comps.minute = minute
comps.weekday = weekday
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
let request = UNNotificationRequest(identifier: "\(idBase).w\(weekday)",
for slot in slots {
let trigger = UNCalendarNotificationTrigger(dateMatching: slot.dc, repeats: true)
let request = UNNotificationRequest(identifier: "\(idBase).\(slot.suffix)",
content: content,
trigger: trigger)
try? await center.add(request)
}
}
/// idBase 7 weekday pending ()
/// idBase pending (daily/monthly/yearly + 7 weekday,)
private static func cancelBase(_ idBase: String) {
let center = UNUserNotificationCenter.current()
let ids = (1...7).map { "\(idBase).w\($0)" }
var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"]
ids += (1...7).map { "\(idBase).w\($0)" }
center.removePendingNotificationRequests(withIdentifiers: ids)
}
}