Files
rely/lib/features/ai_digest/application/anonymized_llm_context_builder.dart
2026-05-19 19:17:35 +02:00

450 lines
14 KiB
Dart

import 'dart:convert';
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({
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,
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;
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.',
'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,
'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),
'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,
) {
return state.aiSuggestionDrafts
.where((AiSuggestionDraft draft) => draft.personId == person.id)
.take(maxPriorSuggestionsPerPerson)
.map(
(AiSuggestionDraft draft) => <String, String>{
'kind': draft.kind.name,
'status': draft.status.name,
'title': _safeCategory(draft.title),
},
)
.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';
}
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>{};
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();
}
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;
}
bool _containsAny(String value, List<String> probes) {
return probes.any(value.contains);
}
}