450 lines
14 KiB
Dart
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);
|
|
}
|
|
}
|