From ea412d6fe933a7a56262bce515be08516853fb56 Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Sun, 26 Apr 2026 14:37:37 +1000 Subject: [PATCH] feat: implement background LLM pattern analysis on Insights screen Adds a prompt builder that serialises 7-day mood/activity data, wires InsightNotifier to call the on-device LLM directly (no user interaction), caches results by prompt hash, and renders the analysis as a passive card. Includes loading, empty (<2 entries), and error states with a force-refresh button. --- lib/core/database/daos/mood_dao.dart | 16 ++ lib/core/llm/prompt_builder.dart | 56 ++++++ lib/core/providers/insight_provider.dart | 106 +++++++----- lib/features/insights/insights_screen.dart | 192 ++++++++++++++++++++- 4 files changed, 322 insertions(+), 48 deletions(-) create mode 100644 lib/core/llm/prompt_builder.dart diff --git a/lib/core/database/daos/mood_dao.dart b/lib/core/database/daos/mood_dao.dart index 540e64c..509ada3 100644 --- a/lib/core/database/daos/mood_dao.dart +++ b/lib/core/database/daos/mood_dao.dart @@ -84,6 +84,22 @@ class MoodDao extends DatabaseAccessor with _$MoodDaoMixin { .getSingleOrNull()) != null; } + // Entries with their linked activities — used by LLM prompt builder + Future> getEntriesWithActivitiesInRange( + DateTime start, DateTime end) async { + final entries = await getEntriesInRange(start, end); + return Future.wait(entries.map((e) async { + final links = await (select(entryActivities) + ..where((t) => t.entryId.equals(e.id))) + .get(); + final actIds = links.map((l) => l.activityId).toList(); + final acts = actIds.isEmpty + ? [] + : await (select(activities)..where((t) => t.id.isIn(actIds))).get(); + return MoodEntryWithActivities(entry: e, activities: acts, tags: []); + })); + } + // Average mood per day — used by calendar heatmap Future> getDailyAverages( DateTime start, DateTime end) async { diff --git a/lib/core/llm/prompt_builder.dart b/lib/core/llm/prompt_builder.dart new file mode 100644 index 0000000..a882c7a --- /dev/null +++ b/lib/core/llm/prompt_builder.dart @@ -0,0 +1,56 @@ +import '../database/daos/mood_dao.dart'; + +abstract class PromptBuilder { + static String buildWeeklyPrompt(List entries) { + final lines = entries.map(_entryLine).join('\n'); + return ''' +You are a personal wellbeing assistant analysing a private mood journal. +Analyse the following data and identify 2–3 patterns or correlations across mood, sleep, energy, positivity, and self-worth. +End with one specific, actionable suggestion. Be concise (4–6 sentences total). Do not greet the user. + +$lines'''; + } + + static String _entryLine(MoodEntryWithActivities e) { + final dt = e.entry.timestamp; + final date = + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + final time = + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + + final parts = [ + '$date $time', + 'Mood ${e.entry.moodLevel}/5', + ]; + + if (e.entry.sleepMinutes != null) { + final h = e.entry.sleepMinutes! ~/ 60; + final m = (e.entry.sleepMinutes! % 60).toString().padLeft(2, '0'); + parts.add('Sleep ${h}h$m'); + } + if (e.entry.energyLevel != null) { + parts.add('Energy ${_scale(e.entry.energyLevel!, _energyLabels)}'); + } + if (e.entry.positivityLevel != null) { + parts.add('Positivity ${_scale(e.entry.positivityLevel!, _positivityLabels)}'); + } + if (e.entry.selfWorthLevel != null) { + parts.add('Self-worth ${_scale(e.entry.selfWorthLevel!, _selfWorthLabels)}'); + } + if (e.activities.isNotEmpty) { + parts.add('Activities: ${e.activities.map((a) => a.name).join(', ')}'); + } + if (e.entry.note != null && e.entry.note!.isNotEmpty) { + parts.add('Note: ${e.entry.note}'); + } + + return parts.join(' | '); + } + + static String _scale(int level, List labels) => + labels[(level - 1).clamp(0, labels.length - 1)]; + + static const _energyLabels = ['very low', 'low', 'ok', 'high', 'very high']; + static const _positivityLabels = ['very negative', 'negative', 'neutral', 'positive', 'very positive']; + static const _selfWorthLabels = ['very low', 'low', 'moderate', 'high', 'very high']; +} diff --git a/lib/core/providers/insight_provider.dart b/lib/core/providers/insight_provider.dart index 17e57b3..80361ca 100644 --- a/lib/core/providers/insight_provider.dart +++ b/lib/core/providers/insight_provider.dart @@ -3,71 +3,87 @@ import 'package:crypto/crypto.dart'; import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../database/app_database.dart'; +import '../llm/prompt_builder.dart'; import 'database_provider.dart'; +import 'llm_provider.dart'; // ── Insight type enum ───────────────────────────────────────────────────────── enum InsightType { weekly, monthly, query } -// ── Cached insight lookup ───────────────────────────────────────────────────── - -final cachedInsightProvider = - FutureProvider.autoDispose.family((ref, promptHash) { - return ref.watch(insightDaoProvider).getCached(promptHash); -}); - -// ── Insight notifier — triggers generation and caches results ───────────────── +// ── Insight notifier — fetches data, builds prompt, calls LLM, caches result ── class InsightNotifier extends Notifier> { @override AsyncValue build() => const AsyncData(null); - String _hashPrompt(String prompt) => - sha256.convert(utf8.encode(prompt)).toString(); - Future generateInsight({ - required String prompt, - required InsightType type, - DateTime? rangeStart, - DateTime? rangeEnd, + InsightType type = InsightType.weekly, + bool forceRefresh = false, }) async { state = const AsyncLoading(); - final hash = _hashPrompt(prompt); - final dao = ref.read(insightDaoProvider); + try { + // Ensure model is initialised before generating + await ref.read(llmReadyProvider.future); + if (!ref.mounted) return; - final cached = await dao.getCached(hash); - if (!ref.mounted) return; - if (cached != null) { - state = AsyncData(cached.responseText); - return; + final now = DateTime.now(); + final end = DateTime(now.year, now.month, now.day, 23, 59, 59); + final start = end.subtract(const Duration(days: 6)); + + final dao = ref.read(moodDaoProvider); + final entries = await dao.getEntriesWithActivitiesInRange(start, end); + if (!ref.mounted) return; + + if (entries.length < 2) { + state = const AsyncData(null); + return; + } + + final prompt = PromptBuilder.buildWeeklyPrompt(entries); + final hash = _hashPrompt(prompt); + + if (!forceRefresh) { + final cached = await ref.read(insightDaoProvider).getCached(hash); + if (!ref.mounted) return; + if (cached != null) { + state = AsyncData(cached.responseText); + return; + } + } + + final llm = ref.read(llmServiceProvider); + final buffer = StringBuffer(); + await for (final token in llm.generateStream(prompt)) { + if (!ref.mounted) return; + buffer.write(token); + state = AsyncData(buffer.toString()); + } + + if (!ref.mounted) return; + final result = buffer.toString().trim(); + await ref.read(insightDaoProvider).upsertInsight( + LlmInsightsCompanion.insert( + type: type.name, + promptHash: hash, + responseText: result, + rangeStart: Value(start), + rangeEnd: Value(end), + ), + ); + if (!ref.mounted) return; + state = AsyncData(result); + } catch (e, st) { + if (!ref.mounted) return; + state = AsyncError(e, st); } - - // Generation happens in LlmNotifier (see llm_provider.dart) - state = const AsyncData(null); - } - - Future saveInsight({ - required String promptHash, - required String responseText, - required InsightType type, - DateTime? rangeStart, - DateTime? rangeEnd, - }) async { - await ref.read(insightDaoProvider).upsertInsight( - LlmInsightsCompanion.insert( - type: type.name, - promptHash: promptHash, - responseText: responseText, - rangeStart: Value(rangeStart), - rangeEnd: Value(rangeEnd), - ), - ); - if (!ref.mounted) return; - state = AsyncData(responseText); } void clear() => state = const AsyncData(null); + + static String _hashPrompt(String prompt) => + sha256.convert(utf8.encode(prompt)).toString(); } final insightNotifierProvider = diff --git a/lib/features/insights/insights_screen.dart b/lib/features/insights/insights_screen.dart index 26c37e7..f216a2c 100644 --- a/lib/features/insights/insights_screen.dart +++ b/lib/features/insights/insights_screen.dart @@ -1,12 +1,198 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/providers/insight_provider.dart'; -class InsightsScreen extends StatelessWidget { +class InsightsScreen extends ConsumerStatefulWidget { const InsightsScreen({super.key}); + @override + ConsumerState createState() => _InsightsScreenState(); +} + +class _InsightsScreenState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(insightNotifierProvider.notifier).generateInsight(); + }); + } + + void _refresh() { + ref + .read(insightNotifierProvider.notifier) + .generateInsight(forceRefresh: true); + } + @override Widget build(BuildContext context) { - return const Scaffold( - body: Center(child: Text('Insights coming soon')), + final insightAsync = ref.watch(insightNotifierProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Insights'), + centerTitle: false, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: insightAsync is AsyncLoading ? null : _refresh, + ), + ], + ), + body: insightAsync.when( + loading: () => const _LoadingView(), + error: (e, _) => _ErrorView(onRetry: _refresh), + data: (text) => + text == null ? const _EmptyView() : _InsightCard(text: text), + ), + ); + } +} + +class _LoadingView extends StatelessWidget { + const _LoadingView(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + 'Analysing your week…', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ); + } +} + +class _EmptyView extends StatelessWidget { + const _EmptyView(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_awesome_outlined, + size: 48, + color: Theme.of(context).colorScheme.outlineVariant, + ), + const SizedBox(height: 16), + Text( + 'Not enough data yet', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Log a few more entries and check back soon.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Something went wrong', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: onRetry, + child: const Text('Try again'), + ), + ], + ), + ); + } +} + +class _InsightCard extends StatelessWidget { + const _InsightCard({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.auto_awesome, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'This week', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + height: 1.5, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Generated on-device. Your data never leaves your phone.', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.outlineVariant, + ), + textAlign: TextAlign.center, + ), + ], ); } }