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:
Trey t
2025-12-13 10:20:11 -06:00
parent 6adef2d6fc
commit 5974002a82
6 changed files with 934 additions and 1046 deletions

View 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
}
}