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