Files
Reflect/Shared/Services/FoundationModelsInsightService.swift
Trey t b0cd4be8d7 Add AI enablement guidance with reason-specific UI and localized translations
Show specific guidance when Apple Intelligence is unavailable:
- Device not eligible: "iPhone 15 Pro or later required"
- Not enabled: step-by-step path + "Open Settings" button
- Model downloading: "Please wait" + "Try Again" button
- Pre-iOS 26: "Update required"

Auto re-checks availability when app returns to foreground so enabling
Apple Intelligence in Settings immediately triggers insight generation.

Adds translations for all new AI strings across de, es, fr, ja, ko, pt-BR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:36:04 -05:00

315 lines
12 KiB
Swift

//
// 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<String> = []
// 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
}
}