Files
Reflect/Shared/Services/FoundationModelsInsightService.swift
Trey t 2e9e28d00b Pass raw health metrics to AI instead of hardcoded correlations
- Replace HealthService.analyzeCorrelations() with computeHealthAverages()
- Remove hardcoded threshold-based correlation analysis (8k steps, 7hrs sleep, etc.)
- Pass raw averages (steps, exercise, sleep, HRV, HR, mindfulness, calories) to AI
- Let Apple Intelligence find nuanced multi-variable patterns naturally
- Update MoodDataSummarizer to format raw health data for AI prompts
- Simplifies code by ~200 lines while improving insight quality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 09:42:45 -06:00

278 lines
10 KiB
Swift

//
// FoundationModelsInsightService.swift
// Feels
//
// Created by Claude Code on 12/13/24.
//
import Foundation
import FoundationModels
/// Error types for insight generation
enum InsightGenerationError: Error, LocalizedError {
case modelUnavailable(reason: String)
case insufficientData
case generationFailed(underlying: Error)
case invalidResponse
var errorDescription: String? {
switch self {
case .modelUnavailable(let reason):
return "AI insights unavailable: \(reason)"
case .insufficientData:
return "Not enough mood data to generate insights"
case .generationFailed(let error):
return "Failed to generate insights: \(error.localizedDescription)"
case .invalidResponse:
return "Unable to parse AI response"
}
}
}
/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
@MainActor
class FoundationModelsInsightService: ObservableObject {
// MARK: - Published State
@Published private(set) var isAvailable: Bool = false
@Published private(set) var isGenerating: Bool = false
@Published private(set) var lastError: InsightGenerationError?
// MARK: - Dependencies
private let summarizer = MoodDataSummarizer()
// MARK: - Cache
private var cachedInsights: [String: (insights: [Insight], timestamp: Date)] = [:]
private let cacheValidityDuration: TimeInterval = 3600 // 1 hour
// MARK: - Initialization
init() {
checkAvailability()
}
/// Check if Foundation Models is available on this device
func checkAvailability() {
let model = SystemLanguageModel.default
switch model.availability {
case .available:
isAvailable = true
case .unavailable(let reason):
isAvailable = false
lastError = .modelUnavailable(reason: describeUnavailability(reason))
@unknown default:
isAvailable = false
lastError = .modelUnavailable(reason: "Unknown availability status")
}
}
private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
switch reason {
case .deviceNotEligible:
return "This device doesn't support Apple Intelligence"
case .appleIntelligenceNotEnabled:
return "Apple Intelligence is not enabled in Settings"
case .modelNotReady:
return "The AI model is still downloading"
@unknown default:
return "AI features are not available"
}
}
/// Creates a new session for each request to allow concurrent generation
private func createSession() -> LanguageModelSession {
LanguageModelSession(instructions: systemInstructions)
}
// MARK: - System Instructions
private var systemInstructions: String {
let personalityPack = UserDefaultsStore.personalityPackable()
switch personalityPack {
case .Default:
return defaultSystemInstructions
// case .Rude:
// return rudeSystemInstructions
case .MotivationalCoach:
return coachSystemInstructions
case .ZenMaster:
return zenSystemInstructions
case .BestFriend:
return bestFriendSystemInstructions
case .DataAnalyst:
return analystSystemInstructions
}
}
private var defaultSystemInstructions: String {
"""
You are a supportive mood analyst for the Feels app. Analyze mood data and provide warm, actionable insights.
Style: Encouraging, empathetic, concise (1-2 sentences per insight). Reference specific data.
SF Symbols: star.fill, sun.max.fill, heart.fill, chart.line.uptrend.xyaxis, trophy.fill, calendar, leaf.fill, sparkles
"""
}
private var rudeSystemInstructions: String {
"""
You are a brutally honest, sarcastic mood analyst. Think: judgmental friend meets disappointed therapist.
Style: Backhanded compliments, dramatic disappointment, weaponize their own data against them. Use "Oh honey," "Congratulations," and "Shocking" sarcastically. Mock patterns, not pain.
Examples: "THREE whole good days? Trophy's in the mail." / "Mondays destroy you. Revolutionary." / "Your mood tanks Sundays. Almost like you hate your job."
SF Symbols: eye.fill, trophy.fill, flame.fill, exclamationmark.triangle.fill, theatermasks.fill, crown.fill
"""
}
private var coachSystemInstructions: String {
"""
You are a HIGH ENERGY motivational coach analyzing mood data! Think Tony Robbins meets sports coach.
Style: Enthusiastic, empowering, action-oriented! Use exclamations! Celebrate wins BIG, frame struggles as opportunities for GROWTH. Every insight ends with a call to action.
Phrases: "Let's GO!", "Champion move!", "That's the winner's mindset!", "You're in the ZONE!", "Level up!"
SF Symbols: figure.run, trophy.fill, flame.fill, bolt.fill, star.fill, flag.checkered, medal.fill
"""
}
private var zenSystemInstructions: String {
"""
You are a calm, mindful Zen master reflecting on mood data. Think Buddhist monk meets gentle therapist.
Style: Serene, philosophical, uses nature metaphors. Speak in calm, measured tones. Find wisdom in all emotions. No judgment, only observation and acceptance.
Phrases: "Like the seasons...", "The river of emotion...", "In stillness we find...", "This too shall pass...", "With gentle awareness..."
SF Symbols: leaf.fill, moon.fill, drop.fill, wind, cloud.fill, sunrise.fill, sparkles, peacesign
"""
}
private var bestFriendSystemInstructions: String {
"""
You are their supportive best friend analyzing their mood data! Think caring bestie who's always got their back.
Style: Warm, casual, uses "you" and "we" language. Validate feelings, celebrate with them, commiserate together. Use conversational tone with occasional gentle humor.
Phrases: "Okay but...", "Not gonna lie...", "I see you!", "That's so valid!", "Girl/Dude...", "Honestly though..."
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, star.fill, sun.max.fill, face.smiling.fill, balloon.fill
"""
}
private var analystSystemInstructions: String {
"""
You are a clinical data analyst examining mood metrics. Think spreadsheet expert meets research scientist.
Style: Objective, statistical, data-driven. Reference exact numbers, percentages, and trends. Avoid emotional language. Present findings like a research report.
Phrases: "Data indicates...", "Statistically significant...", "Correlation observed...", "Trend analysis shows...", "Based on the metrics..."
SF Symbols: chart.bar.fill, chart.line.uptrend.xyaxis, function, number, percent, chart.pie.fill, doc.text.magnifyingglass
"""
}
// MARK: - Insight Generation
/// Generate AI-powered insights for the given mood entries
/// - Parameters:
/// - entries: Array of mood entries to analyze
/// - periodName: The time period name (e.g., "this month", "this year", "all time")
/// - count: Number of insights to generate (default 5)
/// - healthAverages: Optional raw health data for AI to analyze correlations
/// - Returns: Array of Insight objects
func generateInsights(
for entries: [MoodEntryModel],
periodName: String,
count: Int = 5,
healthAverages: HealthService.HealthAverages? = nil
) async throws -> [Insight] {
// Check cache first
if let cached = cachedInsights[periodName],
Date().timeIntervalSince(cached.timestamp) < cacheValidityDuration {
return cached.insights
}
guard isAvailable else {
throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available")
}
// Create a new session for this request to allow concurrent generation
let session = createSession()
// Filter valid entries
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
guard !validEntries.isEmpty else {
throw InsightGenerationError.insufficientData
}
isGenerating = true
defer { isGenerating = false }
// Prepare data summary with health data for AI analysis
let summary = summarizer.summarize(entries: validEntries, periodName: periodName, healthAverages: healthAverages)
let prompt = buildPrompt(from: summary, count: count)
do {
let response = try await session.respond(
to: prompt,
generating: AIInsightsResponse.self
)
let insights = response.content.insights.map { $0.toInsight() }
// Cache results
cachedInsights[periodName] = (insights, Date())
return insights
} catch {
// Log detailed error for debugging
print("❌ AI Insight generation failed for '\(periodName)': \(error)")
print(" Error type: \(type(of: error))")
print(" Localized: \(error.localizedDescription)")
lastError = .generationFailed(underlying: error)
throw lastError!
}
}
// MARK: - Prompt Construction
private func buildPrompt(from summary: MoodDataSummary, count: Int) -> String {
let dataSection = summarizer.toPromptString(summary)
return """
Analyze this mood data and generate \(count) insights:
\(dataSection)
Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points.
"""
}
// MARK: - Cache Management
/// Invalidate all cached insights
func invalidateCache() {
cachedInsights.removeAll()
}
/// Invalidate cached insights for a specific period
func invalidateCache(for periodName: String) {
cachedInsights.removeValue(forKey: periodName)
}
/// Check if insights are cached for a period
func hasCachedInsights(for periodName: String) -> Bool {
guard let cached = cachedInsights[periodName] else { return false }
return Date().timeIntervalSince(cached.timestamp) < cacheValidityDuration
}
}