Files
rely/lib/features/ai_digest/application/anonymized_llm_context_builder.dart
T
Rijad Zuzo f655adfbea feat: add local-first private AI digest workflow
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
2026-05-17 00:17:20 +02:00

286 lines
8.3 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/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,
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<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.',
],
'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),
};
}
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);
}
}