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 payload; final Map 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 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> peoplePayload = >[]; final Map tokenToPersonId = {}; 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: { 'schema': 'relationship_saver_private_digest_v1', 'task': 'Create a balanced private weekly digest with gift ideas, event ideas, reminders, and check-ins.', 'rules': [ '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': { 'maxItems': 10, 'allowedKinds': [ '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 _personPayload( LocalDataState state, PersonProfile person, String token, DateTime now, ) { final List signals = state.preferenceSignals .where( (PersonPreferenceSignal signal) => signal.personId == person.id && signal.status != PreferenceSignalStatus.dismissed, ) .take(maxSignalsPerPerson) .toList(growable: false); final List dates = state.importantDates .where( (PersonImportantDate value) => value.personId == person.id && !value.isSensitive, ) .take(maxSignalsPerPerson) .toList(growable: false); return { 'personToken': token, 'relationshipCategory': _relationshipCategory(person.relationship), 'affinityBand': _affinityBand(person.affinityScore), 'upcoming': [ _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([ ...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> _priorSuggestions( LocalDataState state, PersonProfile person, ) { return state.aiSuggestionDrafts .where((AiSuggestionDraft draft) => draft.personId == person.id) .take(maxPriorSuggestionsPerPerson) .map( (AiSuggestionDraft draft) => { '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, [ 'partner', 'spouse', 'wife', 'husband', ])) { return 'partner'; } if (_containsAny(normalized, [ 'family', 'sister', 'brother', 'mother', 'father', 'parent', 'cousin', 'aunt', 'uncle', ])) { return 'family'; } if (normalized.contains('friend')) { return 'friend'; } if (_containsAny(normalized, ['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 _safeList(Iterable values) { final List out = []; final Set seen = {}; 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 probes) { return probes.any(value.contains); } }