Add AI-powered mental wellness features: Reflection Companion, Pattern Tags, Weekly Digest
Three new Foundation Models features to deepen user engagement with mental wellness: 1. AI Reflection Companion — personalized feedback after completing guided reflections, referencing the user's actual words with personality-pack-adapted tone 2. Mood Pattern Tags — auto-extracts theme tags (work, family, stress, etc.) from notes and reflections, displayed as colored pills on entries 3. Weekly Emotional Digest — BGTask-scheduled Sunday digest with headline, summary, highlight, and intention; shown as card in Insights tab with notification All features: on-device (zero cost), premium-gated, iOS 26+ with graceful degradation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
Shared/Services/FoundationModelsDigestService.swift
Normal file
155
Shared/Services/FoundationModelsDigestService.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// 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)
|
||||
|
||||
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.
|
||||
"""
|
||||
}
|
||||
}
|
||||
128
Shared/Services/FoundationModelsReflectionService.swift
Normal file
128
Shared/Services/FoundationModelsReflectionService.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// FoundationModelsReflectionService.swift
|
||||
// Reflect
|
||||
//
|
||||
// Generates personalized AI feedback after a user completes a guided reflection.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
|
||||
@available(iOS 26, *)
|
||||
@MainActor
|
||||
class FoundationModelsReflectionService {
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Feedback Generation
|
||||
|
||||
/// Generate personalized feedback based on a completed guided reflection
|
||||
/// - Parameters:
|
||||
/// - reflection: The completed guided reflection with Q&A responses
|
||||
/// - mood: The mood associated with this entry
|
||||
/// - Returns: AI-generated reflection feedback
|
||||
func generateFeedback(
|
||||
for reflection: GuidedReflection,
|
||||
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
|
||||
)
|
||||
|
||||
return response.content
|
||||
}
|
||||
|
||||
// MARK: - System Instructions
|
||||
|
||||
private var systemInstructions: String {
|
||||
let personalityPack = UserDefaultsStore.personalityPackable()
|
||||
|
||||
switch personalityPack {
|
||||
case .Default:
|
||||
return defaultInstructions
|
||||
case .MotivationalCoach:
|
||||
return coachInstructions
|
||||
case .ZenMaster:
|
||||
return zenInstructions
|
||||
case .BestFriend:
|
||||
return bestFriendInstructions
|
||||
case .DataAnalyst:
|
||||
return analystInstructions
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultInstructions: String {
|
||||
"""
|
||||
You are a warm, supportive companion responding to someone who just completed a guided mood reflection. \
|
||||
Validate their effort, reflect their own words back to them, and offer a gentle takeaway. \
|
||||
Be specific — reference what they actually wrote. Keep each field to 1 sentence. \
|
||||
SF Symbols: sparkles, heart.fill, star.fill, sun.max.fill, leaf.fill
|
||||
"""
|
||||
}
|
||||
|
||||
private var coachInstructions: String {
|
||||
"""
|
||||
You are a HIGH ENERGY motivational coach responding to someone who just completed a guided mood reflection! \
|
||||
Celebrate their self-awareness, pump them up about the growth they showed, and give them a power move for tomorrow. \
|
||||
Reference what they actually wrote. Keep each field to 1 sentence. Use exclamations! \
|
||||
SF Symbols: trophy.fill, flame.fill, bolt.fill, star.fill, figure.run
|
||||
"""
|
||||
}
|
||||
|
||||
private var zenInstructions: String {
|
||||
"""
|
||||
You are a calm, mindful guide responding to someone who just completed a guided mood reflection. \
|
||||
Acknowledge their practice of self-awareness with gentle wisdom. Use nature metaphors. \
|
||||
Reference what they actually wrote. Keep each field to 1 sentence. Speak with serene clarity. \
|
||||
SF Symbols: leaf.fill, moon.fill, drop.fill, sunrise.fill, wind
|
||||
"""
|
||||
}
|
||||
|
||||
private var bestFriendInstructions: String {
|
||||
"""
|
||||
You are their supportive best friend responding after they completed a guided mood reflection. \
|
||||
Be warm, casual, and validating. Use conversational tone. \
|
||||
Reference what they actually wrote. Keep each field to 1 sentence. \
|
||||
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, star.fill, face.smiling.fill
|
||||
"""
|
||||
}
|
||||
|
||||
private var analystInstructions: String {
|
||||
"""
|
||||
You are a clinical data analyst providing feedback on a completed mood reflection. \
|
||||
Note the cognitive patterns observed, the technique application quality, and a data-informed recommendation. \
|
||||
Reference what they actually wrote. Keep each field to 1 sentence. Be objective but encouraging. \
|
||||
SF Symbols: chart.bar.fill, brain.head.profile, doc.text.magnifyingglass, chart.line.uptrend.xyaxis
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Prompt Construction
|
||||
|
||||
private func buildPrompt(from reflection: GuidedReflection, mood: Mood) -> String {
|
||||
let moodName = mood.widgetDisplayName
|
||||
let technique = reflection.moodCategory.techniqueName
|
||||
|
||||
let qaPairs = reflection.responses
|
||||
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.map { response in
|
||||
let chips = response.selectedChips.isEmpty ? "" : " [themes: \(response.selectedChips.joined(separator: ", "))]"
|
||||
return "Q: \(response.question)\nA: \(response.answer)\(chips)"
|
||||
}
|
||||
.joined(separator: "\n\n")
|
||||
|
||||
return """
|
||||
The user logged their mood as "\(moodName)" and completed a \(technique) reflection:
|
||||
|
||||
\(qaPairs)
|
||||
|
||||
Respond with personalized feedback that references their specific answers.
|
||||
"""
|
||||
}
|
||||
}
|
||||
99
Shared/Services/FoundationModelsTagService.swift
Normal file
99
Shared/Services/FoundationModelsTagService.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// FoundationModelsTagService.swift
|
||||
// Reflect
|
||||
//
|
||||
// Extracts theme tags from mood entry notes and guided reflections using Foundation Models.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
|
||||
@available(iOS 26, *)
|
||||
@MainActor
|
||||
class FoundationModelsTagService {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = FoundationModelsTagService()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Tag Extraction
|
||||
|
||||
/// Extract theme tags from an entry's note and/or reflection content
|
||||
/// - Parameters:
|
||||
/// - entry: The mood entry to extract tags from
|
||||
/// - Returns: Array of tag label strings, or nil if extraction fails
|
||||
func extractTags(for entry: MoodEntryModel) async -> [String]? {
|
||||
// Need at least some text content to extract from
|
||||
let noteText = entry.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let reflectionText = extractReflectionText(from: entry)
|
||||
|
||||
guard !noteText.isEmpty || !reflectionText.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let session = LanguageModelSession(instructions: systemInstructions)
|
||||
let prompt = buildPrompt(noteText: noteText, reflectionText: reflectionText, mood: entry.mood)
|
||||
|
||||
do {
|
||||
let response = try await session.respond(to: prompt, generating: AIEntryTags.self)
|
||||
return response.content.tags.map { $0.label.lowercased() }
|
||||
} catch {
|
||||
print("Tag extraction failed: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract tags and save them to the entry via DataController
|
||||
func extractAndSaveTags(for entry: MoodEntryModel) async {
|
||||
guard let tags = await extractTags(for: entry), !tags.isEmpty else { return }
|
||||
|
||||
if let data = try? JSONEncoder().encode(tags),
|
||||
let json = String(data: data, encoding: .utf8) {
|
||||
DataController.shared.updateTags(forDate: entry.forDate, tagsJSON: json)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System Instructions
|
||||
|
||||
private var systemInstructions: String {
|
||||
"""
|
||||
You are a theme extractor for a mood journal. Extract 1-4 theme tags from the user's journal text. \
|
||||
Only use tags from this list: work, family, social, health, sleep, exercise, stress, gratitude, \
|
||||
growth, creative, nature, self-care, finances, relationships, loneliness, motivation. \
|
||||
Only extract tags clearly present in the text. Do not guess or infer themes not mentioned.
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Prompt Construction
|
||||
|
||||
private func buildPrompt(noteText: String, reflectionText: String, mood: Mood) -> String {
|
||||
var content = "Mood: \(mood.widgetDisplayName)\n"
|
||||
|
||||
if !noteText.isEmpty {
|
||||
content += "\nJournal note:\n\(String(noteText.prefix(500)))\n"
|
||||
}
|
||||
|
||||
if !reflectionText.isEmpty {
|
||||
content += "\nReflection responses:\n\(String(reflectionText.prefix(800)))\n"
|
||||
}
|
||||
|
||||
content += "\nExtract theme tags from the text above."
|
||||
return content
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func extractReflectionText(from entry: MoodEntryModel) -> String {
|
||||
guard let json = entry.reflectionJSON,
|
||||
let reflection = GuidedReflection.decode(from: json) else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return reflection.responses
|
||||
.filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.map { "Q: \($0.question)\nA: \($0.answer)" }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user