docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档 补充了关于导出摘要出现虚构病例问题的详细分析和修复方案, 包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。 ```
236 lines
9.2 KiB
Swift
236 lines
9.2 KiB
Swift
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 } }
|
|
)
|
|
}
|
|
}
|