Speed optimizations:
- Add session.prewarm() in InsightsViewModel and ReportsViewModel init
for 40% faster first-token latency
- Cap maximumResponseTokens on all 8 AI respond() calls (100-600 per use case)
- Add prompt brevity constraints ("1-2 sentences", "2 sentences")
- Reduce report batch concurrency from 4 to 2 to prevent device contention
- Pre-fetch health data once and share across all 3 insight periods
Richer insight data in MoodDataSummarizer:
- Tag-mood correlations: overall frequency + good day vs bad day tag breakdown
- Weather-mood correlations: avg mood by condition and temperature range
- Absence pattern detection: logging gap count with pre/post-gap mood averages
- Entry source breakdown: % of entries from App, Widget, Watch, Siri, etc.
- Update insight prompt to leverage tags, weather, and gap data when available
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
233 lines
8.0 KiB
Swift
233 lines
8.0 KiB
Swift
//
|
|
// InsightsViewModel.swift
|
|
// Reflect
|
|
//
|
|
// Created by Claude Code on 12/9/24.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Represents a single insight to display in the UI
|
|
struct Insight: Identifiable {
|
|
let id = UUID()
|
|
let icon: String
|
|
let title: String
|
|
let description: String
|
|
let mood: Mood?
|
|
}
|
|
|
|
/// Loading state for each insight section
|
|
enum InsightLoadingState: Equatable {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case error(String)
|
|
}
|
|
|
|
/// ViewModel for the Insights tab - uses Apple Foundation Models for AI-powered insights
|
|
@MainActor
|
|
class InsightsViewModel: ObservableObject {
|
|
|
|
// MARK: - Published Properties
|
|
|
|
@Published var monthInsights: [Insight] = []
|
|
@Published var yearInsights: [Insight] = []
|
|
@Published var allTimeInsights: [Insight] = []
|
|
|
|
@Published var monthLoadingState: InsightLoadingState = .idle
|
|
@Published var yearLoadingState: InsightLoadingState = .idle
|
|
@Published var allTimeLoadingState: InsightLoadingState = .idle
|
|
|
|
@Published var isAIAvailable: Bool = false
|
|
|
|
// MARK: - Dependencies
|
|
|
|
/// Stored as Any? to avoid referencing @available(iOS 26, *) type at the property level
|
|
private var insightService: Any?
|
|
private let healthService = HealthService.shared
|
|
private let calendar = Calendar.current
|
|
|
|
// MARK: - Initialization
|
|
|
|
private var dataListenerToken: DataController.DataListenerToken?
|
|
|
|
init() {
|
|
if #available(iOS 26, *) {
|
|
let service = FoundationModelsInsightService()
|
|
insightService = service
|
|
isAIAvailable = service.isAvailable
|
|
service.prewarm()
|
|
} else {
|
|
insightService = nil
|
|
isAIAvailable = false
|
|
}
|
|
|
|
dataListenerToken = DataController.shared.addNewDataListener { [weak self] in
|
|
self?.onDataChanged()
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
if let token = dataListenerToken {
|
|
Task { @MainActor in
|
|
DataController.shared.removeDataListener(token: token)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Called when mood data changes in another tab. Invalidates cached insights
|
|
/// so they are regenerated with fresh data on next view appearance.
|
|
private func onDataChanged() {
|
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
|
service.invalidateCache()
|
|
}
|
|
monthLoadingState = .idle
|
|
yearLoadingState = .idle
|
|
allTimeLoadingState = .idle
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Generate insights for all time periods
|
|
func generateInsights() {
|
|
Task {
|
|
await generateAllInsights()
|
|
}
|
|
}
|
|
|
|
/// Force refresh all insights (invalidates cache)
|
|
func refreshInsights() {
|
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
|
service.invalidateCache()
|
|
}
|
|
generateInsights()
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func generateAllInsights() async {
|
|
let now = Date()
|
|
|
|
// Get date ranges
|
|
let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: now))!
|
|
let yearStart = calendar.date(from: calendar.dateComponents([.year], from: now))!
|
|
let allTimeStart = Date(timeIntervalSince1970: 0)
|
|
|
|
// Fetch entries for each period
|
|
let monthEntries = DataController.shared.getData(startDate: monthStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
|
|
let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
|
|
let allTimeEntries = DataController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7])
|
|
|
|
// Pre-fetch health data once (instead of 3x per period)
|
|
var sharedHealthAverages: HealthService.HealthAverages?
|
|
if healthService.isEnabled && healthService.isAuthorized {
|
|
let allValidEntries = allTimeEntries.filter { ![.missing, .placeholder].contains($0.mood) }
|
|
if !allValidEntries.isEmpty {
|
|
let healthData = await healthService.fetchHealthData(for: allValidEntries)
|
|
sharedHealthAverages = healthService.computeHealthAverages(entries: allValidEntries, healthData: healthData)
|
|
}
|
|
}
|
|
|
|
// Generate insights concurrently for all three periods
|
|
await withTaskGroup(of: Void.self) { group in
|
|
group.addTask { @MainActor in
|
|
await self.generatePeriodInsights(
|
|
entries: monthEntries,
|
|
periodName: "this month",
|
|
healthAverages: sharedHealthAverages,
|
|
updateState: { self.monthLoadingState = $0 },
|
|
updateInsights: { self.monthInsights = $0 }
|
|
)
|
|
}
|
|
|
|
group.addTask { @MainActor in
|
|
await self.generatePeriodInsights(
|
|
entries: yearEntries,
|
|
periodName: "this year",
|
|
healthAverages: sharedHealthAverages,
|
|
updateState: { self.yearLoadingState = $0 },
|
|
updateInsights: { self.yearInsights = $0 }
|
|
)
|
|
}
|
|
|
|
group.addTask { @MainActor in
|
|
await self.generatePeriodInsights(
|
|
entries: allTimeEntries,
|
|
periodName: "all time",
|
|
healthAverages: sharedHealthAverages,
|
|
updateState: { self.allTimeLoadingState = $0 },
|
|
updateInsights: { self.allTimeInsights = $0 }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func generatePeriodInsights(
|
|
entries: [MoodEntryModel],
|
|
periodName: String,
|
|
healthAverages: HealthService.HealthAverages?,
|
|
updateState: @escaping (InsightLoadingState) -> Void,
|
|
updateInsights: @escaping ([Insight]) -> Void
|
|
) async {
|
|
// Filter valid entries
|
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
|
|
|
// Handle empty data case
|
|
guard !validEntries.isEmpty else {
|
|
updateInsights([Insight(
|
|
icon: "questionmark.circle",
|
|
title: "No Data Yet",
|
|
description: "Start logging your moods to see insights for \(periodName).",
|
|
mood: nil
|
|
)])
|
|
updateState(.loaded)
|
|
return
|
|
}
|
|
|
|
// Check if AI is available
|
|
guard isAIAvailable else {
|
|
updateInsights([Insight(
|
|
icon: "brain.head.profile",
|
|
title: "AI Unavailable",
|
|
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
|
|
mood: nil
|
|
)])
|
|
updateState(.error("AI not available"))
|
|
return
|
|
}
|
|
|
|
updateState(.loading)
|
|
|
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
|
do {
|
|
let insights = try await service.generateInsights(
|
|
for: validEntries,
|
|
periodName: periodName,
|
|
count: 5,
|
|
healthAverages: healthAverages
|
|
)
|
|
updateInsights(insights)
|
|
updateState(.loaded)
|
|
} catch {
|
|
// On error, provide a helpful message
|
|
updateInsights([Insight(
|
|
icon: "exclamationmark.triangle",
|
|
title: "Insights Unavailable",
|
|
description: "Unable to generate AI insights right now. Please try again later.",
|
|
mood: nil
|
|
)])
|
|
updateState(.error(error.localizedDescription))
|
|
}
|
|
} else {
|
|
updateInsights([Insight(
|
|
icon: "brain.head.profile",
|
|
title: "AI Unavailable",
|
|
description: "Apple Intelligence is required for personalized insights. Please enable it in Settings.",
|
|
mood: nil
|
|
)])
|
|
updateState(.error("AI not available"))
|
|
}
|
|
}
|
|
}
|