From 70400b779057a4aa26fbade7b8cf4361a08aeef4 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 4 Apr 2026 11:52:14 -0500 Subject: [PATCH] Optimize AI generation speed and add richer insight data Speed optimizations: - Add session.prewarm() in InsightsViewModel and ReportsViewModel init for 40% faster first-token latency - Cap maximumResponseTokens on all 8 AI respond() calls (100-600 per use case) - Add prompt brevity constraints ("1-2 sentences", "2 sentences") - Reduce report batch concurrency from 4 to 2 to prevent device contention - Pre-fetch health data once and share across all 3 insight periods Richer insight data in MoodDataSummarizer: - Tag-mood correlations: overall frequency + good day vs bad day tag breakdown - Weather-mood correlations: avg mood by condition and temperature range - Absence pattern detection: logging gap count with pre/post-gap mood averages - Entry source breakdown: % of entries from App, Widget, Watch, Siri, etc. - Update insight prompt to leverage tags, weather, and gap data when available Co-Authored-By: Claude Opus 4.6 (1M context) --- .../FoundationModelsDigestService.swift | 3 +- .../FoundationModelsInsightService.swift | 18 +- .../FoundationModelsReflectionService.swift | 6 +- .../Services/FoundationModelsTagService.swift | 2 +- Shared/Services/MoodDataSummarizer.swift | 224 +++++++++++++++++- .../InsightsView/InsightsViewModel.swift | 22 +- .../Views/InsightsView/ReportsViewModel.swift | 80 ++++--- 7 files changed, 302 insertions(+), 53 deletions(-) diff --git a/Shared/Services/FoundationModelsDigestService.swift b/Shared/Services/FoundationModelsDigestService.swift index c102912..d8ac23b 100644 --- a/Shared/Services/FoundationModelsDigestService.swift +++ b/Shared/Services/FoundationModelsDigestService.swift @@ -47,7 +47,7 @@ class FoundationModelsDigestService { let session = LanguageModelSession(instructions: systemInstructions) let prompt = buildPrompt(entries: validEntries, weekStart: weekStart, weekEnd: now) - let response = try await session.respond(to: prompt, generating: AIWeeklyDigestResponse.self) + let response = try await session.respond(to: prompt, generating: AIWeeklyDigestResponse.self, options: GenerationOptions(maximumResponseTokens: 300)) let digest = WeeklyDigest( headline: response.content.headline, @@ -150,6 +150,7 @@ class FoundationModelsDigestService { Current streak: \(summary.currentLoggingStreak) days Write a warm, personalized weekly digest. + Keep summary to 2 sentences. Keep highlight and intention to 1 sentence each. """ } } diff --git a/Shared/Services/FoundationModelsInsightService.swift b/Shared/Services/FoundationModelsInsightService.swift index ca0381e..1caea03 100644 --- a/Shared/Services/FoundationModelsInsightService.swift +++ b/Shared/Services/FoundationModelsInsightService.swift @@ -84,7 +84,13 @@ class FoundationModelsInsightService: ObservableObject { } } - /// Creates a new session for each request to allow concurrent generation + /// Prewarm the language model to reduce first-generation latency + func prewarm() { + let session = LanguageModelSession(instructions: systemInstructions) + session.prewarm() + } + + /// Creates a fresh session per request (sessions accumulate transcript, so reuse causes context overflow) private func createSession() -> LanguageModelSession { LanguageModelSession(instructions: systemInstructions) } @@ -213,8 +219,7 @@ class FoundationModelsInsightService: ObservableObject { throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available") } - // Create a new session for this request to allow concurrent generation - let session = createSession() + let activeSession = createSession() // Filter valid entries let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) } @@ -231,9 +236,10 @@ class FoundationModelsInsightService: ObservableObject { let prompt = buildPrompt(from: summary, count: count) do { - let response = try await session.respond( + let response = try await activeSession.respond( to: prompt, - generating: AIInsightsResponse.self + generating: AIInsightsResponse.self, + options: GenerationOptions(maximumResponseTokens: 600) ) let insights = response.content.insights.map { $0.toInsight() } @@ -263,7 +269,7 @@ class FoundationModelsInsightService: ObservableObject { \(dataSection) - Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points. + Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points. Keep each insight to 1-2 sentences. If theme tags are available, identify what good days and bad days have in common. If weather data is available, note weather-mood correlations. If logging gaps exist, comment on what happens around breaks in tracking. """ } diff --git a/Shared/Services/FoundationModelsReflectionService.swift b/Shared/Services/FoundationModelsReflectionService.swift index b9364d8..61ba318 100644 --- a/Shared/Services/FoundationModelsReflectionService.swift +++ b/Shared/Services/FoundationModelsReflectionService.swift @@ -28,14 +28,12 @@ class FoundationModelsReflectionService { mood: Mood ) async throws -> AIReflectionFeedback { let session = LanguageModelSession(instructions: systemInstructions) - let prompt = buildPrompt(from: reflection, mood: mood) - let response = try await session.respond( to: prompt, - generating: AIReflectionFeedback.self + generating: AIReflectionFeedback.self, + options: GenerationOptions(maximumResponseTokens: 200) ) - return response.content } diff --git a/Shared/Services/FoundationModelsTagService.swift b/Shared/Services/FoundationModelsTagService.swift index b3b2513..861a889 100644 --- a/Shared/Services/FoundationModelsTagService.swift +++ b/Shared/Services/FoundationModelsTagService.swift @@ -37,7 +37,7 @@ class FoundationModelsTagService { let prompt = buildPrompt(noteText: noteText, reflectionText: reflectionText, mood: entry.mood) do { - let response = try await session.respond(to: prompt, generating: AIEntryTags.self) + let response = try await session.respond(to: prompt, generating: AIEntryTags.self, options: GenerationOptions(maximumResponseTokens: 100)) return response.content.tags.map { $0.label.lowercased() } } catch { print("Tag extraction failed: \(error.localizedDescription)") diff --git a/Shared/Services/MoodDataSummarizer.swift b/Shared/Services/MoodDataSummarizer.swift index a60af4d..273cd78 100644 --- a/Shared/Services/MoodDataSummarizer.swift +++ b/Shared/Services/MoodDataSummarizer.swift @@ -49,6 +49,23 @@ struct MoodDataSummary { // Health data for AI analysis (optional) let healthAverages: HealthService.HealthAverages? + + // Tag-mood correlations + let tagFrequencies: [String: Int] + let goodDayTags: [String: Int] // tag counts for entries with mood good/great + let badDayTags: [String: Int] // tag counts for entries with mood bad/horrible + + // Weather-mood correlation + let weatherMoodAverages: [String: Double] // condition -> avg mood (1-5 scale) + let tempRangeMoodAverages: [String: Double] // "Cold"/"Mild"/"Warm"/"Hot" -> avg mood + + // Absence patterns + let loggingGapCount: Int // number of 2+ day gaps + let preGapMoodAverage: Double // avg mood in 3 days before a gap + let postGapMoodAverage: Double // avg mood in 3 days after returning + + // Entry source breakdown + let entrySourceBreakdown: [String: Int] // source name -> count } /// Transforms raw MoodEntryModel data into AI-optimized summaries @@ -83,6 +100,11 @@ class MoodDataSummarizer { // Format date range let dateRange = formatDateRange(entries: sortedEntries) + let tagAnalysis = calculateTagAnalysis(entries: validEntries) + let weatherAnalysis = calculateWeatherAnalysis(entries: validEntries) + let absencePatterns = calculateAbsencePatterns(entries: sortedEntries) + let sourceBreakdown = calculateEntrySourceBreakdown(entries: validEntries) + return MoodDataSummary( periodName: periodName, totalEntries: validEntries.count, @@ -107,7 +129,16 @@ class MoodDataSummarizer { last7DaysMoods: recentContext.moods, hasAllMoodTypes: moodTypes.hasAll, missingMoodTypes: moodTypes.missing, - healthAverages: healthAverages + healthAverages: healthAverages, + tagFrequencies: tagAnalysis.frequencies, + goodDayTags: tagAnalysis.goodDayTags, + badDayTags: tagAnalysis.badDayTags, + weatherMoodAverages: weatherAnalysis.conditionAverages, + tempRangeMoodAverages: weatherAnalysis.tempRangeAverages, + loggingGapCount: absencePatterns.gapCount, + preGapMoodAverage: absencePatterns.preGapAverage, + postGapMoodAverage: absencePatterns.postGapAverage, + entrySourceBreakdown: sourceBreakdown ) } @@ -346,6 +377,139 @@ class MoodDataSummarizer { return (hasAll, missing) } + // MARK: - Tag Analysis + + private func calculateTagAnalysis(entries: [MoodEntryModel]) -> (frequencies: [String: Int], goodDayTags: [String: Int], badDayTags: [String: Int]) { + var frequencies: [String: Int] = [:] + var goodDayTags: [String: Int] = [:] + var badDayTags: [String: Int] = [:] + + for entry in entries { + let entryTags = entry.tags + guard !entryTags.isEmpty else { continue } + + for tag in entryTags { + let normalizedTag = tag.lowercased() + frequencies[normalizedTag, default: 0] += 1 + + if [.good, .great].contains(entry.mood) { + goodDayTags[normalizedTag, default: 0] += 1 + } else if [.bad, .horrible].contains(entry.mood) { + badDayTags[normalizedTag, default: 0] += 1 + } + } + } + + return (frequencies, goodDayTags, badDayTags) + } + + // MARK: - Weather Analysis + + private func calculateWeatherAnalysis(entries: [MoodEntryModel]) -> (conditionAverages: [String: Double], tempRangeAverages: [String: Double]) { + var conditionTotals: [String: (total: Int, count: Int)] = [:] + var tempRangeTotals: [String: (total: Int, count: Int)] = [:] + + for entry in entries { + guard let json = entry.weatherJSON, let weather = WeatherData.decode(from: json) else { continue } + + let moodScore = Int(entry.moodValue) + 1 // 1-5 scale + + // Group by weather condition + let condition = weather.condition + let current = conditionTotals[condition, default: (0, 0)] + conditionTotals[condition] = (current.total + moodScore, current.count + 1) + + // Group by temperature range (convert Celsius to Fahrenheit) + let tempF = weather.temperature * 9.0 / 5.0 + 32.0 + let tempRange: String + if tempF < 50 { + tempRange = "Cold" + } else if tempF <= 70 { + tempRange = "Mild" + } else if tempF <= 85 { + tempRange = "Warm" + } else { + tempRange = "Hot" + } + let currentTemp = tempRangeTotals[tempRange, default: (0, 0)] + tempRangeTotals[tempRange] = (currentTemp.total + moodScore, currentTemp.count + 1) + } + + var conditionAverages: [String: Double] = [:] + for (condition, data) in conditionTotals { + conditionAverages[condition] = Double(data.total) / Double(data.count) + } + + var tempRangeAverages: [String: Double] = [:] + for (range, data) in tempRangeTotals { + tempRangeAverages[range] = Double(data.total) / Double(data.count) + } + + return (conditionAverages, tempRangeAverages) + } + + // MARK: - Absence Patterns + + private func calculateAbsencePatterns(entries: [MoodEntryModel]) -> (gapCount: Int, preGapAverage: Double, postGapAverage: Double) { + guard entries.count >= 2 else { + return (0, 0, 0) + } + + var gapCount = 0 + var preGapScores: [Int] = [] + var postGapScores: [Int] = [] + + for i in 1..= 2 else { continue } + + gapCount += 1 + + // Collect up to 3 entries before the gap + let preStart = max(0, i - 3) + for j in preStart.. [String: Int] { + var breakdown: [String: Int] = [:] + + let sourceNames: [Int: String] = [ + 0: "App", + 1: "Widget", + 2: "Watch", + 3: "Shortcut", + 4: "Auto-fill", + 5: "Notification", + 6: "Header", + 7: "Siri", + 8: "Control Center", + 9: "Live Activity" + ] + + for entry in entries { + let name = sourceNames[entry.entryType] ?? "Other" + breakdown[name, default: 0] += 1 + } + + return breakdown + } + // MARK: - Helpers private func formatDateRange(entries: [MoodEntryModel]) -> String { @@ -384,7 +548,16 @@ class MoodDataSummarizer { last7DaysMoods: [], hasAllMoodTypes: false, missingMoodTypes: ["great", "good", "average", "bad", "horrible"], - healthAverages: nil + healthAverages: nil, + tagFrequencies: [:], + goodDayTags: [:], + badDayTags: [:], + weatherMoodAverages: [:], + tempRangeMoodAverages: [:], + loggingGapCount: 0, + preGapMoodAverage: 0, + postGapMoodAverage: 0, + entrySourceBreakdown: [:] ) } @@ -469,6 +642,53 @@ class MoodDataSummarizer { lines.append("Analyze how these health metrics may correlate with mood patterns.") } + // Tag-mood correlations (only if tags exist) + if !summary.tagFrequencies.isEmpty { + let topTags = summary.tagFrequencies.sorted { $0.value > $1.value }.prefix(8) + .map { "\($0.key)(\($0.value))" }.joined(separator: ", ") + lines.append("Themes: \(topTags)") + + if !summary.goodDayTags.isEmpty { + let goodTags = summary.goodDayTags.sorted { $0.value > $1.value }.prefix(5) + .map { "\($0.key)(\($0.value))" }.joined(separator: ", ") + lines.append("Good day themes: \(goodTags)") + } + if !summary.badDayTags.isEmpty { + let badTags = summary.badDayTags.sorted { $0.value > $1.value }.prefix(5) + .map { "\($0.key)(\($0.value))" }.joined(separator: ", ") + lines.append("Bad day themes: \(badTags)") + } + } + + // Weather-mood (only if weather data exists) + if !summary.weatherMoodAverages.isEmpty { + let weatherMood = summary.weatherMoodAverages.sorted { $0.value > $1.value } + .map { "\($0.key) avg \(String(format: "%.1f", $0.value))" }.joined(separator: ", ") + lines.append("Weather-mood: \(weatherMood)") + } + if !summary.tempRangeMoodAverages.isEmpty { + let tempMood = ["Cold", "Mild", "Warm", "Hot"].compactMap { range -> String? in + guard let avg = summary.tempRangeMoodAverages[range] else { return nil } + return "\(range) avg \(String(format: "%.1f", avg))" + }.joined(separator: ", ") + if !tempMood.isEmpty { + lines.append("Temp-mood: \(tempMood)") + } + } + + // Gaps (only if gaps exist) + if summary.loggingGapCount > 0 { + lines.append("Logging gaps: \(summary.loggingGapCount) breaks of 2+ days. Pre-gap avg: \(String(format: "%.1f", summary.preGapMoodAverage))/5, Post-return avg: \(String(format: "%.1f", summary.postGapMoodAverage))/5") + } + + // Sources (only if multiple sources) + if summary.entrySourceBreakdown.count > 1 { + let total = Double(summary.entrySourceBreakdown.values.reduce(0, +)) + let sources = summary.entrySourceBreakdown.sorted { $0.value > $1.value } + .map { "\($0.key) \(Int(Double($0.value) / total * 100))%" }.joined(separator: ", ") + lines.append("Entry sources: \(sources)") + } + return lines.joined(separator: "\n") } } diff --git a/Shared/Views/InsightsView/InsightsViewModel.swift b/Shared/Views/InsightsView/InsightsViewModel.swift index 2ae9a31..5162e14 100644 --- a/Shared/Views/InsightsView/InsightsViewModel.swift +++ b/Shared/Views/InsightsView/InsightsViewModel.swift @@ -57,6 +57,7 @@ class InsightsViewModel: ObservableObject { let service = FoundationModelsInsightService() insightService = service isAIAvailable = service.isAvailable + service.prewarm() } else { insightService = nil isAIAvailable = false @@ -118,12 +119,23 @@ class InsightsViewModel: ObservableObject { let yearEntries = DataController.shared.getData(startDate: yearStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7]) let allTimeEntries = DataController.shared.getData(startDate: allTimeStart, endDate: now, includedDays: [1, 2, 3, 4, 5, 6, 7]) + // Pre-fetch health data once (instead of 3x per period) + var sharedHealthAverages: HealthService.HealthAverages? + if healthService.isEnabled && healthService.isAuthorized { + let allValidEntries = allTimeEntries.filter { ![.missing, .placeholder].contains($0.mood) } + if !allValidEntries.isEmpty { + let healthData = await healthService.fetchHealthData(for: allValidEntries) + sharedHealthAverages = healthService.computeHealthAverages(entries: allValidEntries, healthData: healthData) + } + } + // Generate insights concurrently for all three periods await withTaskGroup(of: Void.self) { group in group.addTask { @MainActor in await self.generatePeriodInsights( entries: monthEntries, periodName: "this month", + healthAverages: sharedHealthAverages, updateState: { self.monthLoadingState = $0 }, updateInsights: { self.monthInsights = $0 } ) @@ -133,6 +145,7 @@ class InsightsViewModel: ObservableObject { await self.generatePeriodInsights( entries: yearEntries, periodName: "this year", + healthAverages: sharedHealthAverages, updateState: { self.yearLoadingState = $0 }, updateInsights: { self.yearInsights = $0 } ) @@ -142,6 +155,7 @@ class InsightsViewModel: ObservableObject { await self.generatePeriodInsights( entries: allTimeEntries, periodName: "all time", + healthAverages: sharedHealthAverages, updateState: { self.allTimeLoadingState = $0 }, updateInsights: { self.allTimeInsights = $0 } ) @@ -152,6 +166,7 @@ class InsightsViewModel: ObservableObject { private func generatePeriodInsights( entries: [MoodEntryModel], periodName: String, + healthAverages: HealthService.HealthAverages?, updateState: @escaping (InsightLoadingState) -> Void, updateInsights: @escaping ([Insight]) -> Void ) async { @@ -184,13 +199,6 @@ class InsightsViewModel: ObservableObject { updateState(.loading) - // Fetch health data if enabled - pass raw averages to AI for correlation analysis - var healthAverages: HealthService.HealthAverages? - if healthService.isEnabled && healthService.isAuthorized { - let healthData = await healthService.fetchHealthData(for: validEntries) - healthAverages = healthService.computeHealthAverages(entries: validEntries, healthData: healthData) - } - if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService { do { let insights = try await service.generateInsights( diff --git a/Shared/Views/InsightsView/ReportsViewModel.swift b/Shared/Views/InsightsView/ReportsViewModel.swift index da7dfc7..1540c8b 100644 --- a/Shared/Views/InsightsView/ReportsViewModel.swift +++ b/Shared/Views/InsightsView/ReportsViewModel.swift @@ -79,6 +79,10 @@ class ReportsViewModel: ObservableObject { let service = FoundationModelsInsightService() insightService = service isAIAvailable = service.isAvailable + service.prewarm() + // Also prewarm the clinical session used for reports + let clinicalSession = LanguageModelSession(instructions: clinicalSystemInstructions) + clinicalSession.prewarm() } else { insightService = nil isAIAvailable = false @@ -205,7 +209,7 @@ class ReportsViewModel: ObservableObject { """ do { - let response = try await session.respond(to: prompt, generating: AIQuickSummaryResponse.self) + let response = try await session.respond(to: prompt, generating: AIQuickSummaryResponse.self, options: GenerationOptions(maximumResponseTokens: 400)) guard !Task.isCancelled else { throw CancellationError() } @@ -251,10 +255,11 @@ class ReportsViewModel: ObservableObject { let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count var completedSections = 0 - // Generate weekly AI summaries — batched at 4 concurrent + // Generate AI summaries — fresh session per call, batched at 4 concurrent if #available(iOS 26, *) { - let batchSize = 4 + let batchSize = 2 + // Weekly summaries — batched at 4 concurrent for batchStart in stride(from: 0, to: weeks.count, by: batchSize) { guard !Task.isCancelled else { throw CancellationError() } @@ -279,46 +284,60 @@ class ReportsViewModel: ObservableObject { } } - // Generate monthly AI summaries — concurrent + // Monthly summaries — batched at 4 concurrent guard !Task.isCancelled else { throw CancellationError() } progressMessage = String(localized: "Generating monthly summaries...") - await withTaskGroup(of: (Int, String?).self) { group in - for (index, monthSummary) in monthlySummaries.enumerated() { - group.addTask { @MainActor in - let summary = await self.generateMonthlySummary(month: monthSummary, allEntries: reportEntries) - return (index, summary) - } - } + for batchStart in stride(from: 0, to: monthlySummaries.count, by: batchSize) { + guard !Task.isCancelled else { throw CancellationError() } - for await (index, summary) in group { - monthlySummaries[index].aiSummary = summary - completedSections += 1 - progressValue = Double(completedSections) / Double(totalSections) - } - } - - // Generate yearly AI summaries — concurrent - guard !Task.isCancelled else { throw CancellationError() } - - if !yearlySummaries.isEmpty { - progressMessage = String(localized: "Generating yearly summaries...") + let batchEnd = min(batchStart + batchSize, monthlySummaries.count) + let batchIndices = batchStart.. String? { let session = LanguageModelSession(instructions: clinicalSystemInstructions) - let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in let day = entry.date.formatted(.dateTime.weekday(.abbreviated)) let mood = entry.mood.widgetDisplayName @@ -358,7 +376,7 @@ class ReportsViewModel: ObservableObject { """ do { - let response = try await session.respond(to: prompt, generating: AIWeeklySummary.self) + let response = try await session.respond(to: prompt, generating: AIWeeklySummary.self, options: GenerationOptions(maximumResponseTokens: 150)) return response.content.summary } catch { return "Summary unavailable" @@ -368,7 +386,6 @@ class ReportsViewModel: ObservableObject { @available(iOS 26, *) private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? { let session = LanguageModelSession(instructions: clinicalSystemInstructions) - let monthEntries = allEntries.filter { calendar.component(.month, from: $0.date) == month.month && calendar.component(.year, from: $0.date) == month.year @@ -387,7 +404,7 @@ class ReportsViewModel: ObservableObject { """ do { - let response = try await session.respond(to: prompt, generating: AIMonthSummary.self) + let response = try await session.respond(to: prompt, generating: AIMonthSummary.self, options: GenerationOptions(maximumResponseTokens: 150)) return response.content.summary } catch { return "Summary unavailable" @@ -397,7 +414,6 @@ class ReportsViewModel: ObservableObject { @available(iOS 26, *) private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? { let session = LanguageModelSession(instructions: clinicalSystemInstructions) - let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year } let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) } @@ -420,7 +436,7 @@ class ReportsViewModel: ObservableObject { """ do { - let response = try await session.respond(to: prompt, generating: AIYearSummary.self) + let response = try await session.respond(to: prompt, generating: AIYearSummary.self, options: GenerationOptions(maximumResponseTokens: 150)) return response.content.summary } catch { return "Summary unavailable"