Add LLM digest setup and share timestamp context

This commit is contained in:
Rijad Zuzo
2026-05-19 19:17:35 +02:00
parent 9d2da0c600
commit 5d80af375e
42 changed files with 2878 additions and 39 deletions
@@ -69,6 +69,16 @@ class AiDigestResponseParser {
suggestedFor:
_dateOrNull(item['suggestedFor']) ??
_dateOrNull(item['suggestedTiming']),
eventEndsAt:
_dateOrNull(item['eventEndsAt']) ??
_dateOrNull(item['endsAt']) ??
_dateOrNull(item['endTime']),
eventLocation: _optionalTrimAndCap(
_string(item['eventLocation']) ?? _string(item['location']),
160,
),
eventUrl: _urlOrNull(item['eventUrl']) ?? _urlOrNull(item['url']),
sourceUrls: _sourceUrls(item['sourceUrls'] ?? item['sources']),
confidence: _confidence(item['confidence']),
status: AiSuggestionStatus.pending,
sourceRunId: sourceRunId,
@@ -158,6 +168,40 @@ class AiDigestResponseParser {
return DateTime.tryParse(value)?.toLocal();
}
String? _urlOrNull(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
final Uri? uri = Uri.tryParse(value);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
return uri.toString();
}
List<String> _sourceUrls(Object? raw) {
final List<Object?> values = raw is List<dynamic> ? raw : <Object?>[raw];
final Set<String> urls = <String>{};
for (final Object? value in values) {
if (value is Map<String, dynamic>) {
final String? url =
_urlOrNull(value['url']) ??
_urlOrNull(value['uri']) ??
_urlOrNull(value['link']);
if (url != null) {
urls.add(url);
}
continue;
}
final String? url = _urlOrNull(value);
if (url != null) {
urls.add(url);
}
}
return urls.take(5).toList(growable: false);
}
double _confidence(Object? raw) {
if (raw is num) {
return raw.toDouble().clamp(0.0, 1.0).toDouble();
@@ -172,4 +216,12 @@ class AiDigestResponseParser {
}
return trimmed.substring(0, maxChars).trim();
}
String? _optionalTrimAndCap(String? value, int maxChars) {
if (value == null) {
return null;
}
final String trimmed = _trimAndCap(value, maxChars);
return trimmed.isEmpty ? null : trimmed;
}
}
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
class AnonymizedLlmDigestContext {
const AnonymizedLlmDigestContext({
@@ -23,12 +24,14 @@ class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({
this.maxPeople = 20,
this.maxSignalsPerPerson = 8,
this.maxFactsPerPerson = 8,
this.maxPriorSuggestionsPerPerson = 6,
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final int maxPeople;
final int maxSignalsPerPerson;
final int maxFactsPerPerson;
final int maxPriorSuggestionsPerPerson;
final DateTime Function() _now;
@@ -69,7 +72,11 @@ class AnonymizedLlmContextBuilder {
'Do not infer or ask for names.',
'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.',
'Use web search for current event recommendations and include source URLs.',
'Only suggest real upcoming events with verified date and venue details.',
'Do not repeat priorSuggestions for the same personToken.',
'When shared_message_datetime is present, treat it as the point-in-time reference for that evidence.',
'Do not turn old health/status evidence into a current check-in; preserve durable preferences instead.',
],
'limits': <String, dynamic>{
'maxItems': 10,
@@ -188,10 +195,81 @@ class AnonymizedLlmContextBuilder {
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.toList(growable: false),
).take(maxSignalsPerPerson).toList(growable: false),
'preferenceSignals': _preferenceSignals(signals, now),
'capturedFacts': _capturedFacts(state, person, now),
'priorSuggestions': _priorSuggestions(state, person),
};
}
List<Map<String, String>> _capturedFacts(
LocalDataState state,
PersonProfile person,
DateTime now,
) {
final List<String> identityTerms = _identityTerms(state);
final Map<String, DateTime> sharedMessageTimes = _sharedMessageTimes(state);
return state.personFacts
.where(
(PersonFact fact) =>
fact.personId == person.id &&
!fact.isSensitive &&
!fact.needsReview &&
fact.text.trim().isNotEmpty,
)
.take(maxFactsPerPerson)
.map((PersonFact fact) {
final DateTime? sharedMessageDateTime = fact.sharedMessageId == null
? null
: sharedMessageTimes[fact.sharedMessageId];
return <String, String>{
'type': _factTypeLabel(fact.type),
if (_safeContextText(fact.label, identityTerms).isNotEmpty)
'label': _safeContextText(fact.label, identityTerms),
'summary': _safeContextText(fact.text, identityTerms),
if (sharedMessageDateTime != null) ...<String, String>{
'shared_message_datetime': sharedMessageDateTime
.toUtc()
.toIso8601String(),
'sourceAge': _ageLabel(sharedMessageDateTime, now),
},
};
})
.where(
(Map<String, String> fact) =>
fact['summary'] != null && fact['summary']!.isNotEmpty,
)
.toList(growable: false);
}
List<Map<String, String>> _preferenceSignals(
List<PersonPreferenceSignal> signals,
DateTime now,
) {
return signals
.map(
(PersonPreferenceSignal signal) => <String, String>{
'label': _safeCategory(signal.label),
'category': _safeCategory(signal.category),
'polarity': signal.polarity == PreferenceSignalPolarity.dislike
? 'dislike'
: 'like',
'lastObserved': signal.lastSeenAt.toUtc().toIso8601String(),
'sourceAge': _ageLabel(signal.lastSeenAt, now),
},
)
.where((Map<String, String> signal) => signal['label']!.isNotEmpty)
.take(maxSignalsPerPerson)
.toList(growable: false);
}
Map<String, DateTime> _sharedMessageTimes(LocalDataState state) {
return <String, DateTime>{
for (final SharedMessageEntry entry in state.sharedMessages)
if (entry.sharedMessageDateTime != null)
entry.id: entry.sharedMessageDateTime!,
};
}
List<Map<String, String>> _priorSuggestions(
LocalDataState state,
PersonProfile person,
@@ -272,6 +350,18 @@ class AnonymizedLlmContextBuilder {
return 'last contact about $days days ago';
}
String _ageLabel(DateTime value, DateTime now) {
final int days = now.difference(value).inDays;
if (days <= 1) {
return 'current or recent';
}
if (days < 45) {
return 'about $days days ago';
}
final int months = (days / 30).round().clamp(2, 24).toInt();
return 'about $months months ago';
}
List<String> _safeList(Iterable<String> values) {
final List<String> out = <String>[];
final Set<String> seen = <String>{};
@@ -297,6 +387,58 @@ class AnonymizedLlmContextBuilder {
.toLowerCase();
}
String _safeContextText(String? value, List<String> identityTerms) {
if (value == null) {
return '';
}
String safe = value.trim().replaceAll(RegExp(r'https?://\S+'), '');
for (final String term in identityTerms) {
safe = safe.replaceAll(
RegExp(RegExp.escape(term), caseSensitive: false),
'this person',
);
}
return safe
.replaceAll(RegExp(r'[\r\n\t]+'), ' ')
.replaceAll(RegExp(r'[^a-zA-Z0-9 .,;:!?+&%$#@()\-/]+'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.toLowerCase();
}
List<String> _identityTerms(LocalDataState state) {
final Set<String> terms = <String>{};
for (final PersonProfile person in state.people) {
for (final String raw in <String>[person.name, ...person.aliases]) {
final String term = raw.trim();
if (term.length >= 3) {
terms.add(term);
}
for (final String part in term.split(RegExp(r'\s+'))) {
if (part.length >= 3) {
terms.add(part);
}
}
}
}
final List<String> sorted = terms.toList(growable: false)
..sort((String a, String b) => b.length.compareTo(a.length));
return sorted;
}
String _factTypeLabel(CapturedFactType type) {
return switch (type) {
CapturedFactType.note => 'note',
CapturedFactType.like => 'like',
CapturedFactType.dislike => 'dislike',
CapturedFactType.importantDate => 'important date',
CapturedFactType.giftIdea => 'gift idea',
CapturedFactType.placeIdea => 'place idea',
CapturedFactType.activityIdea => 'activity idea',
CapturedFactType.misc => 'misc',
};
}
int _daysUntil(DateTime date, DateTime now) {
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
}
@@ -0,0 +1,53 @@
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:url_launcher/url_launcher.dart';
class CalendarEventLauncher {
const CalendarEventLauncher();
Future<bool> addSuggestionToCalendar(AiSuggestionDraft draft) async {
final Uri? uri = calendarUriForSuggestion(draft);
if (uri == null) {
return false;
}
if (await launchUrl(uri, mode: LaunchMode.externalApplication)) {
return true;
}
return launchUrl(uri);
}
}
Uri? calendarUriForSuggestion(AiSuggestionDraft draft) {
final DateTime? startsAt = draft.suggestedFor;
if (draft.kind != AiSuggestionKind.eventIdea || startsAt == null) {
return null;
}
final DateTime endsAt =
draft.eventEndsAt ?? startsAt.add(const Duration(hours: 2));
final String details = <String>[
draft.details,
if (draft.reason != null && draft.reason!.trim().isNotEmpty)
'Why: ${draft.reason!.trim()}',
if (draft.eventUrl != null && draft.eventUrl!.trim().isNotEmpty)
draft.eventUrl!.trim(),
if (draft.sourceUrls.isNotEmpty) 'Sources: ${draft.sourceUrls.join(', ')}',
].where((String value) => value.trim().isNotEmpty).join('\n\n');
return Uri.https('calendar.google.com', '/calendar/render', <String, String>{
'action': 'TEMPLATE',
'text': draft.title,
'dates': '${_calendarTimestamp(startsAt)}/${_calendarTimestamp(endsAt)}',
if (details.isNotEmpty) 'details': details,
if (draft.eventLocation != null && draft.eventLocation!.trim().isNotEmpty)
'location': draft.eventLocation!.trim(),
});
}
String _calendarTimestamp(DateTime value) {
final DateTime utc = value.toUtc();
return '${utc.year.toString().padLeft(4, '0')}'
'${utc.month.toString().padLeft(2, '0')}'
'${utc.day.toString().padLeft(2, '0')}T'
'${utc.hour.toString().padLeft(2, '0')}'
'${utc.minute.toString().padLeft(2, '0')}'
'${utc.second.toString().padLeft(2, '0')}Z';
}
@@ -3,6 +3,7 @@ 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/core/llm/llm_service.dart';
import 'package:relationship_saver/core/observability/sentry_init.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';
@@ -11,6 +12,7 @@ import 'package:relationship_saver/features/ai_digest/application/llm_digest_tex
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:sentry_flutter/sentry_flutter.dart';
import 'package:uuid/uuid.dart';
const Duration _scheduledCooldown = Duration(days: 7);
@@ -107,7 +109,8 @@ class LlmDigestOrchestrator {
);
}
if (!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
if (!bypassCooldown &&
!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
return const LlmDigestRunResult(
started: false,
completed: false,
@@ -143,12 +146,11 @@ class LlmDigestOrchestrator {
}
final String sourceRunId = 'digest-${_uuid.v4()}';
final String response = await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt,
userPrompt: context.toPromptJson(),
);
final String response = await _completeDigestPrompt(
userPrompt: context.toPromptJson(),
enableWebSearch: config.enableWebSearch,
sourceRunId: sourceRunId,
);
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
response: response,
tokenToPersonId: context.tokenToPersonId,
@@ -157,6 +159,18 @@ class LlmDigestOrchestrator {
);
if (!parsed.success) {
_ref
.read(sentryInitProvider)
.captureMessage(
parsed.error ?? 'Invalid digest response.',
level: SentryLevel.warning,
area: 'llm_digest_parse',
context: <String, dynamic>{
'sourceRunId': sourceRunId,
'responseChars': response.length,
'eligiblePeople': context.tokenToPersonId.length,
},
);
await _markFailure(parsed.error ?? 'Invalid digest response.');
return LlmDigestRunResult(
started: true,
@@ -185,8 +199,19 @@ class LlmDigestOrchestrator {
completed: true,
createdDraftCount: parsed.drafts.length,
);
} catch (error) {
} catch (error, stackTrace) {
final String reason = _digestFailureReason(error);
_ref
.read(sentryInitProvider)
.captureException(
error,
stackTrace: stackTrace,
area: 'llm_digest',
context: <String, dynamic>{
'bypassCooldown': bypassCooldown,
'notify': notify,
},
);
await _markFailure(reason);
return LlmDigestRunResult(
started: true,
@@ -196,6 +221,45 @@ class LlmDigestOrchestrator {
}
}
Future<String> _completeDigestPrompt({
required String userPrompt,
required bool enableWebSearch,
required String sourceRunId,
}) async {
try {
return await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt(enableWebSearch: enableWebSearch),
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
} on LlmProviderException catch (error) {
if (!enableWebSearch || error.statusCode != 429) {
rethrow;
}
_ref
.read(sentryInitProvider)
.captureMessage(
'Grounded digest request hit provider quota; retrying without web search.',
level: SentryLevel.warning,
area: 'llm_digest_grounding_fallback',
context: <String, dynamic>{
'sourceRunId': sourceRunId,
'provider': error.provider.name,
'statusCode': error.statusCode,
},
);
return _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt(enableWebSearch: false),
userPrompt: userPrompt,
enableWebSearch: false,
);
}
}
Future<void> _markFailure(String reason) async {
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState current = await store.read();
@@ -241,7 +305,21 @@ String _digestFailureReason(Object error) {
return '$error';
}
const String _systemPrompt = '''
String _systemPrompt({required bool enableWebSearch}) {
final String groundingRules = enableWebSearch
? '''
Use web search for current event, venue, availability, price, and timing claims.
Prefer official event or venue pages over aggregators. Only classify an item as
eventIdea when there is a real upcoming event with a concrete date in the next
60 days. Include sourceUrls for grounded suggestions and do not fabricate URLs.
'''
: '''
Web search grounding is disabled or unavailable for this run. Do not make
current availability, price, or venue claims. Avoid eventIdea suggestions unless
the input already contains enough concrete event details and leave sourceUrls
empty when no source URL was provided.
''';
return '''
You create private relationship-maintenance digest suggestions.
The input contains pseudonymous people only. Never ask for or invent names.
Return exactly one JSON object:
@@ -252,14 +330,24 @@ Return exactly one JSON object:
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
"title": "short action title",
"details": "practical details for the user to review",
"suggestedTiming": "optional ISO-8601 date or null",
"suggestedTiming": "optional ISO-8601 start date/time or null",
"eventEndsAt": "optional ISO-8601 end date/time for eventIdea only, otherwise null",
"eventLocation": "optional venue or city for eventIdea only, otherwise null",
"eventUrl": "optional official event URL for eventIdea only, otherwise null",
"sourceUrls": ["0-3 source URLs used to verify the suggestion"],
"confidence": 0.0,
"reason": "brief non-sensitive rationale"
}
]
}
Return at most 10 suggestions. Use only personToken values present in input.
If an input fact includes shared_message_datetime, use that timestamp as the
evidence date. A message from months ago about temporary health, travel, mood,
or availability should not produce an immediate checkIn unless other recent
evidence supports it; keep durable preferences, constraints, and gift ideas.
$groundingRules
''';
}
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
@@ -5,6 +5,7 @@ abstract interface class LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
});
}
@@ -17,10 +18,12 @@ class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
bool enableWebSearch = false,
}) {
return _service.completeText(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
enableWebSearch: enableWebSearch,
);
}
}