Files
kangkang/康康/Services/TrendInsightService.swift
2026-06-10 07:12:48 +08:00

95 lines
4.2 KiB
Swift

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