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:
@@ -84,6 +84,22 @@ class MoodDao extends DatabaseAccessor<AppDatabase> with _$MoodDaoMixin {
|
|||||||
.getSingleOrNull()) != null;
|
.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
|
// Average mood per day — used by calendar heatmap
|
||||||
Future<Map<DateTime, double>> getDailyAverages(
|
Future<Map<DateTime, double>> getDailyAverages(
|
||||||
DateTime start, DateTime end) async {
|
DateTime start, DateTime end) async {
|
||||||
|
|||||||
@@ -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 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 = <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'];
|
||||||
|
}
|
||||||
@@ -3,71 +3,87 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../database/app_database.dart';
|
import '../database/app_database.dart';
|
||||||
|
import '../llm/prompt_builder.dart';
|
||||||
import 'database_provider.dart';
|
import 'database_provider.dart';
|
||||||
|
import 'llm_provider.dart';
|
||||||
|
|
||||||
// ── Insight type enum ─────────────────────────────────────────────────────────
|
// ── Insight type enum ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
enum InsightType { weekly, monthly, query }
|
enum InsightType { weekly, monthly, query }
|
||||||
|
|
||||||
// ── Cached insight lookup ─────────────────────────────────────────────────────
|
// ── Insight notifier — fetches data, builds prompt, calls LLM, caches result ──
|
||||||
|
|
||||||
final cachedInsightProvider =
|
|
||||||
FutureProvider.autoDispose.family<LlmInsight?, String>((ref, promptHash) {
|
|
||||||
return ref.watch(insightDaoProvider).getCached(promptHash);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Insight notifier — triggers generation and caches results ─────────────────
|
|
||||||
|
|
||||||
class InsightNotifier extends Notifier<AsyncValue<String?>> {
|
class InsightNotifier extends Notifier<AsyncValue<String?>> {
|
||||||
@override
|
@override
|
||||||
AsyncValue<String?> build() => const AsyncData(null);
|
AsyncValue<String?> build() => const AsyncData(null);
|
||||||
|
|
||||||
String _hashPrompt(String prompt) =>
|
|
||||||
sha256.convert(utf8.encode(prompt)).toString();
|
|
||||||
|
|
||||||
Future<void> generateInsight({
|
Future<void> generateInsight({
|
||||||
required String prompt,
|
InsightType type = InsightType.weekly,
|
||||||
required InsightType type,
|
bool forceRefresh = false,
|
||||||
DateTime? rangeStart,
|
|
||||||
DateTime? rangeEnd,
|
|
||||||
}) async {
|
}) async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
|
|
||||||
final hash = _hashPrompt(prompt);
|
try {
|
||||||
final dao = ref.read(insightDaoProvider);
|
// Ensure model is initialised before generating
|
||||||
|
await ref.read(llmReadyProvider.future);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
final cached = await dao.getCached(hash);
|
final now = DateTime.now();
|
||||||
if (!ref.mounted) return;
|
final end = DateTime(now.year, now.month, now.day, 23, 59, 59);
|
||||||
if (cached != null) {
|
final start = end.subtract(const Duration(days: 6));
|
||||||
state = AsyncData(cached.responseText);
|
|
||||||
return;
|
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);
|
void clear() => state = const AsyncData(null);
|
||||||
|
|
||||||
|
static String _hashPrompt(String prompt) =>
|
||||||
|
sha256.convert(utf8.encode(prompt)).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
final insightNotifierProvider =
|
final insightNotifierProvider =
|
||||||
|
|||||||
@@ -1,12 +1,198 @@
|
|||||||
import 'package:flutter/material.dart';
|
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});
|
const InsightsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<InsightsScreen> createState() => _InsightsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InsightsScreenState extends ConsumerState<InsightsScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ref.read(insightNotifierProvider.notifier).generateInsight();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refresh() {
|
||||||
|
ref
|
||||||
|
.read(insightNotifierProvider.notifier)
|
||||||
|
.generateInsight(forceRefresh: true);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
final insightAsync = ref.watch(insightNotifierProvider);
|
||||||
body: Center(child: Text('Insights coming soon')),
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user