```
docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档 补充了关于导出摘要出现虚构病例问题的详细分析和修复方案, 包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。 ```
This commit is contained in:
@@ -26,6 +26,12 @@ struct DiaryQuickSheet: View {
|
||||
/// 累积已覆盖的问诊维度(question.dim),回传下一轮 prompt 用于按维度去重。
|
||||
@State private var coveredDims: Set<String> = []
|
||||
@State private var suggestTask: Task<Void, Never>?
|
||||
/// 当前正在「就地填空」的 question id;nil = 没有展开的填空面板。
|
||||
@State private var fillingId: UUID?
|
||||
/// 当前填空面板各占位槽的输入值,长度 = 该模板占位数。
|
||||
@State private var fillValues: [String] = []
|
||||
/// 上一轮「再问一轮」没问出任何新维度(全被去重)时为 true,提示用户已覆盖主要维度。
|
||||
@State private var exhaustedNote = false
|
||||
/// sheet detent。默认 large,确保建议面板有足够展示空间。
|
||||
/// 仍保留 medium,用户可手动下拉收回为半屏(纯写文本时更轻量)。
|
||||
@State private var detent: PresentationDetent = .large
|
||||
@@ -76,6 +82,7 @@ struct DiaryQuickSheet: View {
|
||||
text: $content, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
.focused($contentFocused)
|
||||
.onChange(of: content) { _, _ in exhaustedNote = false }
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
@@ -177,6 +184,19 @@ struct DiaryQuickSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
if exhaustedNote {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.leaf)
|
||||
Text("已覆盖主要问诊维度;补充原文后可再追问")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
// 底部主操作按钮(状态机驱动)
|
||||
phaseFooter
|
||||
}
|
||||
@@ -318,6 +338,7 @@ struct DiaryQuickSheet: View {
|
||||
|
||||
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
|
||||
let adopted = question.adopted
|
||||
let filling = fillingId == question.id
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("\(index).")
|
||||
@@ -341,7 +362,7 @@ struct DiaryQuickSheet: View {
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().fill(Tj.Palette.leafSoft))
|
||||
} else {
|
||||
} else if !filling {
|
||||
Button { adopt(question) } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
@@ -357,7 +378,14 @@ struct DiaryQuickSheet: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
if !question.fill.isEmpty && !adopted {
|
||||
if filling {
|
||||
QuestionFillPanel(
|
||||
template: question.fill,
|
||||
values: $fillValues,
|
||||
onCommit: { assembled in commitAdoption(question, text: assembled) },
|
||||
onCancel: { closeFill() }
|
||||
)
|
||||
} else if !question.fill.isEmpty && !adopted {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Text("将追加:")
|
||||
.font(.system(size: 11))
|
||||
@@ -405,6 +433,7 @@ struct DiaryQuickSheet: View {
|
||||
detent = .large
|
||||
}
|
||||
}
|
||||
exhaustedNote = false
|
||||
phase = .loading
|
||||
suggestTask = Task { @MainActor in
|
||||
do {
|
||||
@@ -413,21 +442,34 @@ struct DiaryQuickSheet: View {
|
||||
coveredDimensions: covered
|
||||
)
|
||||
if Task.isCancelled { return }
|
||||
// 客户端字面兜底(防 LLM 不听话);跨轮去重主要靠 prompt 的维度排除。
|
||||
let existing = Set(questions.map { Self.normalize($0.q) })
|
||||
// 客户端硬去重(不依赖 1.7B 听话):
|
||||
// ① 维度已在往轮覆盖 → 丢;② 本轮内维度重复 → 丢;③ 文本与已有近似 → 丢。
|
||||
let coveredSnapshot = coveredDims
|
||||
var acceptedNorms = questions.map { Self.normalize($0.q) }
|
||||
var batchDims = Set<String>()
|
||||
let nextRound = currentRound + 1
|
||||
let fresh = result.questions
|
||||
.filter { !existing.contains(Self.normalize($0.q)) }
|
||||
.map { q -> DiaryAssistService.Question in
|
||||
var stamped = q
|
||||
stamped.round = nextRound
|
||||
return stamped
|
||||
}
|
||||
let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
|
||||
let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let norm = Self.normalize(q.q)
|
||||
if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
|
||||
if !dim.isEmpty, batchDims.contains(dim) { return nil }
|
||||
if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
|
||||
if !dim.isEmpty { batchDims.insert(dim) }
|
||||
acceptedNorms.append(norm)
|
||||
var stamped = q
|
||||
stamped.round = nextRound
|
||||
return stamped
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
questions.append(contentsOf: fresh)
|
||||
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
|
||||
if fresh.isEmpty {
|
||||
exhaustedNote = true // 这轮没问出任何新维度
|
||||
} else {
|
||||
questions.append(contentsOf: fresh)
|
||||
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
|
||||
currentRound = nextRound
|
||||
exhaustedNote = false
|
||||
}
|
||||
lastRate = result.decodeRate
|
||||
currentRound = nextRound
|
||||
phase = .ready
|
||||
}
|
||||
} catch is CancellationError {
|
||||
@@ -449,20 +491,59 @@ struct DiaryQuickSheet: View {
|
||||
.replacingOccurrences(of: "?", with: "?")
|
||||
}
|
||||
|
||||
/// 近似判重:归一化后相等,或字符集 Jaccard ≥ 0.8(抓「会/下」这类换一两字的重复)。
|
||||
private static func isSimilar(_ a: String, _ b: String) -> Bool {
|
||||
if a == b { return true }
|
||||
let sa = Set(a), sb = Set(b)
|
||||
guard !sa.isEmpty, !sb.isEmpty else { return false }
|
||||
let inter = sa.intersection(sb).count
|
||||
let union = sa.union(sb).count
|
||||
return union > 0 && Double(inter) / Double(union) >= 0.8
|
||||
}
|
||||
|
||||
private func cancelSuggestions() {
|
||||
suggestTask?.cancel()
|
||||
phase = hasQuestions ? .ready : .idle
|
||||
}
|
||||
|
||||
/// 把 question.fill 追加到 textfield 末尾,并把该 question 标记为 adopted。
|
||||
/// 采纳:模板含 `[占位]` 时展开就地填空面板;无占位则直接把整句追加(并标记 adopted)。
|
||||
/// 已采纳的 q 不会从列表里消失;其维度已在生成时计入 coveredDims,下一轮 prompt 会避开。
|
||||
private func adopt(_ question: DiaryAssistService.Question) {
|
||||
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
|
||||
// 无占位:直接采纳整句(空 fill 时退回到追加问题本身)。
|
||||
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
|
||||
return
|
||||
}
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
fillingId = question.id
|
||||
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
|
||||
}
|
||||
}
|
||||
|
||||
/// 关闭填空面板(取消)。
|
||||
private func closeFill() {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
fillingId = nil
|
||||
fillValues = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交采纳:把(填好的)整句追加到正文,标记 adopted,收起面板。
|
||||
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
|
||||
if let idx = questions.firstIndex(where: { $0.id == question.id }) {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
questions[idx].adopted = true
|
||||
}
|
||||
}
|
||||
let toAppend = question.fill.isEmpty ? question.q : question.fill
|
||||
appendToContent(text)
|
||||
fillingId = nil
|
||||
fillValues = []
|
||||
}
|
||||
|
||||
/// 把一段补充文本追加到正文末尾(自动补换行,空文本忽略)。
|
||||
private func appendToContent(_ text: String) {
|
||||
let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !toAppend.isEmpty else { return }
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
content = toAppend
|
||||
|
||||
235
康康/Features/Diary/QuestionFillPanel.swift
Normal file
235
康康/Features/Diary/QuestionFillPanel.swift
Normal file
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
|
||||
/// AI 补充句模板(如「症状从 [时间] 开始,」)的一个片段:字面文本或待填占位槽。
|
||||
enum FillSegment: Equatable {
|
||||
case literal(String)
|
||||
/// `label` 为方括号内原文(如 "时间" / "活动/休息");
|
||||
/// `options` 为可一键填充的短词候选(`/` 分隔且都短时才有,否则空)。
|
||||
case slot(label: String, options: [String])
|
||||
}
|
||||
|
||||
/// 把 `fill` 模板解析成有序片段、组装回填好的句子。纯值逻辑,便于复用与单测。
|
||||
enum DiaryFillTemplate {
|
||||
|
||||
/// 解析模板为有序片段。无方括号时返回单个 `.literal`。
|
||||
static func parse(_ template: String) -> [FillSegment] {
|
||||
let chars = Array(template)
|
||||
var segs: [FillSegment] = []
|
||||
var i = 0
|
||||
var literalStart = 0
|
||||
func flushLiteral(upTo end: Int) {
|
||||
if end > literalStart { segs.append(.literal(String(chars[literalStart..<end]))) }
|
||||
}
|
||||
while i < chars.count {
|
||||
if chars[i] == "[",
|
||||
let close = (i + 1 ..< chars.count).first(where: { chars[$0] == "]" }) {
|
||||
flushLiteral(upTo: i)
|
||||
let inner = String(chars[(i + 1)..<close])
|
||||
segs.append(.slot(label: inner, options: options(from: inner)))
|
||||
i = close + 1
|
||||
literalStart = i
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
flushLiteral(upTo: chars.count)
|
||||
return segs
|
||||
}
|
||||
|
||||
/// 占位内 `/` 分隔、每段都短(≤5 字)、且 ≥2 段时,视为可点选的快填候选。
|
||||
private static func options(from inner: String) -> [String] {
|
||||
let tokens = inner.split(separator: "/")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard tokens.count >= 2, tokens.allSatisfy({ $0.count <= 5 }) else { return [] }
|
||||
return tokens
|
||||
}
|
||||
|
||||
/// 模板里的占位槽数量。
|
||||
static func slotCount(_ template: String) -> Int {
|
||||
parse(template).reduce(0) { acc, seg in
|
||||
if case .slot = seg { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 `values` 填充各槽组装成句:已填用输入值,留空回退为方括号内原文(去方括号,读起来仍自然)。
|
||||
static func assemble(_ template: String, values: [String]) -> String {
|
||||
var out = ""
|
||||
var idx = 0
|
||||
for seg in parse(template) {
|
||||
switch seg {
|
||||
case .literal(let t):
|
||||
out += t
|
||||
case .slot(let label, _):
|
||||
let v = idx < values.count
|
||||
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
out += v.isEmpty ? label : v
|
||||
idx += 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/// 「采纳即就地填空」面板:每个 `[占位]` 一个输入框 + 快填 chip,顶部实时预览整句,
|
||||
/// 底部「加入记录 / 取消」。确认时回传**填好的、无方括号**的整句。
|
||||
struct QuestionFillPanel: View {
|
||||
let template: String
|
||||
@Binding var values: [String]
|
||||
let onCommit: (String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
private var segments: [FillSegment] { DiaryFillTemplate.parse(template) }
|
||||
|
||||
/// 抽出占位槽 + 其在 values 里的下标。
|
||||
private var slots: [(index: Int, label: String, options: [String])] {
|
||||
var result: [(Int, String, [String])] = []
|
||||
var i = 0
|
||||
for seg in segments {
|
||||
if case let .slot(label, options) = seg {
|
||||
result.append((i, label, options))
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
// 实时预览:已填值高亮,未填槽浅色下划线提示。
|
||||
previewText
|
||||
.font(.system(size: 13))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
)
|
||||
|
||||
ForEach(slots, id: \.index) { slot in
|
||||
slotEditor(index: slot.index, label: slot.label, options: slot.options)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: onCancel) {
|
||||
Text("取消")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
// 背景仅描边、内部透明:.plain 按钮的命中区会只剩文字本身,
|
||||
// 中间透明区点不到。补 contentShape 让整框可点。
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
onCommit(DiaryFillTemplate.assemble(template, values: values))
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "text.append")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text("加入记录")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.ink)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 22)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
// MARK: - 子部件
|
||||
|
||||
/// 预览整句:literal 用正文色,已填值用 brick 加粗,未填槽用浅色下划线。
|
||||
private var previewText: Text {
|
||||
var result = Text("")
|
||||
var idx = 0
|
||||
for seg in segments {
|
||||
switch seg {
|
||||
case .literal(let t):
|
||||
result = result + Text(t).foregroundStyle(Tj.Palette.text)
|
||||
case .slot(let label, _):
|
||||
let v = idx < values.count
|
||||
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
|
||||
if v.isEmpty {
|
||||
result = result + Text(label).foregroundStyle(Tj.Palette.text3).underline()
|
||||
} else {
|
||||
result = result + Text(v).foregroundStyle(Tj.Palette.brick).fontWeight(.semibold)
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
|
||||
if !options.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(options, id: \.self) { opt in
|
||||
let picked = bindingValue(index) == opt
|
||||
Button { values[index] = opt } label: {
|
||||
Text(opt)
|
||||
.font(.system(size: 12, weight: picked ? .semibold : .regular))
|
||||
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
Capsule().fill(picked ? Tj.Palette.ink : Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
Capsule().strokeBorder(Tj.Palette.line,
|
||||
lineWidth: picked ? 0 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
TextField(String(appLoc: "填写\(label)"), text: binding(index))
|
||||
.font(.system(size: 13))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func bindingValue(_ i: Int) -> String {
|
||||
i < values.count ? values[i] : ""
|
||||
}
|
||||
|
||||
private func binding(_ i: Int) -> Binding<String> {
|
||||
Binding(
|
||||
get: { i < values.count ? values[i] : "" },
|
||||
set: { if i < values.count { values[i] = $0 } }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user