feat(Trends): AI 趋势解读上线 — 数据指纹缓存,秒开不重算
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
94
康康/Services/TrendInsightService.swift
Normal file
94
康康/Services/TrendInsightService.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user