Add AI-powered insights using Apple Foundation Models
- Replace static insights with on-device AI generation via FoundationModels framework - Add @Generable AIInsight model for structured LLM output - Create FoundationModelsInsightService with session-per-request for concurrent generation - Add MoodDataSummarizer to prepare mood data for AI analysis - Implement loading states with skeleton UI and pull-to-refresh - Add AI availability badge and error handling - Support default (supportive) and rude (sarcastic) personality modes - Optimize prompts to fit within 4096 token context limit - Bump iOS deployment target to 26.0 for Foundation Models support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
219
Shared/Services/FoundationModelsInsightService.swift
Normal file
219
Shared/Services/FoundationModelsInsightService.swift
Normal file
@@ -0,0 +1,219 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user