当您提供代码差异后,我将按照以下格式生成: ``` <type>(<scope>): <subject> <body> ``` 其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
182 lines
7.3 KiB
Swift
182 lines
7.3 KiB
Swift
import Foundation
|
||
|
||
/// 导出身体档案「## 指标趋势」段的一条趋势摘要。
|
||
///
|
||
/// 设计见 `docs/superpowers/specs/2026-06-07-export-indicator-trends-design.md`:
|
||
/// 对本次就诊相关、且时间窗内有 ≥2 次记录的指标,给一行确定性摘要
|
||
/// (首值→末值 + 方向 + 时间跨度 + 次数),**不经 LLM**,与 `ReportCompareService` 同思路,
|
||
/// 从根上杜绝小模型编造趋势数字(§10#5 失败回退 / §12#6 禁止编造)。
|
||
struct ExportTrend: Sendable {
|
||
|
||
enum Direction: Sendable {
|
||
case up, down, flat
|
||
var arrow: String {
|
||
switch self {
|
||
case .up: return "↑"
|
||
case .down: return "↓"
|
||
case .flat: return "→"
|
||
}
|
||
}
|
||
}
|
||
|
||
let title: String
|
||
let unit: String
|
||
/// "152→138" 或血压双值 "152/96→138/88"。
|
||
let valueText: String
|
||
let direction: Direction
|
||
/// 参考范围文本,如 "90-140";无(单边范围解析不出 / 血压双范围)则 nil。
|
||
let rangeText: String?
|
||
/// 首末两次记录之间的天数。
|
||
let spanDays: Int
|
||
/// 时间窗内记录次数。
|
||
let count: Int
|
||
/// 末值仍异常,或状态跨越了参考范围边界 → 行首加 ⚠️。
|
||
let flagged: Bool
|
||
|
||
/// 一行中文:`⚠️ 收缩压 152→138 mmHg ↓(参考 90-140),近 21 天 4 次`
|
||
func line() -> String {
|
||
var s = flagged ? "⚠️ " : ""
|
||
s += title
|
||
s += " \(valueText)"
|
||
if !unit.isEmpty { s += " \(unit)" }
|
||
s += " \(direction.arrow)"
|
||
if let r = rangeText, !r.isEmpty { s += "(参考 \(r))" }
|
||
s += ",近 \(spanDays) 天 \(count) 次"
|
||
return s
|
||
}
|
||
}
|
||
|
||
enum ExportTrendBuilder {
|
||
|
||
/// 平稳阈值:首末相对变化 < 5% 视为「平稳(→)」。
|
||
static let flatThreshold = 0.05
|
||
|
||
/// 构建趋势摘要列表。
|
||
/// - Parameters:
|
||
/// - allInWindow: 时间窗内**全部**指标(裁剪前)—— 用来还原完整时间序列。
|
||
/// - relevant: 本次就诊**相关**指标集(裁剪后)—— 只对这些 series 出趋势。
|
||
/// - profile: 用于解析性别相关的参考范围(交给 SeriesBucket)。
|
||
/// - customMetrics: 自定义监测项,用于解析自定义 series 的名称/范围。
|
||
/// - Returns: 已按「异常优先,其次最近」排序的趋势行。
|
||
static func build(allInWindow: [Indicator],
|
||
relevant: [Indicator],
|
||
profile: UserProfile? = nil,
|
||
customMetrics: [CustomMonitorMetric] = []) -> [ExportTrend] {
|
||
let relevantIDs = Set(relevant.compactMap { bucketID(for: $0) })
|
||
guard !relevantIDs.isEmpty else { return [] }
|
||
|
||
// 复用 Trends 的分组逻辑:同 seriesKey 分组、血压合并、name+unit 回退、minPoints≥2、点按时间升序。
|
||
let buckets = SeriesBucket.build(from: allInWindow,
|
||
profile: profile,
|
||
customMetrics: customMetrics,
|
||
minPoints: 2)
|
||
|
||
let trends = buckets
|
||
.filter { relevantIDs.contains($0.id) }
|
||
.compactMap { trend(from: $0) }
|
||
|
||
// 异常优先,其次最近。
|
||
return trends.sorted { lhs, rhs in
|
||
if lhs.flagged != rhs.flagged { return lhs.flagged }
|
||
return lhs.spanDays >= rhs.spanDays // 仅作稳定次序,实际新近性已由 buckets 顺序保证
|
||
}
|
||
}
|
||
|
||
/// 指标 → 其所属 SeriesBucket 的 id(与 `SeriesBucket.build` 的 id 方案一致)。
|
||
/// nil 表示该指标无法归入任何 series(空名)。
|
||
static func bucketID(for i: Indicator) -> String? {
|
||
if let k = i.seriesKey, !k.isEmpty {
|
||
if k == "bp.systolic" || k == "bp.diastolic" { return "bp" }
|
||
return k
|
||
}
|
||
let nk = SeriesBucket.normalizedKey(name: i.name, unit: i.unit)
|
||
return nk.isEmpty ? nil : "lab:\(nk)"
|
||
}
|
||
|
||
// MARK: - Private
|
||
|
||
private static func trend(from bucket: SeriesBucket) -> ExportTrend? {
|
||
if bucket.id == "bp" { return bpTrend(from: bucket) }
|
||
|
||
guard let line = bucket.lines.first,
|
||
line.points.count >= 2,
|
||
let first = line.points.first,
|
||
let last = line.points.last else { return nil }
|
||
|
||
return ExportTrend(
|
||
title: bucket.title,
|
||
unit: bucket.unit,
|
||
valueText: "\(num(first.value))→\(num(last.value))",
|
||
direction: direction(first: first.value, last: last.value),
|
||
rangeText: rangeText(line.referenceRange),
|
||
spanDays: spanDays(first.date, last.date),
|
||
count: line.points.count,
|
||
flagged: last.status != .normal
|
||
|| crossedBoundary(first: first.status, last: last.status)
|
||
)
|
||
}
|
||
|
||
/// 血压:收缩 + 舒张合成一行,方向以收缩压为准;不展示参考(收缩/舒张范围不同,保持简洁)。
|
||
private static func bpTrend(from bucket: SeriesBucket) -> ExportTrend? {
|
||
guard let sys = bucket.lines.first(where: { $0.seriesKey == "bp.systolic" }),
|
||
sys.points.count >= 2,
|
||
let sFirst = sys.points.first,
|
||
let sLast = sys.points.last else { return nil }
|
||
|
||
let dia = bucket.lines.first { $0.seriesKey == "bp.diastolic" }
|
||
let dFirst = dia?.points.first
|
||
let dLast = dia?.points.last
|
||
|
||
let valueText: String
|
||
if let dFirst, let dLast {
|
||
valueText = "\(num(sFirst.value))/\(num(dFirst.value))→\(num(sLast.value))/\(num(dLast.value))"
|
||
} else {
|
||
valueText = "\(num(sFirst.value))→\(num(sLast.value))"
|
||
}
|
||
|
||
let sysFlag = sLast.status != .normal
|
||
|| crossedBoundary(first: sFirst.status, last: sLast.status)
|
||
let diaFlag = dLast.map { $0.status != .normal } ?? false
|
||
|
||
return ExportTrend(
|
||
title: bucket.title,
|
||
unit: bucket.unit,
|
||
valueText: valueText,
|
||
direction: direction(first: sFirst.value, last: sLast.value),
|
||
rangeText: nil,
|
||
spanDays: spanDays(sFirst.date, sLast.date),
|
||
count: sys.points.count,
|
||
flagged: sysFlag || diaFlag
|
||
)
|
||
}
|
||
|
||
static func direction(first: Double, last: Double) -> ExportTrend.Direction {
|
||
let delta = last - first
|
||
let base = abs(first)
|
||
let rel = base > 0 ? abs(delta) / base : abs(delta)
|
||
if rel < flatThreshold { return .flat }
|
||
return delta > 0 ? .up : .down
|
||
}
|
||
|
||
/// 状态是否跨越了参考范围边界(正常↔异常之间发生切换)。
|
||
static func crossedBoundary(first: IndicatorStatus, last: IndicatorStatus) -> Bool {
|
||
(first == .normal) != (last == .normal)
|
||
}
|
||
|
||
static func spanDays(_ from: Date, _ to: Date) -> Int {
|
||
let days = to.timeIntervalSince(from) / 86400
|
||
return max(1, Int(days.rounded()))
|
||
}
|
||
|
||
static func rangeText(_ r: ClosedRange<Double>?) -> String? {
|
||
guard let r else { return nil }
|
||
return "\(num(r.lowerBound))-\(num(r.upperBound))"
|
||
}
|
||
|
||
/// 数值格式化:整数去小数点,其余去掉尾随 0(138.0→"138",6.10→"6.1")。
|
||
static func num(_ v: Double) -> String {
|
||
if v.truncatingRemainder(dividingBy: 1) == 0 { return String(Int(v)) }
|
||
return String(format: "%g", v)
|
||
}
|
||
}
|