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