Broadens installable audience to iOS 18+ while keeping AI insights available on iOS 26. Foundation Models types and service wrapped in @available(iOS 26, *), InsightsViewModel conditionally instantiates the service with fallback UI on older versions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
288 lines
11 KiB
Swift
288 lines
11 KiB
Swift
//
|
|
// 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
|
|
@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 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
|
|
"""
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|