Files
Reflect/Shared/Services/FoundationModelsInsightService.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

288 lines
11 KiB
Swift

//
// FoundationModelsInsightService.swift
// Reflect
//
// 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
@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?
// 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
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 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")
}
// 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
}
}