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

View File

@@ -0,0 +1,389 @@
//
// MoodDataSummarizer.swift
// Feels
//
// Created by Claude Code on 12/13/24.
//
import Foundation
/// Summary of mood data for a specific time period, formatted for AI consumption
struct MoodDataSummary {
let periodName: String
let totalEntries: Int
let dateRange: String
// Mood distribution
let moodCounts: [String: Int]
let moodPercentages: [String: Int]
let averageMoodScore: Double
// Temporal patterns
let weekdayAverages: [String: Double]
let weekendAverage: Double
let weekdayAverage: Double
let bestDayOfWeek: String
let worstDayOfWeek: String
// Trends
let recentTrend: String
let trendMagnitude: Double
// Streaks
let currentLoggingStreak: Int
let longestLoggingStreak: Int
let longestPositiveStreak: Int
let longestNegativeStreak: Int
// Variability
let moodSwingCount: Int
let moodStabilityScore: Double
// Recent context
let last7DaysAverage: Double
let last7DaysMoods: [String]
// Notable observations
let hasAllMoodTypes: Bool
let missingMoodTypes: [String]
}
/// Transforms raw MoodEntryModel data into AI-optimized summaries
class MoodDataSummarizer {
private let calendar = Calendar.current
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
// MARK: - Main Summarization
func summarize(entries: [MoodEntryModel], periodName: String) -> MoodDataSummary {
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
guard !validEntries.isEmpty else {
return emptyDataSummary(periodName: periodName)
}
let sortedEntries = validEntries.sorted { $0.forDate < $1.forDate }
// Calculate all metrics
let moodDistribution = calculateMoodDistribution(entries: validEntries)
let temporalPatterns = calculateTemporalPatterns(entries: validEntries)
let trend = calculateTrend(entries: sortedEntries)
let streaks = calculateStreaks(entries: sortedEntries)
let variability = calculateVariability(entries: sortedEntries)
let recentContext = calculateRecentContext(entries: sortedEntries)
let moodTypes = calculateMoodTypes(entries: validEntries)
// Format date range
let dateRange = formatDateRange(entries: sortedEntries)
return MoodDataSummary(
periodName: periodName,
totalEntries: validEntries.count,
dateRange: dateRange,
moodCounts: moodDistribution.counts,
moodPercentages: moodDistribution.percentages,
averageMoodScore: moodDistribution.average,
weekdayAverages: temporalPatterns.weekdayAverages,
weekendAverage: temporalPatterns.weekendAverage,
weekdayAverage: temporalPatterns.weekdayAverage,
bestDayOfWeek: temporalPatterns.bestDay,
worstDayOfWeek: temporalPatterns.worstDay,
recentTrend: trend.direction,
trendMagnitude: trend.magnitude,
currentLoggingStreak: streaks.current,
longestLoggingStreak: streaks.longest,
longestPositiveStreak: streaks.longestPositive,
longestNegativeStreak: streaks.longestNegative,
moodSwingCount: variability.swingCount,
moodStabilityScore: variability.stabilityScore,
last7DaysAverage: recentContext.average,
last7DaysMoods: recentContext.moods,
hasAllMoodTypes: moodTypes.hasAll,
missingMoodTypes: moodTypes.missing
)
}
// MARK: - Mood Distribution
private func calculateMoodDistribution(entries: [MoodEntryModel]) -> (counts: [String: Int], percentages: [String: Int], average: Double) {
var counts: [String: Int] = [:]
var totalScore = 0
for entry in entries {
let moodName = entry.mood.widgetDisplayName.lowercased()
counts[moodName, default: 0] += 1
totalScore += Int(entry.moodValue)
}
var percentages: [String: Int] = [:]
for (mood, count) in counts {
percentages[mood] = Int((Double(count) / Double(entries.count)) * 100)
}
let average = Double(totalScore) / Double(entries.count)
return (counts, percentages, average)
}
// MARK: - Temporal Patterns
private func calculateTemporalPatterns(entries: [MoodEntryModel]) -> (weekdayAverages: [String: Double], weekendAverage: Double, weekdayAverage: Double, bestDay: String, worstDay: String) {
let weekdayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
var weekdayTotals: [Int: (total: Int, count: Int)] = [:]
for entry in entries {
let weekday = Int(entry.weekDay)
let current = weekdayTotals[weekday, default: (0, 0)]
weekdayTotals[weekday] = (current.total + Int(entry.moodValue), current.count + 1)
}
var weekdayAverages: [String: Double] = [:]
var bestDay = "Monday"
var worstDay = "Monday"
var bestAvg = -1.0
var worstAvg = 5.0
for (weekday, data) in weekdayTotals {
let avg = Double(data.total) / Double(data.count)
let dayName = weekdayNames[weekday - 1]
weekdayAverages[dayName] = avg
if avg > bestAvg {
bestAvg = avg
bestDay = dayName
}
if avg < worstAvg {
worstAvg = avg
worstDay = dayName
}
}
// Weekend vs weekday
let weekendEntries = entries.filter { [1, 7].contains(Int($0.weekDay)) }
let weekdayEntries = entries.filter { ![1, 7].contains(Int($0.weekDay)) }
let weekendAvg = weekendEntries.isEmpty ? 0 : Double(weekendEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(weekendEntries.count)
let weekdayAvg = weekdayEntries.isEmpty ? 0 : Double(weekdayEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(weekdayEntries.count)
return (weekdayAverages, weekendAvg, weekdayAvg, bestDay, worstDay)
}
// MARK: - Trend Analysis
private func calculateTrend(entries: [MoodEntryModel]) -> (direction: String, magnitude: Double) {
guard entries.count >= 4 else {
return ("stable", 0.0)
}
let halfCount = entries.count / 2
let firstHalf = Array(entries.prefix(halfCount))
let secondHalf = Array(entries.suffix(halfCount))
let firstAvg = Double(firstHalf.reduce(0) { $0 + Int($1.moodValue) }) / Double(firstHalf.count)
let secondAvg = Double(secondHalf.reduce(0) { $0 + Int($1.moodValue) }) / Double(secondHalf.count)
let diff = secondAvg - firstAvg
let direction: String
if diff > 0.5 {
direction = "improving"
} else if diff < -0.5 {
direction = "declining"
} else {
direction = "stable"
}
return (direction, abs(diff))
}
// MARK: - Streak Calculations
private func calculateStreaks(entries: [MoodEntryModel]) -> (current: Int, longest: Int, longestPositive: Int, longestNegative: Int) {
let sortedByDateDesc = entries.sorted { $0.forDate > $1.forDate }
// Current logging streak
var currentStreak = 0
let today = calendar.startOfDay(for: Date())
if let mostRecent = sortedByDateDesc.first?.forDate {
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) {
currentStreak = 1
var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent)!
for entry in sortedByDateDesc.dropFirst() {
if calendar.isDate(entry.forDate, inSameDayAs: checkDate) {
currentStreak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate)!
} else {
break
}
}
}
}
// Longest logging streak
var longestStreak = 1
var tempStreak = 1
let sortedByDateAsc = entries.sorted { $0.forDate < $1.forDate }
for i in 1..<sortedByDateAsc.count {
let dayDiff = calendar.dateComponents([.day], from: sortedByDateAsc[i-1].forDate, to: sortedByDateAsc[i].forDate).day ?? 0
if dayDiff == 1 {
tempStreak += 1
longestStreak = max(longestStreak, tempStreak)
} else {
tempStreak = 1
}
}
// Positive mood streak (good/great)
let longestPositive = calculateMoodStreak(entries: sortedByDateAsc, moods: [.good, .great])
// Negative mood streak (bad/horrible)
let longestNegative = calculateMoodStreak(entries: sortedByDateAsc, moods: [.bad, .horrible])
return (currentStreak, longestStreak, longestPositive, longestNegative)
}
private func calculateMoodStreak(entries: [MoodEntryModel], moods: [Mood]) -> Int {
var longest = 0
var current = 0
for entry in entries {
if moods.contains(entry.mood) {
current += 1
longest = max(longest, current)
} else {
current = 0
}
}
return longest
}
// MARK: - Variability
private func calculateVariability(entries: [MoodEntryModel]) -> (swingCount: Int, stabilityScore: Double) {
guard entries.count >= 2 else {
return (0, 1.0)
}
var swings = 0
for i in 1..<entries.count {
let diff = abs(Int(entries[i].moodValue) - Int(entries[i-1].moodValue))
if diff >= 2 {
swings += 1
}
}
let swingRate = Double(swings) / Double(entries.count - 1)
let stabilityScore = 1.0 - min(swingRate, 1.0)
return (swings, stabilityScore)
}
// MARK: - Recent Context
private func calculateRecentContext(entries: [MoodEntryModel]) -> (average: Double, moods: [String]) {
let recentEntries = entries.suffix(7)
guard !recentEntries.isEmpty else {
return (0, [])
}
let average = Double(recentEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(recentEntries.count)
let moods = recentEntries.map { $0.mood.widgetDisplayName }
return (average, moods)
}
// MARK: - Mood Types
private func calculateMoodTypes(entries: [MoodEntryModel]) -> (hasAll: Bool, missing: [String]) {
let allMoods: Set<Mood> = [.great, .good, .average, .bad, .horrible]
let presentMoods = Set(entries.map { $0.mood })
let missing = allMoods.subtracting(presentMoods).map { $0.widgetDisplayName }
let hasAll = missing.isEmpty
return (hasAll, missing)
}
// MARK: - Helpers
private func formatDateRange(entries: [MoodEntryModel]) -> String {
guard let first = entries.first, let last = entries.last else {
return "No data"
}
let startDate = dateFormatter.string(from: first.forDate)
let endDate = dateFormatter.string(from: last.forDate)
return "\(startDate) - \(endDate)"
}
private func emptyDataSummary(periodName: String) -> MoodDataSummary {
MoodDataSummary(
periodName: periodName,
totalEntries: 0,
dateRange: "No data",
moodCounts: [:],
moodPercentages: [:],
averageMoodScore: 0,
weekdayAverages: [:],
weekendAverage: 0,
weekdayAverage: 0,
bestDayOfWeek: "N/A",
worstDayOfWeek: "N/A",
recentTrend: "stable",
trendMagnitude: 0,
currentLoggingStreak: 0,
longestLoggingStreak: 0,
longestPositiveStreak: 0,
longestNegativeStreak: 0,
moodSwingCount: 0,
moodStabilityScore: 1.0,
last7DaysAverage: 0,
last7DaysMoods: [],
hasAllMoodTypes: false,
missingMoodTypes: ["great", "good", "average", "bad", "horrible"]
)
}
// MARK: - Prompt String Generation
/// Generates a concise prompt string optimized for Apple's 4096 token context limit
func toPromptString(_ summary: MoodDataSummary) -> String {
// Compact format to stay under token limit
var lines: [String] = []
lines.append("Period: \(summary.periodName), \(summary.totalEntries) entries, avg \(String(format: "%.1f", summary.averageMoodScore))/4")
// Mood distribution - compact
let moodDist = summary.moodPercentages.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)%" }
.joined(separator: ", ")
lines.append("Moods: \(moodDist)")
// Day patterns - only best/worst
lines.append("Best day: \(summary.bestDayOfWeek), Worst: \(summary.worstDayOfWeek)")
lines.append("Weekend avg: \(String(format: "%.1f", summary.weekendAverage)), Weekday avg: \(String(format: "%.1f", summary.weekdayAverage))")
// Trends
lines.append("Trend: \(summary.recentTrend), Last 7 days avg: \(String(format: "%.1f", summary.last7DaysAverage))")
// Streaks - compact
lines.append("Streaks - Current: \(summary.currentLoggingStreak)d, Longest: \(summary.longestLoggingStreak)d, Best positive: \(summary.longestPositiveStreak)d, Worst negative: \(summary.longestNegativeStreak)d")
// Stability
lines.append("Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%, Mood swings: \(summary.moodSwingCount)")
return lines.joined(separator: "\n")
}
}