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>
315 lines
12 KiB
Swift
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
|
|
}
|
|
}
|