diff --git a/康康/Features/Archive/HealthExportSheet.swift b/康康/Features/Archive/HealthExportSheet.swift index 92a9172..4c403f4 100644 --- a/康康/Features/Archive/HealthExportSheet.swift +++ b/康康/Features/Archive/HealthExportSheet.swift @@ -20,6 +20,8 @@ struct HealthExportSheet: View { @State private var completed: Bool = false @State private var copiedFlash: Bool = false @State private var answeringTurnID: UUID? + @State private var retrieval: HealthExportService.RetrievalSummary? + @State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:] @FocusState private var questionFocused: Bool // 快捷问答 @@ -234,9 +236,14 @@ struct HealthExportSheet: View { Text(turn.role.transcriptLabel) .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3) + if !isUser, let summary = turnRetrievals[turn.id] { + RetrievalChipsView(summary: summary) + } if turn.id == answeringTurnID && turn.text.isEmpty { VStack(alignment: .leading, spacing: 8) { - Text("正在查看本地记录…") + Text(turnRetrievals[turn.id] == nil + ? "正在查看本地记录…" + : "正在根据这些记录回答…") .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) AIFlowBar() @@ -298,6 +305,9 @@ struct HealthExportSheet: View { arrow phasePill(.generating) } + if let retrieval { + RetrievalChipsView(summary: retrieval) + } if phase == .generating && rate > 0 { Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate)) .font(.tjScaled( 11, design: .monospaced)) @@ -477,9 +487,18 @@ struct HealthExportSheet: View { task?.cancel() task = Task { @MainActor in do { - for try await chunk in stream { - appendToTurn(id: assistantTurn.id, text: chunk.text) - if chunk.decodeRate > 0 { rate = chunk.decodeRate } + for try await event in stream { + switch event { + case .retrieved(let summary): + withAnimation(.snappy(duration: 0.25)) { + turnRetrievals[assistantTurn.id] = summary + } + case .token(let chunk): + appendToTurn(id: assistantTurn.id, text: chunk.text) + if chunk.decodeRate > 0 { rate = chunk.decodeRate } + case .phaseChanged, .completed: + break + } } answeringTurnID = nil questionFocused = true @@ -512,6 +531,7 @@ struct HealthExportSheet: View { rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示 error = nil completed = false + retrieval = nil phase = .retrieving let stream = HealthExportService.shared.export(conversation: turns, in: ctx) @@ -522,6 +542,8 @@ struct HealthExportSheet: View { switch event { case .phaseChanged(let ph): phase = ph + case .retrieved(let summary): + withAnimation(.snappy(duration: 0.25)) { retrieval = summary } case .token(let chunk): content += chunk.text if chunk.decodeRate > 0 { rate = chunk.decodeRate } @@ -549,6 +571,7 @@ struct HealthExportSheet: View { rate = 0 completed = false content = "" + retrieval = nil } private func reset() { @@ -560,6 +583,8 @@ struct HealthExportSheet: View { error = nil completed = false answeringTurnID = nil + retrieval = nil + turnRetrievals = [:] questionFocused = true } @@ -577,6 +602,44 @@ struct HealthExportSheet: View { } } +// MARK: - 检索结果 chips(本地 RAG 可视化) + +/// 生成开始前先把「本地 RAG 找到了什么」演出来:N 条记录 + 记录名 chips。 +/// 结构化检索(不用 embedding)的天然优势 —— 每条命中都可解释、可展示(§12 卖点 3)。 +private struct RetrievalChipsView: View { + let summary: HealthExportService.RetrievalSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if summary.totalCount == 0 { + Text("本地档案中暂无相关记录,将仅按你的描述整理") + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + } else { + Text(String(appLoc: "已在本地档案中找到 \(summary.totalCount) 条相关记录")) + .font(.tjScaled( 11, weight: .medium)) + .foregroundStyle(Tj.Palette.leaf) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(Array(summary.chips.enumerated()), id: \.offset) { _, chip in + Text(chip) + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text2) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Tj.Palette.sand2)) + .overlay(Capsule().strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)) + } + } + .padding(.vertical, 1) + } + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } +} + // MARK: - 简易 Markdown 渲染(行级) /// 极简 Markdown 渲染器,够给医生看的报告就行。 diff --git a/康康/Services/HealthExportService.swift b/康康/Services/HealthExportService.swift index 013e5c2..a0c08b0 100644 --- a/康康/Services/HealthExportService.swift +++ b/康康/Services/HealthExportService.swift @@ -35,8 +35,56 @@ struct HealthExportService { } } + /// 检索结果摘要 —— 把「本地 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 里 @@ -80,6 +128,7 @@ struct HealthExportService { // —— 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: 生成 —— @@ -178,9 +227,10 @@ struct HealthExportService { } /// 多轮导出页的单轮问答。只回答,不入库。 + /// 事件流:先 .retrieved(检索可视化),后 .token 流式正文;不发 .phaseChanged / .completed。 func answer(question: String, conversation: [HealthExportDialogueTurn], - in modelContext: ModelContext) -> AsyncThrowingStream { + in modelContext: ModelContext) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { @MainActor in do { @@ -191,6 +241,7 @@ struct HealthExportService { } 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( @@ -209,7 +260,7 @@ struct HealthExportService { if clean.count > displayed.count, clean.hasPrefix(displayed) { let delta = String(clean.dropFirst(displayed.count)) displayed = clean - continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate)) + continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate))) } else if clean != displayed { displayed = clean } @@ -245,6 +296,7 @@ struct HealthExportService { 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() diff --git a/康康Tests/RetrievalSummaryTests.swift b/康康Tests/RetrievalSummaryTests.swift new file mode 100644 index 0000000..db4b485 --- /dev/null +++ b/康康Tests/RetrievalSummaryTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import 康康 + +struct RetrievalSummaryTests { + + @Test func groupsAndCountsPreservingOrder() { + let chips = HealthExportService.RetrievalSummary.groupedChips( + ["血压", "血糖", "血压", "血压", "体重"], cap: 8) + #expect(chips == ["血压 ×3", "血糖", "体重"]) + } + + @Test func capsAndAppendsOverflow() { + let names = (1...12).map { "指标\($0)" } + let chips = HealthExportService.RetrievalSummary.groupedChips(names, cap: 8) + #expect(chips.count == 9) + #expect(chips.last == "+4") + } + + @Test func emptyInputGivesEmptyChips() { + #expect(HealthExportService.RetrievalSummary.groupedChips([], cap: 8).isEmpty) + } +}