From 43cdde9bab5bc8c828429391aab51b92de1bdb3b Mon Sep 17 00:00:00 2001 From: link2026 Date: Wed, 10 Jun 2026 07:12:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(Capture):=20=E5=BD=92=E6=A1=A3=E5=90=8E?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E9=A2=84=E7=94=9F=E6=88=90=E5=A4=A7=E7=99=BD?= =?UTF-8?q?=E8=AF=9D=E6=91=98=E8=A6=81,=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E7=A7=92=E5=BC=80=20+=20=E5=85=9C=E5=BA=95=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- 康康/AI/Prompts/InsightPrompts.swift | 44 +++++++++++++ .../Features/Capture/UnifiedCaptureFlow.swift | 3 + .../Timeline/TimelineEntryDetailView.swift | 58 +++++++++++++++--- 康康/Services/ReportInsightService.swift | 61 +++++++++++++++++++ 康康Tests/InsightPromptsTests.swift | 26 ++++++++ 5 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 康康/AI/Prompts/InsightPrompts.swift create mode 100644 康康/Services/ReportInsightService.swift create mode 100644 康康Tests/InsightPromptsTests.swift diff --git a/康康/AI/Prompts/InsightPrompts.swift b/康康/AI/Prompts/InsightPrompts.swift new file mode 100644 index 0000000..fc7700a --- /dev/null +++ b/康康/AI/Prompts/InsightPrompts.swift @@ -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 + """ + } +} diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift index 05a406e..05bdcd3 100644 --- a/康康/Features/Capture/UnifiedCaptureFlow.swift +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -311,6 +311,9 @@ struct UnifiedCaptureFlow: View { } try? ctx.save() + // 后台预生成大白话摘要:用户继续操作,详情页打开时秒开。 + // 低优先级 —— 任何前台 AI 任务(再次拍照/问答)都会让它在下一个 token 让位。 + Task { await ReportInsightService.shared.pregenerateIfNeeded(report: report, in: ctx) } onClose() } } diff --git a/康康/Features/Timeline/TimelineEntryDetailView.swift b/康康/Features/Timeline/TimelineEntryDetailView.swift index 1fb29cc..3a75a83 100644 --- a/康康/Features/Timeline/TimelineEntryDetailView.swift +++ b/康康/Features/Timeline/TimelineEntryDetailView.swift @@ -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(@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) + ) + } +} diff --git a/康康/Services/ReportInsightService.swift b/康康/Services/ReportInsightService.swift new file mode 100644 index 0000000..0a13571 --- /dev/null +++ b/康康/Services/ReportInsightService.swift @@ -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 = [] + + 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") + } +} diff --git a/康康Tests/InsightPromptsTests.swift b/康康Tests/InsightPromptsTests.swift new file mode 100644 index 0000000..3f881dd --- /dev/null +++ b/康康Tests/InsightPromptsTests.swift @@ -0,0 +1,26 @@ +import Testing +@testable import 康康 + +struct InsightPromptsTests { + + @Test func reportSummaryPromptCarriesDataAndGuards() { + let p = InsightPrompts.reportPlainSummary( + title: "春季体检", typeLabel: "体检报告", + indicatorLines: "血红蛋白 118 g/L(参考 130-175)low") + #expect(p.contains("春季体检")) + #expect(p.contains("血红蛋白 118")) + #expect(p.contains("/no_think")) + #expect(p.contains("不诊断")) + #expect(!p.contains("患者")) + } + + @Test func trendPromptCarriesDataAndGuards() { + let p = InsightPrompts.trendInsight( + title: "空腹血糖", unit: "mmol/L", rangeText: ",参考 3.9-6.1", + dataLines: "2026-05-01 5.2 / 2026-06-01 5.8") + #expect(p.contains("空腹血糖")) + #expect(p.contains("2026-06-01 5.8")) + #expect(p.contains("/no_think")) + #expect(!p.contains("患者")) + } +}