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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-04 11:52:14 -05:00
parent 329fb7c671
commit 70400b7790
7 changed files with 302 additions and 53 deletions

View File

@@ -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(

View File

@@ -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..<batchEnd
await withTaskGroup(of: (Int, String?).self) { group in
for (index, yearSummary) in yearlySummaries.enumerated() {
for index in batchIndices {
group.addTask { @MainActor in
let summary = await self.generateYearlySummary(year: yearSummary, allEntries: reportEntries)
let summary = await self.generateMonthlySummary(month: monthlySummaries[index], allEntries: reportEntries)
return (index, summary)
}
}
for await (index, summary) in group {
yearlySummaries[index].aiSummary = summary
monthlySummaries[index].aiSummary = summary
completedSections += 1
progressValue = Double(completedSections) / Double(totalSections)
}
}
}
// Yearly summaries batched at 4 concurrent
guard !Task.isCancelled else { throw CancellationError() }
if !yearlySummaries.isEmpty {
progressMessage = String(localized: "Generating yearly summaries...")
for batchStart in stride(from: 0, to: yearlySummaries.count, by: batchSize) {
guard !Task.isCancelled else { throw CancellationError() }
let batchEnd = min(batchStart + batchSize, yearlySummaries.count)
let batchIndices = batchStart..<batchEnd
await withTaskGroup(of: (Int, String?).self) { group in
for index in batchIndices {
group.addTask { @MainActor in
let summary = await self.generateYearlySummary(year: yearlySummaries[index], allEntries: reportEntries)
return (index, summary)
}
}
for await (index, summary) in group {
yearlySummaries[index].aiSummary = summary
completedSections += 1
progressValue = Double(completedSections) / Double(totalSections)
}
}
}
}
}
return MoodReport(
@@ -337,7 +356,6 @@ class ReportsViewModel: ObservableObject {
@available(iOS 26, *)
private func generateWeeklySummary(week: ReportWeek) async -> 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"