feat(Capture): 归档后后台预生成大白话摘要,详情页秒开 + 兜底重试
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
44
康康/AI/Prompts/InsightPrompts.swift
Normal file
44
康康/AI/Prompts/InsightPrompts.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// 本地解读类 prompt:报告大白话摘要 + 趋势一句话解读。
|
||||
/// 红线:不诊断、不荐药;称呼「你」,不出现「患者」(产品定位:自我健康记录)。
|
||||
nonisolated enum InsightPrompts {
|
||||
|
||||
/// 报告整体大白话摘要(归档后台预生成,写回 Report.summary)。
|
||||
static func reportPlainSummary(title: String, typeLabel: String, indicatorLines: String) -> String {
|
||||
"""
|
||||
你是健康档案助手。下面是一份报告的指标列表,请用大白话给本人(称「你」)写 2~3 句整体解读:
|
||||
- 第 1 句:总体情况(共几项、几项异常)。
|
||||
- 之后:点名最值得留意的异常项,用生活化语言说明偏高/偏低意味着什么方向。
|
||||
- 不诊断疾病、不推荐药物或剂量;异常较多时建议「带上报告咨询医生」。
|
||||
- 只输出正文文字,不要标题、列表、JSON、markdown。
|
||||
|
||||
示例:
|
||||
输入:血常规(化验单),指标:白细胞 5.2 (3.5-9.5) normal;血红蛋白 118 (130-175) low;血小板 210 (125-350) normal
|
||||
输出:这份血常规共 3 项,2 项正常,血红蛋白略低于参考范围。血红蛋白偏低通常与贫血方向有关,平时可以多补充含铁食物;如果还伴随乏力头晕,建议带上报告咨询医生。
|
||||
|
||||
现在的报告:\(title)(\(typeLabel))
|
||||
指标:
|
||||
\(indicatorLines)
|
||||
只输出 2~3 句正文。/no_think
|
||||
"""
|
||||
}
|
||||
|
||||
/// 趋势一句话解读(TrendDetailView,按数据指纹缓存)。
|
||||
static func trendInsight(title: String, unit: String, rangeText: String, dataLines: String) -> String {
|
||||
"""
|
||||
你是健康档案助手。下面是「\(title)」的历史记录(单位 \(unit)\(rangeText)),请用大白话给本人(称「你」)写 1~2 句趋势解读:
|
||||
- 说清整体走向(上升/下降/平稳/波动)和当前值与参考范围的关系。
|
||||
- 不诊断疾病、不推荐药物;持续异常时温和建议「复查或咨询医生」。
|
||||
- 只输出正文文字,不要标题、列表、JSON。
|
||||
|
||||
示例:
|
||||
输入:体重,单位 kg,记录:2026-04-01 72.5 / 2026-04-15 71.8 / 2026-05-01 71.2
|
||||
输出:近一个月你的体重稳步下降了约 1.3kg,节奏平缓,继续保持现在的习惯就好。
|
||||
|
||||
现在的记录:
|
||||
\(dataLines)
|
||||
只输出 1~2 句正文。/no_think
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,9 @@ struct UnifiedCaptureFlow: View {
|
||||
}
|
||||
|
||||
try? ctx.save()
|
||||
// 后台预生成大白话摘要:用户继续操作,详情页打开时秒开。
|
||||
// 低优先级 —— 任何前台 AI 任务(再次拍照/问答)都会让它在下一个 token 让位。
|
||||
Task { await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx) }
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,14 +257,7 @@ struct TimelineEntryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let sum = r.summary, !sum.isEmpty {
|
||||
card {
|
||||
Text(String(appLoc: "摘要"))
|
||||
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
ReportSummaryCard(report: r)
|
||||
|
||||
if !r.indicators.isEmpty {
|
||||
card {
|
||||
@@ -559,3 +552,52 @@ private struct EvidenceHighlightOverlay: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 报告摘要卡(无摘要时后台预生成兜底)
|
||||
|
||||
/// 有摘要直接显示;无摘要且有指标时触发后台预生成(归档时若被抢占,这里兜底),
|
||||
/// 生成期间显示流光线,完成后 SwiftData 观察自动刷新出文本。
|
||||
private struct ReportSummaryCard: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
let report: Report
|
||||
@State private var generating = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let sum = report.summary, !sum.isEmpty {
|
||||
container {
|
||||
Text(String(appLoc: "摘要"))
|
||||
.font(.tjScaled( 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
|
||||
Text(sum).font(.tjScaled( 14)).foregroundStyle(Tj.Palette.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else if generating {
|
||||
container {
|
||||
Text("本地 AI 正在解读这份报告…")
|
||||
.font(.tjScaled( 12)).foregroundStyle(Tj.Palette.text3)
|
||||
AIFlowBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard (report.summary ?? "").isEmpty, !report.indicators.isEmpty else { return }
|
||||
generating = true
|
||||
await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx)
|
||||
generating = false
|
||||
}
|
||||
}
|
||||
|
||||
private func container<C: View>(@ViewBuilder _ body: () -> C) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) { body() }
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
61
康康/Services/ReportInsightService.swift
Normal file
61
康康/Services/ReportInsightService.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// 报告大白话摘要预生成(§3.1:流程经本服务碰 AIRuntime,UI 不直接调)。
|
||||
/// 时机:归档保存后立即后台跑(用户继续操作时完成);详情页打开时兜底重试。
|
||||
/// 写回策略:只在 summary 为空时生成 —— 绝不覆盖 VL 已给出或用户编辑过的摘要。
|
||||
@MainActor
|
||||
final class ReportInsightService {
|
||||
static let shared = ReportInsightService()
|
||||
private init() {}
|
||||
|
||||
/// 进行中的报告 ID,防止「保存后台任务」与「详情页兜底」重复触发。
|
||||
private var inFlight: Set<String> = []
|
||||
|
||||
func pregenerateIfNeeded(report: Report, in ctx: ModelContext) async {
|
||||
guard (report.summary ?? "").isEmpty, !report.indicators.isEmpty else { return }
|
||||
let key = String(describing: report.persistentModelID)
|
||||
guard !inFlight.contains(key) else { return }
|
||||
inFlight.insert(key)
|
||||
defer { inFlight.remove(key) }
|
||||
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
} catch {
|
||||
return // 模型未就绪:静默放弃,详情页下次打开再试
|
||||
}
|
||||
|
||||
let prompt = InsightPrompts.reportPlainSummary(
|
||||
title: report.title,
|
||||
typeLabel: report.type.label,
|
||||
indicatorLines: Self.indicatorLines(for: report.indicators)
|
||||
)
|
||||
var collected = ""
|
||||
do {
|
||||
let stream = await AIRuntime.shared.generate(
|
||||
prompt: prompt, maxTokens: 200, priority: .background)
|
||||
for try await chunk in stream { collected += chunk.text }
|
||||
} catch {
|
||||
return // 被前台任务抢占(CancellationError)或推理失败:放弃,兜底路径再试
|
||||
}
|
||||
let text = HealthExportService.stripThinkBlocks(collected)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, (report.summary ?? "").isEmpty else { return }
|
||||
report.summary = text
|
||||
try? ctx.save()
|
||||
}
|
||||
|
||||
/// 「名 值 单位(参考 range)status」每指标一行;异常项排前,上限 15 行控 prompt 体积。
|
||||
static func indicatorLines(for indicators: [Indicator]) -> String {
|
||||
let sorted = indicators.sorted {
|
||||
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
|
||||
}
|
||||
return sorted.prefix(15).map { i in
|
||||
var line = "\(i.name) \(i.value)"
|
||||
if !i.unit.isEmpty { line += " \(i.unit)" }
|
||||
if !i.range.isEmpty { line += "(参考 \(i.range))" }
|
||||
line += " \(i.status.rawValue)"
|
||||
return line
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user