// // 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 } } 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 """ } // 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) /// - Returns: Array of Insight objects func generateInsights( for entries: [MoodEntryModel], periodName: String, count: Int = 5 ) 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 let summary = summarizer.summarize(entries: validEntries, periodName: periodName) 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 } }