// // FoundationModelsInsightService.swift // Reflect // // Created by Claude Code on 12/13/24. // import Foundation import FoundationModels import os.log /// 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" } } } /// Why Apple Intelligence is unavailable enum AIUnavailableReason { case deviceNotEligible case notEnabled case modelDownloading case unknown case preiOS26 } /// Service responsible for generating AI-powered mood insights using Apple's Foundation Models @available(iOS 26, *) @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? @Published private(set) var unavailableReason: AIUnavailableReason = .unknown // MARK: - Dependencies private let summarizer = MoodDataSummarizer() // MARK: - Cache private var cachedInsights: [String: (insights: [Insight], timestamp: Date)] = [:] private let cacheValidityDuration: TimeInterval = 3600 // 1 hour private var inProgressPeriods: Set = [] // 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 unavailableReason = .unknown case .unavailable(let reason): isAvailable = false unavailableReason = mapUnavailableReason(reason) lastError = .modelUnavailable(reason: describeUnavailability(reason)) @unknown default: isAvailable = false unavailableReason = .unknown lastError = .modelUnavailable(reason: "Unknown availability status") } } private func mapUnavailableReason(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> AIUnavailableReason { switch reason { case .deviceNotEligible: return .deviceNotEligible case .appleIntelligenceNotEnabled: return .notEnabled case .modelNotReady: return .modelDownloading @unknown default: return .unknown } } 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" } } /// Prewarm the language model to reduce first-generation latency func prewarm() { let session = LanguageModelSession(instructions: systemInstructions) session.prewarm() } /// Creates a fresh session per request (sessions accumulate transcript, so reuse causes context overflow) 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 Reflect 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 } // Prevent duplicate concurrent generation for the same period guard !inProgressPeriods.contains(periodName) else { // Already generating for this period, wait for cache return cachedInsights[periodName]?.insights ?? [] } inProgressPeriods.insert(periodName) defer { inProgressPeriods.remove(periodName) } guard isAvailable else { throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available") } let activeSession = 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 activeSession.respond( to: prompt, generating: AIInsightsResponse.self, options: GenerationOptions(maximumResponseTokens: 600) ) let insights = response.content.insights.map { $0.toInsight() } // Cache results cachedInsights[periodName] = (insights, Date()) return insights } catch { // Log detailed error for debugging AppLogger.ai.error("AI Insight generation failed for '\(periodName)': \(error)") 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. Keep each insight to 1-2 sentences. If theme tags are available, identify what good days and bad days have in common. If weather data is available, note weather-mood correlations. If logging gaps exist, comment on what happens around breaks in tracking. """ } // 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 } }