import Foundation /// 趋势 AI 一句话解读:小预算(≤140 token)+ 按数据指纹缓存(UserDefaults)。 /// 数据没变不重算 —— 进趋势详情页秒开;新增/修改记录改变指纹 → 自动重新生成。 @MainActor final class TrendInsightService { static let shared = TrendInsightService() private init() {} struct Cached: Codable, Equatable { var fingerprint: String var text: String var generatedAt: Date } nonisolated static let storePrefix = "kk.trendInsight." /// 数据指纹:每条线的 key + 点数 + 首末时间 + 末值/极值。体量小,直接当指纹字符串。 nonisolated static func fingerprint(for bucket: SeriesBucket) -> String { var parts: [String] = [bucket.id] for line in bucket.lines { let pts = line.points let first = pts.first.map { Int($0.date.timeIntervalSince1970) } ?? 0 let last = pts.last.map { Int($0.date.timeIntervalSince1970) } ?? 0 let lastV = pts.last?.value ?? 0 let minV = pts.map(\.value).min() ?? 0 let maxV = pts.map(\.value).max() ?? 0 parts.append("\(line.seriesKey)#\(pts.count)#\(first)#\(last)#\(lastV)#\(minV)#\(maxV)") } return parts.joined(separator: "|") } /// 命中缓存(指纹一致)返回文本,否则 nil。 func cachedText(for bucket: SeriesBucket) -> String? { guard let data = UserDefaults.standard.data(forKey: Self.storePrefix + bucket.id), let c = try? JSONDecoder().decode(Cached.self, from: data), c.fingerprint == Self.fingerprint(for: bucket) else { return nil } return c.text } /// 现算一条解读并写缓存。模型未就绪/输出为空时抛错,UI 显示「暂不可用 + 重试」。 func generate(for bucket: SeriesBucket) async throws -> String { try await AIRuntime.shared.prepare() let prompt = InsightPrompts.trendInsight( title: bucket.title, unit: bucket.unit, rangeText: Self.rangeText(for: bucket), dataLines: Self.dataLines(for: bucket) ) var collected = "" let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 140) for try await chunk in stream { collected += chunk.text } let text = HealthExportService.stripThinkBlocks(collected) .trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { throw AIRuntimeError.inferenceFailed("空输出") } let cached = Cached(fingerprint: Self.fingerprint(for: bucket), text: text, generatedAt: .now) if let data = try? JSONEncoder().encode(cached) { UserDefaults.standard.set(data, forKey: Self.storePrefix + bucket.id) } return text } /// 每条线最近 24 个点拼成 "yyyy-MM-dd 值";多线(血压)各占一行带 label 前缀。 /// 用 UTC 时区:展示日期仅供模型理解走向,差几小时无影响,但测试与设备时区无关。 nonisolated static func dataLines(for bucket: SeriesBucket) -> String { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POSIX") df.timeZone = TimeZone(identifier: "UTC") df.dateFormat = "yyyy-MM-dd" var lines: [String] = [] for line in bucket.lines { let pts = line.points.suffix(24) let prefix = bucket.lines.count > 1 ? "\(line.label ?? line.seriesKey):" : "" let series = pts.map { "\(df.string(from: $0.date)) \(fmt($0.value))" } .joined(separator: " / ") lines.append(prefix + series) } return lines.joined(separator: "\n") } /// ",参考 lo-hi" 或空串(无参考范围时整段省略)。 nonisolated static func rangeText(for bucket: SeriesBucket) -> String { guard let r = bucket.lines.first?.referenceRange else { return "" } return ",参考 \(fmt(r.lowerBound))-\(fmt(r.upperBound))" } private nonisolated static func fmt(_ v: Double) -> String { v.truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", v) : String(format: "%.1f", v) } }