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
@@ -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;
}