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.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user