feat(Trends): AI 趋势解读上线 — 数据指纹缓存,秒开不重算

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
link2026
2026-06-10 07:12:48 +08:00
parent 43cdde9bab
commit 0dd60d6021
3 changed files with 212 additions and 22 deletions

View File

@@ -69,7 +69,7 @@ struct TrendDetailView: View {
}
chartCard
statsCard
aiPlaceholder
TrendInsightCard(bucket: bucket)
pointsList
}
.padding(.horizontal, 20)
@@ -318,27 +318,6 @@ struct TrendDetailView: View {
return ("\(arrow) \(fmt(abs(d)))\(pctStr)", color)
}
// MARK: AI
private var aiPlaceholder: some View {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Text("AI 趋势解读即将上线")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2.opacity(0.6))
)
}
// MARK:
/// 线:,线
@@ -423,6 +402,80 @@ struct TrendDetailView: View {
}
}
// MARK: - AI
/// :;( TrendInsightService,§3.1)
private struct TrendInsightCard: View {
let bucket: SeriesBucket
@State private var text: String?
@State private var running = false
@State private var failedMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.ink)
Text("AI 解读")
.font(.tjScaled( 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
Spacer()
}
if let text {
Text(text)
.font(.tjScaled( 13))
.lineSpacing(3)
.foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
AIDisclaimerFooter()
} else if running {
Text("本地 AI 解读中…")
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
AIFlowBar()
} else if let failedMessage {
HStack {
Text(failedMessage)
.font(.tjScaled( 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button("重试") { Task { await load(force: true) } }
.font(.tjScaled( 12, weight: .medium))
.foregroundStyle(Tj.Palette.ink)
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.task(id: bucket.id) { await load(force: false) }
}
@MainActor
private func load(force: Bool) async {
if !force, let cached = TrendInsightService.shared.cachedText(for: bucket) {
text = cached
return
}
running = true
failedMessage = nil
do {
text = try await TrendInsightService.shared.generate(for: bucket)
} catch {
failedMessage = String(appLoc: "AI 解读暂不可用(模型未就绪或繁忙)")
}
running = false
}
}
enum TrendRange: String, CaseIterable, Identifiable {
case all, year, sixMonths, threeMonths
var id: String { rawValue }

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

View File

@@ -0,0 +1,43 @@
import Testing
import SwiftUI
@testable import
@MainActor
struct TrendInsightCacheTests {
private func bucket(values: [Double]) -> SeriesBucket {
let points = values.enumerated().map { i, v in
SeriesBucket.Point(id: "p\(i)",
date: Date(timeIntervalSince1970: Double(i) * 86_400),
value: v, status: .normal)
}
let line = SeriesBucket.SeriesLine(id: "glucose.fasting", seriesKey: "glucose.fasting",
label: nil, color: .blue, points: points,
referenceRange: 3.9...6.1)
return SeriesBucket(id: "glucose.fasting", title: "空腹血糖", unit: "mmol/L",
lines: [line], latestDate: .now, kind: .monitor)
}
@Test func fingerprintStableForSameData() {
let a = TrendInsightService.fingerprint(for: bucket(values: [5.2, 5.5]))
let b = TrendInsightService.fingerprint(for: bucket(values: [5.2, 5.5]))
#expect(a == b)
}
@Test func fingerprintChangesWhenDataChanges() {
let a = TrendInsightService.fingerprint(for: bucket(values: [5.2, 5.5]))
let b = TrendInsightService.fingerprint(for: bucket(values: [5.2, 5.5, 6.0]))
#expect(a != b)
}
@Test func dataLinesFormatsDateAndValue() {
let lines = TrendInsightService.dataLines(for: bucket(values: [5.2, 5.5]))
#expect(lines.contains("1970-01-01 5.2"))
#expect(lines.contains("1970-01-02 5.5"))
}
@Test func rangeTextRendersReference() {
#expect(TrendInsightService.rangeText(for: bucket(values: [5.2]))
== ",参考 3.9-6.1")
}
}