308 lines
9.1 KiB
Dart
308 lines
9.1 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';
|
|
|
|
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.maxPriorSuggestionsPerPerson = 6,
|
|
DateTime Function()? now,
|
|
}) : _now = now ?? DateTime.now;
|
|
|
|
final int maxPeople;
|
|
final int maxSignalsPerPerson;
|
|
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.',
|
|
'Do not repeat priorSuggestions for the same personToken.',
|
|
],
|
|
'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),
|
|
'priorSuggestions': _priorSuggestions(state, person),
|
|
};
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|