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.
This commit is contained in:
2026-04-26 14:37:37 +10:00
parent d1b18c5296
commit ea412d6fe9
4 changed files with 322 additions and 48 deletions
+16
View File
@@ -84,6 +84,22 @@ class MoodDao extends DatabaseAccessor<AppDatabase> with _$MoodDaoMixin {
.getSingleOrNull()) != null;
}
// Entries with their linked activities — used by LLM prompt builder
Future<List<MoodEntryWithActivities>> 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
? <Activity>[]
: 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<Map<DateTime, double>> getDailyAverages(
DateTime start, DateTime end) async {
+56
View File
@@ -0,0 +1,56 @@
import '../database/daos/mood_dao.dart';
abstract class PromptBuilder {
static String buildWeeklyPrompt(List<MoodEntryWithActivities> 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 23 patterns or correlations across mood, sleep, energy, positivity, and self-worth.
End with one specific, actionable suggestion. Be concise (46 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 = <String>[
'$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<String> 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'];
}
+61 -45
View File
@@ -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<LlmInsight?, String>((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<AsyncValue<String?>> {
@override
AsyncValue<String?> build() => const AsyncData(null);
String _hashPrompt(String prompt) =>
sha256.convert(utf8.encode(prompt)).toString();
Future<void> 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<void> 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 =