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