Add LLM digest setup and share timestamp context
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user