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>
157 lines
5.8 KiB
Swift
157 lines
5.8 KiB
Swift
//
|
|
// FoundationModelsDigestService.swift
|
|
// Reflect
|
|
//
|
|
// Generates weekly emotional digests using Foundation Models.
|
|
//
|
|
|
|
import Foundation
|
|
import FoundationModels
|
|
|
|
@available(iOS 26, *)
|
|
@MainActor
|
|
class FoundationModelsDigestService {
|
|
|
|
// MARK: - Singleton
|
|
|
|
static let shared = FoundationModelsDigestService()
|
|
|
|
private let summarizer = MoodDataSummarizer()
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Storage Keys
|
|
|
|
private static let digestStorageKey = "latestWeeklyDigest"
|
|
|
|
// MARK: - Digest Generation
|
|
|
|
/// Generate a weekly digest from the past 7 days of mood data
|
|
func generateWeeklyDigest() async throws -> WeeklyDigest {
|
|
let calendar = Calendar.current
|
|
let now = Date()
|
|
let weekStart = calendar.date(byAdding: .day, value: -7, to: now)!
|
|
|
|
let entries = DataController.shared.getData(
|
|
startDate: weekStart,
|
|
endDate: now,
|
|
includedDays: [1, 2, 3, 4, 5, 6, 7]
|
|
)
|
|
|
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
|
|
|
guard validEntries.count >= 3 else {
|
|
throw InsightGenerationError.insufficientData
|
|
}
|
|
|
|
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, options: GenerationOptions(maximumResponseTokens: 300))
|
|
|
|
let digest = WeeklyDigest(
|
|
headline: response.content.headline,
|
|
summary: response.content.summary,
|
|
highlight: response.content.highlight,
|
|
intention: response.content.intention,
|
|
iconName: response.content.iconName,
|
|
generatedAt: Date(),
|
|
weekStartDate: weekStart,
|
|
weekEndDate: now
|
|
)
|
|
|
|
// Store the digest
|
|
saveDigest(digest)
|
|
|
|
return digest
|
|
}
|
|
|
|
/// Load the latest stored digest
|
|
func loadLatestDigest() -> WeeklyDigest? {
|
|
guard let data = GroupUserDefaults.groupDefaults.data(forKey: Self.digestStorageKey),
|
|
let digest = try? JSONDecoder().decode(WeeklyDigest.self, from: data) else {
|
|
return nil
|
|
}
|
|
return digest
|
|
}
|
|
|
|
// MARK: - Storage
|
|
|
|
private func saveDigest(_ digest: WeeklyDigest) {
|
|
if let data = try? JSONEncoder().encode(digest) {
|
|
GroupUserDefaults.groupDefaults.set(data, forKey: Self.digestStorageKey)
|
|
}
|
|
}
|
|
|
|
// MARK: - System Instructions
|
|
|
|
private var systemInstructions: String {
|
|
let personalityPack = UserDefaultsStore.personalityPackable()
|
|
|
|
switch personalityPack {
|
|
case .Default:
|
|
return """
|
|
You are a warm, supportive mood companion writing a weekly emotional digest. \
|
|
Summarize the week's mood journey with encouragement and specificity. \
|
|
Be personal, brief, and uplifting. Reference specific patterns from the data. \
|
|
SF Symbols: sun.max.fill, heart.fill, star.fill, leaf.fill, sparkles
|
|
"""
|
|
case .MotivationalCoach:
|
|
return """
|
|
You are a HIGH ENERGY motivational coach delivering a weekly performance review! \
|
|
Celebrate wins, frame challenges as growth opportunities, and fire them up for next week! \
|
|
Use exclamations and power language! \
|
|
SF Symbols: trophy.fill, flame.fill, bolt.fill, figure.run, star.fill
|
|
"""
|
|
case .ZenMaster:
|
|
return """
|
|
You are a calm Zen master offering a weekly reflection on the emotional journey. \
|
|
Use nature metaphors, gentle wisdom, and serene observations. Find meaning in all moods. \
|
|
SF Symbols: leaf.fill, moon.fill, drop.fill, sunrise.fill, wind
|
|
"""
|
|
case .BestFriend:
|
|
return """
|
|
You are their best friend doing a weekly check-in on how they've been. \
|
|
Be warm, casual, validating, and conversational. Celebrate with them, commiserate together. \
|
|
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, face.smiling.fill, balloon.fill
|
|
"""
|
|
case .DataAnalyst:
|
|
return """
|
|
You are a clinical data analyst delivering a weekly mood metrics report. \
|
|
Reference exact numbers, percentages, and observed trends. Be objective but constructive. \
|
|
SF Symbols: chart.bar.fill, chart.line.uptrend.xyaxis, number, percent, doc.text.magnifyingglass
|
|
"""
|
|
}
|
|
}
|
|
|
|
// MARK: - Prompt Construction
|
|
|
|
private func buildPrompt(entries: [MoodEntryModel], weekStart: Date, weekEnd: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
|
|
let moodList = entries.sorted { $0.forDate < $1.forDate }.map { entry in
|
|
let day = entry.forDate.formatted(.dateTime.weekday(.abbreviated))
|
|
let mood = entry.mood.widgetDisplayName
|
|
let hasNotes = entry.notes != nil && !entry.notes!.isEmpty
|
|
let noteSnippet = hasNotes ? " (\(String(entry.notes!.prefix(50))))" : ""
|
|
return "\(day): \(mood)\(noteSnippet)"
|
|
}.joined(separator: "\n")
|
|
|
|
let summary = summarizer.summarize(entries: entries, periodName: "this week")
|
|
let avgMood = String(format: "%.1f", summary.averageMoodScore)
|
|
|
|
return """
|
|
Generate a weekly emotional digest for \(formatter.string(from: weekStart)) - \(formatter.string(from: weekEnd)):
|
|
|
|
\(moodList)
|
|
|
|
Average mood: \(avgMood)/5, Trend: \(summary.recentTrend), Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%
|
|
Current streak: \(summary.currentLoggingStreak) days
|
|
|
|
Write a warm, personalized weekly digest.
|
|
Keep summary to 2 sentences. Keep highlight and intention to 1 sentence each.
|
|
"""
|
|
}
|
|
}
|