docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档

补充了关于导出摘要出现虚构病例问题的详细分析和修复方案,
包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。
```
This commit is contained in:
link2026
2026-05-30 20:06:12 +08:00
parent dad9d43486
commit 7ad41c5f09
26 changed files with 9062 additions and 7697 deletions

View File

@@ -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

View 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 } }
)
}
}