diff --git a/康康/Features/Trends/TrendDetailView.swift b/康康/Features/Trends/TrendDetailView.swift index 5d4327b..9eacbb9 100644 --- a/康康/Features/Trends/TrendDetailView.swift +++ b/康康/Features/Trends/TrendDetailView.swift @@ -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 } diff --git a/康康/Services/TrendInsightService.swift b/康康/Services/TrendInsightService.swift new file mode 100644 index 0000000..c896db6 --- /dev/null +++ b/康康/Services/TrendInsightService.swift @@ -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) + } +} diff --git a/康康Tests/TrendInsightCacheTests.swift b/康康Tests/TrendInsightCacheTests.swift new file mode 100644 index 0000000..1bed065 --- /dev/null +++ b/康康Tests/TrendInsightCacheTests.swift @@ -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") + } +}