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:
@@ -47,7 +47,7 @@ class FoundationModelsDigestService {
|
|||||||
let session = LanguageModelSession(instructions: systemInstructions)
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
let prompt = buildPrompt(entries: validEntries, weekStart: weekStart, weekEnd: now)
|
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(
|
let digest = WeeklyDigest(
|
||||||
headline: response.content.headline,
|
headline: response.content.headline,
|
||||||
@@ -150,6 +150,7 @@ class FoundationModelsDigestService {
|
|||||||
Current streak: \(summary.currentLoggingStreak) days
|
Current streak: \(summary.currentLoggingStreak) days
|
||||||
|
|
||||||
Write a warm, personalized weekly digest.
|
Write a warm, personalized weekly digest.
|
||||||
|
Keep summary to 2 sentences. Keep highlight and intention to 1 sentence each.
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
private func createSession() -> LanguageModelSession {
|
||||||
LanguageModelSession(instructions: systemInstructions)
|
LanguageModelSession(instructions: systemInstructions)
|
||||||
}
|
}
|
||||||
@@ -213,8 +219,7 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available")
|
throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new session for this request to allow concurrent generation
|
let activeSession = createSession()
|
||||||
let session = createSession()
|
|
||||||
|
|
||||||
// Filter valid entries
|
// Filter valid entries
|
||||||
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
@@ -231,9 +236,10 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
let prompt = buildPrompt(from: summary, count: count)
|
let prompt = buildPrompt(from: summary, count: count)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await session.respond(
|
let response = try await activeSession.respond(
|
||||||
to: prompt,
|
to: prompt,
|
||||||
generating: AIInsightsResponse.self
|
generating: AIInsightsResponse.self,
|
||||||
|
options: GenerationOptions(maximumResponseTokens: 600)
|
||||||
)
|
)
|
||||||
|
|
||||||
let insights = response.content.insights.map { $0.toInsight() }
|
let insights = response.content.insights.map { $0.toInsight() }
|
||||||
@@ -263,7 +269,7 @@ class FoundationModelsInsightService: ObservableObject {
|
|||||||
|
|
||||||
\(dataSection)
|
\(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.
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,12 @@ class FoundationModelsReflectionService {
|
|||||||
mood: Mood
|
mood: Mood
|
||||||
) async throws -> AIReflectionFeedback {
|
) async throws -> AIReflectionFeedback {
|
||||||
let session = LanguageModelSession(instructions: systemInstructions)
|
let session = LanguageModelSession(instructions: systemInstructions)
|
||||||
|
|
||||||
let prompt = buildPrompt(from: reflection, mood: mood)
|
let prompt = buildPrompt(from: reflection, mood: mood)
|
||||||
|
|
||||||
let response = try await session.respond(
|
let response = try await session.respond(
|
||||||
to: prompt,
|
to: prompt,
|
||||||
generating: AIReflectionFeedback.self
|
generating: AIReflectionFeedback.self,
|
||||||
|
options: GenerationOptions(maximumResponseTokens: 200)
|
||||||
)
|
)
|
||||||
|
|
||||||
return response.content
|
return response.content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class FoundationModelsTagService {
|
|||||||
let prompt = buildPrompt(noteText: noteText, reflectionText: reflectionText, mood: entry.mood)
|
let prompt = buildPrompt(noteText: noteText, reflectionText: reflectionText, mood: entry.mood)
|
||||||
|
|
||||||
do {
|
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() }
|
return response.content.tags.map { $0.label.lowercased() }
|
||||||
} catch {
|
} catch {
|
||||||
print("Tag extraction failed: \(error.localizedDescription)")
|
print("Tag extraction failed: \(error.localizedDescription)")
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ struct MoodDataSummary {
|
|||||||
|
|
||||||
// Health data for AI analysis (optional)
|
// Health data for AI analysis (optional)
|
||||||
let healthAverages: HealthService.HealthAverages?
|
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
|
/// Transforms raw MoodEntryModel data into AI-optimized summaries
|
||||||
@@ -83,6 +100,11 @@ class MoodDataSummarizer {
|
|||||||
// Format date range
|
// Format date range
|
||||||
let dateRange = formatDateRange(entries: sortedEntries)
|
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(
|
return MoodDataSummary(
|
||||||
periodName: periodName,
|
periodName: periodName,
|
||||||
totalEntries: validEntries.count,
|
totalEntries: validEntries.count,
|
||||||
@@ -107,7 +129,16 @@ class MoodDataSummarizer {
|
|||||||
last7DaysMoods: recentContext.moods,
|
last7DaysMoods: recentContext.moods,
|
||||||
hasAllMoodTypes: moodTypes.hasAll,
|
hasAllMoodTypes: moodTypes.hasAll,
|
||||||
missingMoodTypes: moodTypes.missing,
|
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)
|
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..<entries.count {
|
||||||
|
let dayDiff = calendar.dateComponents([.day], from: entries[i-1].forDate, to: entries[i].forDate).day ?? 0
|
||||||
|
guard dayDiff >= 2 else { continue }
|
||||||
|
|
||||||
|
gapCount += 1
|
||||||
|
|
||||||
|
// Collect up to 3 entries before the gap
|
||||||
|
let preStart = max(0, i - 3)
|
||||||
|
for j in preStart..<i {
|
||||||
|
preGapScores.append(Int(entries[j].moodValue) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect up to 3 entries after the gap
|
||||||
|
let postEnd = min(entries.count, i + 3)
|
||||||
|
for j in i..<postEnd {
|
||||||
|
postGapScores.append(Int(entries[j].moodValue) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let preAvg = preGapScores.isEmpty ? 0.0 : Double(preGapScores.reduce(0, +)) / Double(preGapScores.count)
|
||||||
|
let postAvg = postGapScores.isEmpty ? 0.0 : Double(postGapScores.reduce(0, +)) / Double(postGapScores.count)
|
||||||
|
|
||||||
|
return (gapCount, preAvg, postAvg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry Source Breakdown
|
||||||
|
|
||||||
|
private func calculateEntrySourceBreakdown(entries: [MoodEntryModel]) -> [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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func formatDateRange(entries: [MoodEntryModel]) -> String {
|
private func formatDateRange(entries: [MoodEntryModel]) -> String {
|
||||||
@@ -384,7 +548,16 @@ class MoodDataSummarizer {
|
|||||||
last7DaysMoods: [],
|
last7DaysMoods: [],
|
||||||
hasAllMoodTypes: false,
|
hasAllMoodTypes: false,
|
||||||
missingMoodTypes: ["great", "good", "average", "bad", "horrible"],
|
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.")
|
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")
|
return lines.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class InsightsViewModel: ObservableObject {
|
|||||||
let service = FoundationModelsInsightService()
|
let service = FoundationModelsInsightService()
|
||||||
insightService = service
|
insightService = service
|
||||||
isAIAvailable = service.isAvailable
|
isAIAvailable = service.isAvailable
|
||||||
|
service.prewarm()
|
||||||
} else {
|
} else {
|
||||||
insightService = nil
|
insightService = nil
|
||||||
isAIAvailable = false
|
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 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])
|
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
|
// Generate insights concurrently for all three periods
|
||||||
await withTaskGroup(of: Void.self) { group in
|
await withTaskGroup(of: Void.self) { group in
|
||||||
group.addTask { @MainActor in
|
group.addTask { @MainActor in
|
||||||
await self.generatePeriodInsights(
|
await self.generatePeriodInsights(
|
||||||
entries: monthEntries,
|
entries: monthEntries,
|
||||||
periodName: "this month",
|
periodName: "this month",
|
||||||
|
healthAverages: sharedHealthAverages,
|
||||||
updateState: { self.monthLoadingState = $0 },
|
updateState: { self.monthLoadingState = $0 },
|
||||||
updateInsights: { self.monthInsights = $0 }
|
updateInsights: { self.monthInsights = $0 }
|
||||||
)
|
)
|
||||||
@@ -133,6 +145,7 @@ class InsightsViewModel: ObservableObject {
|
|||||||
await self.generatePeriodInsights(
|
await self.generatePeriodInsights(
|
||||||
entries: yearEntries,
|
entries: yearEntries,
|
||||||
periodName: "this year",
|
periodName: "this year",
|
||||||
|
healthAverages: sharedHealthAverages,
|
||||||
updateState: { self.yearLoadingState = $0 },
|
updateState: { self.yearLoadingState = $0 },
|
||||||
updateInsights: { self.yearInsights = $0 }
|
updateInsights: { self.yearInsights = $0 }
|
||||||
)
|
)
|
||||||
@@ -142,6 +155,7 @@ class InsightsViewModel: ObservableObject {
|
|||||||
await self.generatePeriodInsights(
|
await self.generatePeriodInsights(
|
||||||
entries: allTimeEntries,
|
entries: allTimeEntries,
|
||||||
periodName: "all time",
|
periodName: "all time",
|
||||||
|
healthAverages: sharedHealthAverages,
|
||||||
updateState: { self.allTimeLoadingState = $0 },
|
updateState: { self.allTimeLoadingState = $0 },
|
||||||
updateInsights: { self.allTimeInsights = $0 }
|
updateInsights: { self.allTimeInsights = $0 }
|
||||||
)
|
)
|
||||||
@@ -152,6 +166,7 @@ class InsightsViewModel: ObservableObject {
|
|||||||
private func generatePeriodInsights(
|
private func generatePeriodInsights(
|
||||||
entries: [MoodEntryModel],
|
entries: [MoodEntryModel],
|
||||||
periodName: String,
|
periodName: String,
|
||||||
|
healthAverages: HealthService.HealthAverages?,
|
||||||
updateState: @escaping (InsightLoadingState) -> Void,
|
updateState: @escaping (InsightLoadingState) -> Void,
|
||||||
updateInsights: @escaping ([Insight]) -> Void
|
updateInsights: @escaping ([Insight]) -> Void
|
||||||
) async {
|
) async {
|
||||||
@@ -184,13 +199,6 @@ class InsightsViewModel: ObservableObject {
|
|||||||
|
|
||||||
updateState(.loading)
|
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 {
|
if #available(iOS 26, *), let service = insightService as? FoundationModelsInsightService {
|
||||||
do {
|
do {
|
||||||
let insights = try await service.generateInsights(
|
let insights = try await service.generateInsights(
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ class ReportsViewModel: ObservableObject {
|
|||||||
let service = FoundationModelsInsightService()
|
let service = FoundationModelsInsightService()
|
||||||
insightService = service
|
insightService = service
|
||||||
isAIAvailable = service.isAvailable
|
isAIAvailable = service.isAvailable
|
||||||
|
service.prewarm()
|
||||||
|
// Also prewarm the clinical session used for reports
|
||||||
|
let clinicalSession = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
clinicalSession.prewarm()
|
||||||
} else {
|
} else {
|
||||||
insightService = nil
|
insightService = nil
|
||||||
isAIAvailable = false
|
isAIAvailable = false
|
||||||
@@ -205,7 +209,7 @@ class ReportsViewModel: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
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() }
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
|
|
||||||
@@ -251,10 +255,11 @@ class ReportsViewModel: ObservableObject {
|
|||||||
let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count
|
let totalSections = weeks.count + monthlySummaries.count + yearlySummaries.count
|
||||||
var completedSections = 0
|
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, *) {
|
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) {
|
for batchStart in stride(from: 0, to: weeks.count, by: batchSize) {
|
||||||
guard !Task.isCancelled else { throw CancellationError() }
|
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() }
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
progressMessage = String(localized: "Generating monthly summaries...")
|
progressMessage = String(localized: "Generating monthly summaries...")
|
||||||
|
|
||||||
await withTaskGroup(of: (Int, String?).self) { group in
|
for batchStart in stride(from: 0, to: monthlySummaries.count, by: batchSize) {
|
||||||
for (index, monthSummary) in monthlySummaries.enumerated() {
|
guard !Task.isCancelled else { throw CancellationError() }
|
||||||
group.addTask { @MainActor in
|
|
||||||
let summary = await self.generateMonthlySummary(month: monthSummary, allEntries: reportEntries)
|
|
||||||
return (index, summary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (index, summary) in group {
|
let batchEnd = min(batchStart + batchSize, monthlySummaries.count)
|
||||||
monthlySummaries[index].aiSummary = summary
|
let batchIndices = batchStart..<batchEnd
|
||||||
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...")
|
|
||||||
|
|
||||||
await withTaskGroup(of: (Int, String?).self) { group in
|
await withTaskGroup(of: (Int, String?).self) { group in
|
||||||
for (index, yearSummary) in yearlySummaries.enumerated() {
|
for index in batchIndices {
|
||||||
group.addTask { @MainActor in
|
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)
|
return (index, summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (index, summary) in group {
|
for await (index, summary) in group {
|
||||||
yearlySummaries[index].aiSummary = summary
|
monthlySummaries[index].aiSummary = summary
|
||||||
completedSections += 1
|
completedSections += 1
|
||||||
progressValue = Double(completedSections) / Double(totalSections)
|
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(
|
return MoodReport(
|
||||||
@@ -337,7 +356,6 @@ class ReportsViewModel: ObservableObject {
|
|||||||
@available(iOS 26, *)
|
@available(iOS 26, *)
|
||||||
private func generateWeeklySummary(week: ReportWeek) async -> String? {
|
private func generateWeeklySummary(week: ReportWeek) async -> String? {
|
||||||
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in
|
let moodList = week.entries.sorted(by: { $0.date < $1.date }).map { entry in
|
||||||
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
|
let day = entry.date.formatted(.dateTime.weekday(.abbreviated))
|
||||||
let mood = entry.mood.widgetDisplayName
|
let mood = entry.mood.widgetDisplayName
|
||||||
@@ -358,7 +376,7 @@ class ReportsViewModel: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
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
|
return response.content.summary
|
||||||
} catch {
|
} catch {
|
||||||
return "Summary unavailable"
|
return "Summary unavailable"
|
||||||
@@ -368,7 +386,6 @@ class ReportsViewModel: ObservableObject {
|
|||||||
@available(iOS 26, *)
|
@available(iOS 26, *)
|
||||||
private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? {
|
private func generateMonthlySummary(month: ReportMonthSummary, allEntries: [ReportEntry]) async -> String? {
|
||||||
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
let monthEntries = allEntries.filter {
|
let monthEntries = allEntries.filter {
|
||||||
calendar.component(.month, from: $0.date) == month.month &&
|
calendar.component(.month, from: $0.date) == month.month &&
|
||||||
calendar.component(.year, from: $0.date) == month.year
|
calendar.component(.year, from: $0.date) == month.year
|
||||||
@@ -387,7 +404,7 @@ class ReportsViewModel: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
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
|
return response.content.summary
|
||||||
} catch {
|
} catch {
|
||||||
return "Summary unavailable"
|
return "Summary unavailable"
|
||||||
@@ -397,7 +414,6 @@ class ReportsViewModel: ObservableObject {
|
|||||||
@available(iOS 26, *)
|
@available(iOS 26, *)
|
||||||
private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? {
|
private func generateYearlySummary(year: ReportYearSummary, allEntries: [ReportEntry]) async -> String? {
|
||||||
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
let session = LanguageModelSession(instructions: clinicalSystemInstructions)
|
||||||
|
|
||||||
let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year }
|
let yearEntries = allEntries.filter { calendar.component(.year, from: $0.date) == year.year }
|
||||||
|
|
||||||
let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) }
|
let monthlyAvgs = Dictionary(grouping: yearEntries) { calendar.component(.month, from: $0.date) }
|
||||||
@@ -420,7 +436,7 @@ class ReportsViewModel: ObservableObject {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
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
|
return response.content.summary
|
||||||
} catch {
|
} catch {
|
||||||
return "Summary unavailable"
|
return "Summary unavailable"
|
||||||
|
|||||||
Reference in New Issue
Block a user