feat(Ask): 检索过程可视化 — RAG 命中记录以 chips 展示,生成前先看见
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,8 @@ struct HealthExportSheet: View {
|
|||||||
@State private var completed: Bool = false
|
@State private var completed: Bool = false
|
||||||
@State private var copiedFlash: Bool = false
|
@State private var copiedFlash: Bool = false
|
||||||
@State private var answeringTurnID: UUID?
|
@State private var answeringTurnID: UUID?
|
||||||
|
@State private var retrieval: HealthExportService.RetrievalSummary?
|
||||||
|
@State private var turnRetrievals: [UUID: HealthExportService.RetrievalSummary] = [:]
|
||||||
@FocusState private var questionFocused: Bool
|
@FocusState private var questionFocused: Bool
|
||||||
|
|
||||||
// 快捷问答
|
// 快捷问答
|
||||||
@@ -234,9 +236,14 @@ struct HealthExportSheet: View {
|
|||||||
Text(turn.role.transcriptLabel)
|
Text(turn.role.transcriptLabel)
|
||||||
.font(.tjScaled( 11, weight: .semibold))
|
.font(.tjScaled( 11, weight: .semibold))
|
||||||
.foregroundStyle(isUser ? Tj.Palette.paper.opacity(0.8) : Tj.Palette.text3)
|
.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 {
|
if turn.id == answeringTurnID && turn.text.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("正在查看本地记录…")
|
Text(turnRetrievals[turn.id] == nil
|
||||||
|
? "正在查看本地记录…"
|
||||||
|
: "正在根据这些记录回答…")
|
||||||
.font(.tjScaled( 13))
|
.font(.tjScaled( 13))
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
AIFlowBar()
|
AIFlowBar()
|
||||||
@@ -298,6 +305,9 @@ struct HealthExportSheet: View {
|
|||||||
arrow
|
arrow
|
||||||
phasePill(.generating)
|
phasePill(.generating)
|
||||||
}
|
}
|
||||||
|
if let retrieval {
|
||||||
|
RetrievalChipsView(summary: retrieval)
|
||||||
|
}
|
||||||
if phase == .generating && rate > 0 {
|
if phase == .generating && rate > 0 {
|
||||||
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
Text(String(format: String(appLoc: "本地推理 · %.1f tok/s"), rate))
|
||||||
.font(.tjScaled( 11, design: .monospaced))
|
.font(.tjScaled( 11, design: .monospaced))
|
||||||
@@ -477,9 +487,18 @@ struct HealthExportSheet: View {
|
|||||||
task?.cancel()
|
task?.cancel()
|
||||||
task = Task { @MainActor in
|
task = Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
for try await chunk in stream {
|
for try await event in stream {
|
||||||
appendToTurn(id: assistantTurn.id, text: chunk.text)
|
switch event {
|
||||||
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
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
|
answeringTurnID = nil
|
||||||
questionFocused = true
|
questionFocused = true
|
||||||
@@ -512,6 +531,7 @@ struct HealthExportSheet: View {
|
|||||||
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
rate = 0 // 重新生成时清零,避免旧 tok/s 残留显示
|
||||||
error = nil
|
error = nil
|
||||||
completed = false
|
completed = false
|
||||||
|
retrieval = nil
|
||||||
phase = .retrieving
|
phase = .retrieving
|
||||||
|
|
||||||
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
|
let stream = HealthExportService.shared.export(conversation: turns, in: ctx)
|
||||||
@@ -522,6 +542,8 @@ struct HealthExportSheet: View {
|
|||||||
switch event {
|
switch event {
|
||||||
case .phaseChanged(let ph):
|
case .phaseChanged(let ph):
|
||||||
phase = ph
|
phase = ph
|
||||||
|
case .retrieved(let summary):
|
||||||
|
withAnimation(.snappy(duration: 0.25)) { retrieval = summary }
|
||||||
case .token(let chunk):
|
case .token(let chunk):
|
||||||
content += chunk.text
|
content += chunk.text
|
||||||
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
if chunk.decodeRate > 0 { rate = chunk.decodeRate }
|
||||||
@@ -549,6 +571,7 @@ struct HealthExportSheet: View {
|
|||||||
rate = 0
|
rate = 0
|
||||||
completed = false
|
completed = false
|
||||||
content = ""
|
content = ""
|
||||||
|
retrieval = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reset() {
|
private func reset() {
|
||||||
@@ -560,6 +583,8 @@ struct HealthExportSheet: View {
|
|||||||
error = nil
|
error = nil
|
||||||
completed = false
|
completed = false
|
||||||
answeringTurnID = nil
|
answeringTurnID = nil
|
||||||
|
retrieval = nil
|
||||||
|
turnRetrievals = [:]
|
||||||
questionFocused = true
|
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 渲染(行级)
|
// MARK: - 简易 Markdown 渲染(行级)
|
||||||
|
|
||||||
/// 极简 Markdown 渲染器,够给医生看的报告就行。
|
/// 极简 Markdown 渲染器,够给医生看的报告就行。
|
||||||
|
|||||||
@@ -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 {
|
enum Event {
|
||||||
case phaseChanged(Phase)
|
case phaseChanged(Phase)
|
||||||
|
case retrieved(RetrievalSummary)
|
||||||
case token(TokenChunk)
|
case token(TokenChunk)
|
||||||
case completed(persistentID: PersistentIdentifier)
|
case completed(persistentID: PersistentIdentifier)
|
||||||
// .failed 走 stream throw,不在 Event 里
|
// .failed 走 stream throw,不在 Event 里
|
||||||
@@ -80,6 +128,7 @@ struct HealthExportService {
|
|||||||
// —— Phase 2: 检索 ——
|
// —— Phase 2: 检索 ——
|
||||||
continuation.yield(.phaseChanged(.retrieving))
|
continuation.yield(.phaseChanged(.retrieving))
|
||||||
let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
|
let snapshot = Self.retrieve(intent: intent, ctx: modelContext)
|
||||||
|
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
// —— Phase 3: 生成 ——
|
// —— Phase 3: 生成 ——
|
||||||
@@ -178,9 +227,10 @@ struct HealthExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 多轮导出页的单轮问答。只回答,不入库。
|
/// 多轮导出页的单轮问答。只回答,不入库。
|
||||||
|
/// 事件流:先 .retrieved(检索可视化),后 .token 流式正文;不发 .phaseChanged / .completed。
|
||||||
func answer(question: String,
|
func answer(question: String,
|
||||||
conversation: [HealthExportDialogueTurn],
|
conversation: [HealthExportDialogueTurn],
|
||||||
in modelContext: ModelContext) -> AsyncThrowingStream<TokenChunk, Error> {
|
in modelContext: ModelContext) -> AsyncThrowingStream<Event, Error> {
|
||||||
AsyncThrowingStream { continuation in
|
AsyncThrowingStream { continuation in
|
||||||
let task = Task { @MainActor in
|
let task = Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
@@ -191,6 +241,7 @@ struct HealthExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
|
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
|
||||||
|
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||||||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||||||
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
|
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
|
||||||
let prompt = HealthExportPrompts.dialogueAnswer(
|
let prompt = HealthExportPrompts.dialogueAnswer(
|
||||||
@@ -209,7 +260,7 @@ struct HealthExportService {
|
|||||||
if clean.count > displayed.count, clean.hasPrefix(displayed) {
|
if clean.count > displayed.count, clean.hasPrefix(displayed) {
|
||||||
let delta = String(clean.dropFirst(displayed.count))
|
let delta = String(clean.dropFirst(displayed.count))
|
||||||
displayed = clean
|
displayed = clean
|
||||||
continuation.yield(TokenChunk(text: delta, decodeRate: chunk.decodeRate))
|
continuation.yield(.token(TokenChunk(text: delta, decodeRate: chunk.decodeRate)))
|
||||||
} else if clean != displayed {
|
} else if clean != displayed {
|
||||||
displayed = clean
|
displayed = clean
|
||||||
}
|
}
|
||||||
@@ -245,6 +296,7 @@ struct HealthExportService {
|
|||||||
|
|
||||||
continuation.yield(.phaseChanged(.retrieving))
|
continuation.yield(.phaseChanged(.retrieving))
|
||||||
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
|
let snapshot = Self.retrieveDialogueSnapshot(ctx: modelContext)
|
||||||
|
continuation.yield(.retrieved(RetrievalSummary.from(snapshot: snapshot)))
|
||||||
let dataJSON = Self.serializeData(snapshot: snapshot)
|
let dataJSON = Self.serializeData(snapshot: snapshot)
|
||||||
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
|
let transcript = HealthExportDialogueTurn.transcript(from: conversation)
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|||||||
22
康康Tests/RetrievalSummaryTests.swift
Normal file
22
康康Tests/RetrievalSummaryTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user