import 'dart:convert'; import 'dart:math' as math; import 'package:relationship_saver/app/state/local_data_state.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, DateTime Function()? now, }) : _now = now ?? DateTime.now; final int maxPeople; final int maxSignalsPerPerson; 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.', ], '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), }; } 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); } }