feat: add local-first private AI digest workflow

Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
const String aiDigestNotificationPayload = 'ai_digest_review_inbox';
final Provider<AiDigestNotificationIntentBus>
aiDigestNotificationIntentBusProvider = Provider<AiDigestNotificationIntentBus>(
(Ref ref) {
final AiDigestNotificationIntentBus bus = AiDigestNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
class AiDigestNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String payload) {
if (!_controller.isClosed) {
_controller.add(payload);
}
}
Future<void> close() => _controller.close();
}
abstract interface class AiDigestNotifier {
Future<void> showDigestReady(int count);
}
class LocalAiDigestNotifier implements AiDigestNotifier {
LocalAiDigestNotifier({
FlutterLocalNotificationsPlugin? plugin,
void Function(String payload)? onTapPayload,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_onTapPayload = onTapPayload;
final FlutterLocalNotificationsPlugin _plugin;
final void Function(String payload)? _onTapPayload;
Future<void>? _initializeFuture;
@override
Future<void> showDigestReady(int count) async {
if (!_supportsNotifications()) {
return;
}
await _ensureInitialized();
await _plugin.show(
id: 8124,
title: 'Relationship digest ready',
body:
'$count private ${count == 1 ? 'suggestion is' : 'suggestions are'} ready to review.',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'relationship_saver_ai_digest',
'Relationship Digest',
channelDescription: 'Private AI digest suggestions ready for review',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
),
payload: aiDigestNotificationPayload,
);
}
Future<void> _ensureInitialized() {
return _initializeFuture ??= _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload != null && payload.isNotEmpty) {
_onTapPayload?.call(payload);
}
},
);
}
bool _supportsNotifications() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
}
final Provider<AiDigestNotifier> aiDigestNotifierProvider =
Provider<AiDigestNotifier>((Ref ref) {
return LocalAiDigestNotifier(
onTapPayload: ref.watch(aiDigestNotificationIntentBusProvider).emitTap,
);
});
@@ -0,0 +1,175 @@
import 'dart:convert';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:uuid/uuid.dart';
const Uuid _uuid = Uuid();
class AiDigestParseResult {
const AiDigestParseResult({required this.drafts, this.error});
final List<AiSuggestionDraft> drafts;
final String? error;
bool get success => error == null;
}
class AiDigestResponseParser {
const AiDigestResponseParser({
this.maxItems = 10,
this.maxTitleChars = 90,
this.maxDetailsChars = 420,
});
final int maxItems;
final int maxTitleChars;
final int maxDetailsChars;
AiDigestParseResult parse({
required String response,
required Map<String, String> tokenToPersonId,
required String sourceRunId,
required DateTime suggestedAt,
}) {
try {
final Object? decoded = jsonDecode(_extractJson(response));
final List<dynamic> items = _extractItems(decoded);
final List<AiSuggestionDraft> drafts = <AiSuggestionDraft>[];
for (final dynamic item in items.take(maxItems)) {
if (item is! Map<String, dynamic>) {
continue;
}
final String? token = _string(item['personToken']);
final String? personId = token == null ? null : tokenToPersonId[token];
if (personId == null) {
continue;
}
final AiSuggestionKind? kind = _kind(item['kind']);
final String title = _trimAndCap(
_string(item['title']) ?? '',
maxTitleChars,
);
if (kind == null || title.isEmpty) {
continue;
}
drafts.add(
AiSuggestionDraft(
id: 'ai-${_uuid.v4()}',
personId: personId,
kind: kind,
title: title,
details: _trimAndCap(
_string(item['details']) ?? '',
maxDetailsChars,
),
suggestedAt: suggestedAt,
suggestedFor:
_dateOrNull(item['suggestedFor']) ??
_dateOrNull(item['suggestedTiming']),
confidence: _confidence(item['confidence']),
status: AiSuggestionStatus.pending,
sourceRunId: sourceRunId,
reason: _trimAndCap(_string(item['reason']) ?? '', 240),
),
);
}
if (drafts.isEmpty) {
return const AiDigestParseResult(
drafts: <AiSuggestionDraft>[],
error: 'No valid digest suggestions returned.',
);
}
return AiDigestParseResult(drafts: drafts);
} catch (error) {
return AiDigestParseResult(
drafts: const <AiSuggestionDraft>[],
error: 'Unable to parse digest response: $error',
);
}
}
String _extractJson(String response) {
final String trimmed = response.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return trimmed;
}
final int objectStart = trimmed.indexOf('{');
final int arrayStart = trimmed.indexOf('[');
final int start = objectStart == -1
? arrayStart
: arrayStart == -1
? objectStart
: objectStart < arrayStart
? objectStart
: arrayStart;
final int objectEnd = trimmed.lastIndexOf('}');
final int arrayEnd = trimmed.lastIndexOf(']');
final int end = objectEnd > arrayEnd ? objectEnd : arrayEnd;
if (start < 0 || end < start) {
throw const FormatException('No JSON object or array found.');
}
return trimmed.substring(start, end + 1);
}
List<dynamic> _extractItems(Object? decoded) {
if (decoded is List<dynamic>) {
return decoded;
}
if (decoded is Map<String, dynamic>) {
final Object? suggestions = decoded['suggestions'] ?? decoded['items'];
if (suggestions is List<dynamic>) {
return suggestions;
}
}
throw const FormatException('Digest response must be a JSON array.');
}
AiSuggestionKind? _kind(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
for (final AiSuggestionKind kind in AiSuggestionKind.values) {
if (kind.name == value) {
return kind;
}
}
return null;
}
String? _string(Object? raw) {
if (raw == null) {
return null;
}
final String value = '$raw'.trim();
return value.isEmpty ? null : value;
}
DateTime? _dateOrNull(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
return DateTime.tryParse(value)?.toLocal();
}
double _confidence(Object? raw) {
if (raw is num) {
return raw.toDouble().clamp(0.0, 1.0).toDouble();
}
return 0.5;
}
String _trimAndCap(String value, int maxChars) {
final String trimmed = value.trim();
if (trimmed.length <= maxChars) {
return trimmed;
}
return trimmed.substring(0, maxChars).trim();
}
}
@@ -0,0 +1,285 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
class AnonymizedLlmDigestContext {
const AnonymizedLlmDigestContext({
required this.payload,
required this.tokenToPersonId,
});
final Map<String, dynamic> payload;
final Map<String, String> tokenToPersonId;
String toPromptJson() {
return const JsonEncoder.withIndent(' ').convert(payload);
}
}
class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({
this.maxPeople = 20,
this.maxSignalsPerPerson = 8,
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final int maxPeople;
final int maxSignalsPerPerson;
final DateTime Function() _now;
AnonymizedLlmDigestContext build(LocalDataState state) {
final DateTime now = _now();
final List<PersonProfile> selectedPeople =
state.people
.where(
(PersonProfile person) => _isDigestRelevant(state, person, now),
)
.toList(growable: false)
..sort(
(PersonProfile a, PersonProfile b) => _urgencyScore(
state,
b,
now,
).compareTo(_urgencyScore(state, a, now)),
);
final List<Map<String, dynamic>> peoplePayload = <Map<String, dynamic>>[];
final Map<String, String> tokenToPersonId = <String, String>{};
for (int i = 0; i < math.min(selectedPeople.length, maxPeople); i += 1) {
final PersonProfile person = selectedPeople[i];
final String token = 'person_${(i + 1).toString().padLeft(3, '0')}';
tokenToPersonId[token] = person.id;
peoplePayload.add(_personPayload(state, person, token, now));
}
return AnonymizedLlmDigestContext(
tokenToPersonId: tokenToPersonId,
payload: <String, dynamic>{
'schema': 'relationship_saver_private_digest_v1',
'task':
'Create a balanced private weekly digest with gift ideas, event ideas, reminders, and check-ins.',
'rules': <String>[
'Use only personToken values from the input.',
'Do not infer or ask for names.',
'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.',
],
'limits': <String, dynamic>{
'maxItems': 10,
'allowedKinds': <String>[
'giftIdea',
'eventIdea',
'checkIn',
'reminder',
],
},
'people': peoplePayload,
},
);
}
bool _isDigestRelevant(
LocalDataState state,
PersonProfile person,
DateTime now,
) {
if (_daysUntil(person.nextMoment, now).abs() <= 60) {
return true;
}
final DateTime? last = person.lastInteractedAt;
if (last == null || now.difference(last).inDays >= 14) {
return true;
}
return state.importantDates.any(
(PersonImportantDate value) =>
value.personId == person.id &&
!value.isSensitive &&
_daysUntil(value.date, now).abs() <= 60,
) ||
state.preferenceSignals.any(
(PersonPreferenceSignal signal) =>
signal.personId == person.id &&
signal.status != PreferenceSignalStatus.dismissed,
);
}
int _urgencyScore(LocalDataState state, PersonProfile person, DateTime now) {
final int nextMomentDays = _daysUntil(person.nextMoment, now).abs();
int score = math.max(0, 80 - nextMomentDays);
final DateTime? last = person.lastInteractedAt;
if (last == null) {
score += 30;
} else {
score += math.min(40, now.difference(last).inDays);
}
score +=
state.importantDates
.where(
(PersonImportantDate value) =>
value.personId == person.id &&
!value.isSensitive &&
_daysUntil(value.date, now).abs() <= 60,
)
.length *
10;
return score;
}
Map<String, dynamic> _personPayload(
LocalDataState state,
PersonProfile person,
String token,
DateTime now,
) {
final List<PersonPreferenceSignal> signals = state.preferenceSignals
.where(
(PersonPreferenceSignal signal) =>
signal.personId == person.id &&
signal.status != PreferenceSignalStatus.dismissed,
)
.take(maxSignalsPerPerson)
.toList(growable: false);
final List<PersonImportantDate> dates = state.importantDates
.where(
(PersonImportantDate value) =>
value.personId == person.id && !value.isSensitive,
)
.take(maxSignalsPerPerson)
.toList(growable: false);
return <String, dynamic>{
'personToken': token,
'relationshipCategory': _relationshipCategory(person.relationship),
'affinityBand': _affinityBand(person.affinityScore),
'upcoming': <String>[
_relativeWindow('next planned moment', person.nextMoment, now),
...dates.map(
(PersonImportantDate value) => _relativeWindow(
_safeCategory(value.classification),
value.date,
now,
),
),
],
'recency': _recency(person.lastInteractedAt, now),
'interests': _safeList(<String>[
...person.tags,
...signals
.where(
(PersonPreferenceSignal signal) =>
signal.polarity == PreferenceSignalPolarity.like,
)
.map((PersonPreferenceSignal signal) => signal.label),
]).take(maxSignalsPerPerson).toList(growable: false),
'constraints': _safeList(
signals
.where(
(PersonPreferenceSignal signal) =>
signal.polarity == PreferenceSignalPolarity.dislike,
)
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.toList(growable: false),
).take(maxSignalsPerPerson).toList(growable: false),
};
}
String _relationshipCategory(String value) {
final String normalized = value.toLowerCase();
if (_containsAny(normalized, <String>[
'partner',
'spouse',
'wife',
'husband',
])) {
return 'partner';
}
if (_containsAny(normalized, <String>[
'family',
'sister',
'brother',
'mother',
'father',
'parent',
'cousin',
'aunt',
'uncle',
])) {
return 'family';
}
if (normalized.contains('friend')) {
return 'friend';
}
if (_containsAny(normalized, <String>['work', 'colleague', 'coworker'])) {
return 'colleague';
}
return 'relationship';
}
String _affinityBand(int score) {
if (score >= 85) {
return 'very close';
}
if (score >= 65) {
return 'close';
}
return 'light';
}
String _relativeWindow(String label, DateTime date, DateTime now) {
final int days = _daysUntil(date, now);
final String when = days == 0
? 'today'
: days > 0
? 'in about $days days'
: 'about ${days.abs()} days ago';
return '${_safeCategory(label)} $when';
}
String _recency(DateTime? lastInteractedAt, DateTime now) {
if (lastInteractedAt == null) {
return 'no recent interaction recorded';
}
final int days = now.difference(lastInteractedAt).inDays;
if (days <= 1) {
return 'contacted recently';
}
return 'last contact about $days days ago';
}
List<String> _safeList(Iterable<String> values) {
final List<String> out = <String>[];
final Set<String> seen = <String>{};
for (final String raw in values) {
final String value = _safeCategory(raw);
if (value.isEmpty) {
continue;
}
if (seen.add(value.toLowerCase())) {
out.add(value);
}
}
return out;
}
String _safeCategory(String value) {
return value
.trim()
.replaceAll(RegExp(r'https?://\S+'), '')
.replaceAll(RegExp(r'[^a-zA-Z0-9 +&/-]+'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.toLowerCase();
}
int _daysUntil(DateTime date, DateTime now) {
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
}
bool _containsAny(String value, List<String> probes) {
return probes.any(value.contains);
}
}
@@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:workmanager/workmanager.dart';
const String llmDigestTaskUniqueName = 'relationship_saver_llm_digest_weekly';
const String llmDigestTaskName = 'com.relationshipsaver.llm.digest';
abstract interface class LlmDigestBackgroundScheduler {
Future<void> configure(LlmDigestConfigState config);
Future<void> cancel();
}
class WorkmanagerLlmDigestBackgroundScheduler
implements LlmDigestBackgroundScheduler {
const WorkmanagerLlmDigestBackgroundScheduler({Workmanager? workmanager})
: _workmanager = workmanager;
final Workmanager? _workmanager;
Workmanager get _driver => _workmanager ?? Workmanager();
@override
Future<void> configure(LlmDigestConfigState config) async {
if (!config.enabled) {
await cancel();
return;
}
try {
await _driver.registerPeriodicTask(
llmDigestTaskUniqueName,
llmDigestTaskName,
frequency: const Duration(days: 7),
initialDelay: _initialDelay(config, DateTime.now()),
constraints: Constraints(
networkType: config.requireWifi
? NetworkType.unmetered
: NetworkType.connected,
requiresCharging: config.requireCharging,
),
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
);
} on UnimplementedError {
// Flutter tests and unsupported platforms have no Workmanager backend.
}
}
@override
Future<void> cancel() async {
try {
await _driver.cancelByUniqueName(llmDigestTaskUniqueName);
} on UnimplementedError {
// Flutter tests and unsupported platforms have no Workmanager backend.
}
}
Duration _initialDelay(LlmDigestConfigState config, DateTime now) {
DateTime target = DateTime(
now.year,
now.month,
now.day,
config.preferredHour,
);
final int dayDelta = (config.preferredWeekday - now.weekday) % 7;
target = target.add(Duration(days: dayDelta));
if (!target.isAfter(now)) {
target = target.add(const Duration(days: 7));
}
return target.difference(now);
}
}
final Provider<LlmDigestBackgroundScheduler>
llmDigestBackgroundSchedulerProvider = Provider<LlmDigestBackgroundScheduler>(
(Ref ref) => const WorkmanagerLlmDigestBackgroundScheduler(),
);
class LlmDigestBackgroundRunner extends ConsumerStatefulWidget {
const LlmDigestBackgroundRunner({required this.child, super.key});
final Widget child;
@override
ConsumerState<LlmDigestBackgroundRunner> createState() =>
_LlmDigestBackgroundRunnerState();
}
class _LlmDigestBackgroundRunnerState
extends ConsumerState<LlmDigestBackgroundRunner> {
ProviderSubscription<LlmDigestConfigState>? _subscription;
@override
void initState() {
super.initState();
unawaited(_initializeAndConfigure());
_subscription = ref.listenManual<LlmDigestConfigState>(
llmDigestConfigProvider,
(LlmDigestConfigState? previous, LlmDigestConfigState next) {
unawaited(
ref.read(llmDigestBackgroundSchedulerProvider).configure(next),
);
},
);
}
Future<void> _initializeAndConfigure() async {
await ref.read(llmDigestConfigProvider.notifier).initialize();
await ref
.read(llmDigestBackgroundSchedulerProvider)
.configure(ref.read(llmDigestConfigProvider));
}
@override
void dispose() {
_subscription?.close();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
@@ -0,0 +1,40 @@
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
abstract interface class LlmDigestEnvironment {
Future<bool> canRun(LlmDigestConfigState config);
}
class DeviceLlmDigestEnvironment implements LlmDigestEnvironment {
DeviceLlmDigestEnvironment({Connectivity? connectivity, Battery? battery})
: _connectivity = connectivity ?? Connectivity(),
_battery = battery ?? Battery();
final Connectivity _connectivity;
final Battery _battery;
@override
Future<bool> canRun(LlmDigestConfigState config) async {
if (config.requireWifi) {
final List<ConnectivityResult> results = await _connectivity
.checkConnectivity();
if (!results.contains(ConnectivityResult.wifi)) {
return false;
}
}
if (config.requireCharging) {
final BatteryState state = await _battery.batteryState;
if (state != BatteryState.charging && state != BatteryState.full) {
return false;
}
}
return true;
}
}
final Provider<LlmDigestEnvironment> llmDigestEnvironmentProvider =
Provider<LlmDigestEnvironment>((Ref ref) => DeviceLlmDigestEnvironment());
@@ -0,0 +1,256 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:uuid/uuid.dart';
const Duration _scheduledCooldown = Duration(days: 7);
const Duration _staleRunningAfter = Duration(minutes: 30);
const Uuid _uuid = Uuid();
final Provider<bool> llmDigestApiKeyConfiguredProvider = Provider<bool>(
(Ref ref) => ref.watch(llmConfigProvider).isConfigured,
);
final Provider<Future<void> Function()> llmDigestInitializerProvider =
Provider<Future<void> Function()>((Ref ref) {
return () async {
await ref.read(llmConfigProvider.notifier).initialize();
await ref.read(llmDigestConfigProvider.notifier).initialize();
};
});
class LlmDigestRunResult {
const LlmDigestRunResult({
required this.started,
required this.completed,
this.createdDraftCount = 0,
this.reason,
});
final bool started;
final bool completed;
final int createdDraftCount;
final String? reason;
}
class LlmDigestOrchestrator {
LlmDigestOrchestrator(this._ref, {DateTime Function()? now})
: _now = now ?? DateTime.now;
final Ref _ref;
final DateTime Function() _now;
Future<LlmDigestRunResult> runScheduledDigest() {
return _run(bypassCooldown: false, notify: true);
}
Future<LlmDigestRunResult> runManualDigest() {
return _run(bypassCooldown: true, notify: true);
}
Future<LlmDigestRunResult> _run({
required bool bypassCooldown,
required bool notify,
}) async {
await _ref.read(llmDigestInitializerProvider)();
final LlmDigestConfigState config = _ref.read(llmDigestConfigProvider);
if (!config.enabled && !bypassCooldown) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Digest is disabled.',
);
}
if (!_ref.read(llmDigestApiKeyConfiguredProvider)) {
await _markFailure('No LLM API key configured.');
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'No LLM API key configured.',
);
}
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState before = await store.read();
final DateTime now = _now();
if (_isAlreadyRunning(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Digest is already running.',
);
}
if (!bypassCooldown && !_cooldownAllowsRun(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Weekly digest cooldown is still active.',
);
}
if (!bypassCooldown && !_failureBackoffAllowsRun(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Failure backoff is still active.',
);
}
if (!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Network or charging policy is not satisfied.',
);
}
await store.write(
before.copyWith(lastStartedAt: now, running: true, lastFailure: null),
);
try {
final LocalDataState state = await _ref.read(
localRepositoryProvider.future,
);
final AnonymizedLlmDigestContext context =
const AnonymizedLlmContextBuilder().build(state);
if (context.tokenToPersonId.isEmpty) {
await store.write(
before.copyWith(
lastStartedAt: now,
lastCompletedAt: now,
running: false,
lastFailure: null,
consecutiveFailures: 0,
),
);
return const LlmDigestRunResult(
started: true,
completed: true,
reason: 'No eligible people for digest.',
);
}
final String sourceRunId = 'digest-${_uuid.v4()}';
final String response = await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt,
userPrompt: context.toPromptJson(),
);
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
response: response,
tokenToPersonId: context.tokenToPersonId,
sourceRunId: sourceRunId,
suggestedAt: now,
);
if (!parsed.success) {
await _markFailure(parsed.error ?? 'Invalid digest response.');
return LlmDigestRunResult(
started: true,
completed: false,
reason: parsed.error,
);
}
await _ref
.read(localRepositoryProvider.notifier)
.saveAiSuggestionDrafts(parsed.drafts);
await store.write(
LlmDigestRunState(
lastStartedAt: now,
lastCompletedAt: _now(),
consecutiveFailures: 0,
),
);
if (notify && parsed.drafts.isNotEmpty) {
await _ref
.read(aiDigestNotifierProvider)
.showDigestReady(parsed.drafts.length);
}
return LlmDigestRunResult(
started: true,
completed: true,
createdDraftCount: parsed.drafts.length,
);
} catch (error) {
await _markFailure('$error');
return LlmDigestRunResult(
started: true,
completed: false,
reason: '$error',
);
}
}
Future<void> _markFailure(String reason) async {
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState current = await store.read();
await store.write(
LlmDigestRunState(
lastStartedAt: current.lastStartedAt,
lastCompletedAt: current.lastCompletedAt,
lastFailure: reason,
consecutiveFailures: current.consecutiveFailures + 1,
),
);
}
bool _isAlreadyRunning(LlmDigestRunState state, DateTime now) {
final DateTime? started = state.lastStartedAt;
return state.running &&
started != null &&
now.difference(started) < _staleRunningAfter;
}
bool _cooldownAllowsRun(LlmDigestRunState state, DateTime now) {
final DateTime? completed = state.lastCompletedAt;
return completed == null || now.difference(completed) >= _scheduledCooldown;
}
bool _failureBackoffAllowsRun(LlmDigestRunState state, DateTime now) {
if (state.consecutiveFailures <= 0) {
return true;
}
final DateTime? started = state.lastStartedAt;
if (started == null) {
return true;
}
final int multiplier = 1 << state.consecutiveFailures.clamp(0, 5).toInt();
return now.difference(started) >= Duration(hours: multiplier);
}
}
const String _systemPrompt = '''
You create private relationship-maintenance digest suggestions.
The input contains pseudonymous people only. Never ask for or invent names.
Return exactly one JSON object:
{
"suggestions": [
{
"personToken": "person_001",
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
"title": "short action title",
"details": "practical details for the user to review",
"suggestedTiming": "optional ISO-8601 date or null",
"confidence": 0.0,
"reason": "brief non-sensitive rationale"
}
]
}
Return at most 10 suggestions. Use only personToken values present in input.
''';
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
@@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/llm_service.dart';
abstract interface class LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
});
}
class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
const ConfiguredLlmDigestTextClient(this._service);
final LlmService _service;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
}) {
return _service.completeText(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
}
}
final Provider<LlmDigestTextClient> llmDigestTextClientProvider =
Provider<LlmDigestTextClient>((Ref ref) {
return ConfiguredLlmDigestTextClient(ref.watch(llmServiceProvider));
});