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 payload; final Map 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 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.', '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': { '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), 'preferenceSignals': _preferenceSignals(signals, now), 'capturedFacts': _capturedFacts(state, person, now), 'priorSuggestions': _priorSuggestions(state, person), }; } List> _capturedFacts( LocalDataState state, PersonProfile person, DateTime now, ) { final List identityTerms = _identityTerms(state); final Map 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 { 'type': _factTypeLabel(fact.type), if (_safeContextText(fact.label, identityTerms).isNotEmpty) 'label': _safeContextText(fact.label, identityTerms), 'summary': _safeContextText(fact.text, identityTerms), if (sharedMessageDateTime != null) ...{ 'shared_message_datetime': sharedMessageDateTime .toUtc() .toIso8601String(), 'sourceAge': _ageLabel(sharedMessageDateTime, now), }, }; }) .where( (Map fact) => fact['summary'] != null && fact['summary']!.isNotEmpty, ) .toList(growable: false); } List> _preferenceSignals( List signals, DateTime now, ) { return signals .map( (PersonPreferenceSignal signal) => { '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 signal) => signal['label']!.isNotEmpty) .take(maxSignalsPerPerson) .toList(growable: false); } Map _sharedMessageTimes(LocalDataState state) { return { for (final SharedMessageEntry entry in state.sharedMessages) if (entry.sharedMessageDateTime != null) entry.id: entry.sharedMessageDateTime!, }; } 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'; } 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 _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(); } String _safeContextText(String? value, List 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 _identityTerms(LocalDataState state) { final Set terms = {}; for (final PersonProfile person in state.people) { for (final String raw in [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 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 probes) { return probes.any(value.contains); } }