Add local preference signal scaffolding

This commit is contained in:
Rijad Zuzo
2026-02-22 23:19:49 +01:00
parent 058e2c1787
commit 8c79ba1345
4 changed files with 499 additions and 0 deletions
+45
View File
@@ -7,6 +7,51 @@ Updated: 2026-02-22
- After every sensible code/documentation change set, create a git commit as
the last step so the next agent session can pick up from clean checkpoints.
## Latest Milestone (2026-02-22): Local Preference Signal Scaffolding (Inferred/Confirmed)
Added a local-first data model and repository APIs for chat-derived preference
signals so the app can gradually build profile context from shared messages.
- `lib/features/local/local_models.dart`
- new enums:
- `PreferenceSignalPolarity` (`like`, `dislike`, `neutral`)
- `PreferenceSignalStatus` (`inferred`, `confirmed`, `dismissed`)
- new model:
- `PersonPreferenceSignal`
- key/category/label
- confidence
- status
- first/last seen timestamps
- occurrence count
- evidence message ids/snippets
- source apps
- `LocalDataState` now stores `preferenceSignals`
- added `copyWith`, `toJson`, `fromJson` support (backward-compatible with
existing stored state)
- `lib/features/local/local_repository.dart`
- added local-only repository APIs:
- `upsertInferredPreferenceSignalObservation(...)`
- aggregates repeated observations
- merges evidence and source apps
- updates confidence and occurrence count
- `upsertPreferenceSignal(...)`
- `setPreferenceSignalStatus(...)`
- `confirmPreferenceSignal(...)`
- `dismissPreferenceSignal(...)`
- updated person lifecycle consistency:
- deleting a person removes linked preference signals
- merging profiles rebinds source preference signals to target
- remote person delete application also removes preference signals
- intentionally local-only for now (not yet mapped to backend sync envelopes)
- `test/features/local/local_repository_test.dart`
- added coverage for inferred signal upsert aggregation + status transitions
- Validation
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-22): Person Editor Inline Validation + Duplicate Warning
Improved the People add/edit profile experience to make validation explicit and
+150
View File
@@ -6,6 +6,10 @@ enum IdeaType { gift, event }
enum ReminderCadence { daily, weekly, monthly }
enum PreferenceSignalPolarity { like, dislike, neutral }
enum PreferenceSignalStatus { inferred, confirmed, dismissed }
@immutable
class PersonProfile {
const PersonProfile({
@@ -596,6 +600,137 @@ class SharedInboxEntry {
}
}
@immutable
class PersonPreferenceSignal {
const PersonPreferenceSignal({
required this.id,
required this.personId,
required this.key,
required this.label,
required this.category,
required this.polarity,
required this.confidence,
required this.status,
required this.firstSeenAt,
required this.lastSeenAt,
required this.occurrenceCount,
this.sourceApps = const <String>[],
this.evidenceMessageIds = const <String>[],
this.evidenceSnippets = const <String>[],
});
final String id;
final String personId;
final String key;
final String label;
final String category;
final PreferenceSignalPolarity polarity;
final double confidence;
final PreferenceSignalStatus status;
final DateTime firstSeenAt;
final DateTime lastSeenAt;
final int occurrenceCount;
final List<String> sourceApps;
final List<String> evidenceMessageIds;
final List<String> evidenceSnippets;
PersonPreferenceSignal copyWith({
String? id,
String? personId,
String? key,
String? label,
String? category,
PreferenceSignalPolarity? polarity,
double? confidence,
PreferenceSignalStatus? status,
DateTime? firstSeenAt,
DateTime? lastSeenAt,
int? occurrenceCount,
List<String>? sourceApps,
List<String>? evidenceMessageIds,
List<String>? evidenceSnippets,
}) {
return PersonPreferenceSignal(
id: id ?? this.id,
personId: personId ?? this.personId,
key: key ?? this.key,
label: label ?? this.label,
category: category ?? this.category,
polarity: polarity ?? this.polarity,
confidence: confidence ?? this.confidence,
status: status ?? this.status,
firstSeenAt: firstSeenAt ?? this.firstSeenAt,
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
occurrenceCount: occurrenceCount ?? this.occurrenceCount,
sourceApps: sourceApps ?? this.sourceApps,
evidenceMessageIds: evidenceMessageIds ?? this.evidenceMessageIds,
evidenceSnippets: evidenceSnippets ?? this.evidenceSnippets,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'key': key,
'label': label,
'category': category,
'polarity': polarity.name,
'confidence': confidence,
'status': status.name,
'firstSeenAt': firstSeenAt.toUtc().toIso8601String(),
'lastSeenAt': lastSeenAt.toUtc().toIso8601String(),
'occurrenceCount': occurrenceCount,
'sourceApps': sourceApps,
'evidenceMessageIds': evidenceMessageIds,
'evidenceSnippets': evidenceSnippets,
};
}
factory PersonPreferenceSignal.fromJson(Map<String, dynamic> json) {
final String polarityName =
json['polarity'] as String? ?? PreferenceSignalPolarity.neutral.name;
final String statusName =
json['status'] as String? ?? PreferenceSignalStatus.inferred.name;
return PersonPreferenceSignal(
id: json['id'] as String,
personId: json['personId'] as String,
key: json['key'] as String,
label: json['label'] as String? ?? '',
category: json['category'] as String? ?? 'general',
polarity: PreferenceSignalPolarity.values.firstWhere(
(PreferenceSignalPolarity item) => item.name == polarityName,
orElse: () => PreferenceSignalPolarity.neutral,
),
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
status: PreferenceSignalStatus.values.firstWhere(
(PreferenceSignalStatus item) => item.name == statusName,
orElse: () => PreferenceSignalStatus.inferred,
),
firstSeenAt: DateTime.parse(
json['firstSeenAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
lastSeenAt: DateTime.parse(
json['lastSeenAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
occurrenceCount: (json['occurrenceCount'] as num?)?.toInt() ?? 1,
sourceApps: (json['sourceApps'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
evidenceMessageIds:
(json['evidenceMessageIds'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
evidenceSnippets:
(json['evidenceSnippets'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
);
}
}
@immutable
class DashboardSummary {
const DashboardSummary({
@@ -622,6 +757,7 @@ class LocalDataState {
this.sourceLinks = const <SourceProfileLink>[],
this.sharedMessages = const <SharedMessageEntry>[],
this.sharedInbox = const <SharedInboxEntry>[],
this.preferenceSignals = const <PersonPreferenceSignal>[],
});
final List<PersonProfile> people;
@@ -632,6 +768,7 @@ class LocalDataState {
final List<SourceProfileLink> sourceLinks;
final List<SharedMessageEntry> sharedMessages;
final List<SharedInboxEntry> sharedInbox;
final List<PersonPreferenceSignal> preferenceSignals;
LocalDataState copyWith({
List<PersonProfile>? people,
@@ -642,6 +779,7 @@ class LocalDataState {
List<SourceProfileLink>? sourceLinks,
List<SharedMessageEntry>? sharedMessages,
List<SharedInboxEntry>? sharedInbox,
List<PersonPreferenceSignal>? preferenceSignals,
}) {
return LocalDataState(
people: people ?? this.people,
@@ -652,6 +790,7 @@ class LocalDataState {
sourceLinks: sourceLinks ?? this.sourceLinks,
sharedMessages: sharedMessages ?? this.sharedMessages,
sharedInbox: sharedInbox ?? this.sharedInbox,
preferenceSignals: preferenceSignals ?? this.preferenceSignals,
);
}
@@ -697,6 +836,9 @@ class LocalDataState {
'sharedInbox': sharedInbox
.map((SharedInboxEntry entry) => entry.toJson())
.toList(growable: false),
'preferenceSignals': preferenceSignals
.map((PersonPreferenceSignal signal) => signal.toJson())
.toList(growable: false),
};
}
@@ -750,6 +892,14 @@ class LocalDataState {
SharedInboxEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
preferenceSignals:
(json['preferenceSignals'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic signal) => PersonPreferenceSignal.fromJson(
signal as Map<String, dynamic>,
),
)
.toList(growable: false),
);
}
+226
View File
@@ -481,6 +481,10 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
final List<ReminderRule> reminders = current.reminders
.where((ReminderRule reminder) => reminder.personId != personId)
.toList(growable: false);
final List<PersonPreferenceSignal> preferenceSignals = current
.preferenceSignals
.where((PersonPreferenceSignal signal) => signal.personId != personId)
.toList(growable: false);
await _setState(
current.copyWith(
@@ -488,6 +492,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
moments: moments,
ideas: ideas,
reminders: reminders,
preferenceSignals: preferenceSignals,
),
);
@@ -642,6 +647,15 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
return entry.copyWith(candidateProfileIds: candidates);
})
.toList(growable: false);
final List<PersonPreferenceSignal> nextPreferenceSignals = current
.preferenceSignals
.map((PersonPreferenceSignal signal) {
if (signal.personId != sourcePersonId) {
return signal;
}
return signal.copyWith(personId: targetPersonId);
})
.toList(growable: false);
await _setState(
current.copyWith(
@@ -652,6 +666,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
sourceLinks: nextLinks,
sharedMessages: nextMessages,
sharedInbox: nextInbox,
preferenceSignals: nextPreferenceSignals,
),
);
@@ -964,6 +979,153 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
);
}
/// Upserts a profile preference signal observation inferred from chat context.
///
/// This is intentionally local-only for now. We keep inferred signals separate
/// from manual tags until the user confirms them.
Future<PersonPreferenceSignal> upsertInferredPreferenceSignalObservation({
required String personId,
required String key,
required String label,
required String category,
required PreferenceSignalPolarity polarity,
required double confidence,
String? sourceApp,
String? evidenceMessageId,
String? evidenceSnippet,
DateTime? observedAt,
}) async {
final LocalDataState current = _requireState();
final DateTime observed = observedAt ?? DateTime.now();
final String normalizedKey = _normalizePreferenceKey(key);
if (normalizedKey.isEmpty) {
throw ArgumentError('Preference signal key cannot be empty');
}
if (!_personExists(current.people, personId)) {
throw ArgumentError('Profile does not exist for preference signal');
}
final int existingIndex = current.preferenceSignals.indexWhere(
(PersonPreferenceSignal signal) =>
signal.personId == personId &&
signal.key == normalizedKey &&
signal.polarity == polarity,
);
final double normalizedConfidence = _clampUnit(confidence);
final String normalizedLabel = label.trim().isEmpty
? normalizedKey
: label.trim();
final String normalizedCategory = category.trim().isEmpty
? 'general'
: category.trim().toLowerCase();
final String? snippet = _trimToNull(evidenceSnippet);
final String? messageId = _trimToNull(evidenceMessageId);
final String? source = _trimToNull(sourceApp)?.toLowerCase();
late final PersonPreferenceSignal nextSignal;
final List<PersonPreferenceSignal> nextSignals = current.preferenceSignals
.toList(growable: true);
if (existingIndex < 0) {
nextSignal = PersonPreferenceSignal(
id: 'ps-${_uuid.v4()}',
personId: personId,
key: normalizedKey,
label: normalizedLabel,
category: normalizedCategory,
polarity: polarity,
confidence: normalizedConfidence,
status: PreferenceSignalStatus.inferred,
firstSeenAt: observed,
lastSeenAt: observed,
occurrenceCount: 1,
sourceApps: source == null ? const <String>[] : <String>[source],
evidenceMessageIds: messageId == null
? const <String>[]
: <String>[messageId],
evidenceSnippets: snippet == null
? const <String>[]
: <String>[snippet],
);
nextSignals.insert(0, nextSignal);
} else {
final PersonPreferenceSignal existing = nextSignals[existingIndex];
final int currentCount = existing.occurrenceCount < 1
? 1
: existing.occurrenceCount;
final int nextCount = currentCount + 1;
final double mergedConfidence =
((existing.confidence * currentCount) + normalizedConfidence) /
nextCount;
nextSignal = existing.copyWith(
label: normalizedLabel,
category: normalizedCategory,
confidence: _clampUnit(mergedConfidence),
lastSeenAt: observed,
occurrenceCount: nextCount,
sourceApps: _mergeUniqueStrings(existing.sourceApps, source),
evidenceMessageIds: _mergeUniqueStrings(
existing.evidenceMessageIds,
messageId,
maxItems: 8,
),
evidenceSnippets: _mergeUniqueStrings(
existing.evidenceSnippets,
snippet == null ? null : _truncate(snippet, 220),
maxItems: 6,
),
);
nextSignals[existingIndex] = nextSignal;
}
await _setState(current.copyWith(preferenceSignals: nextSignals));
return nextSignal;
}
Future<void> upsertPreferenceSignal(PersonPreferenceSignal signal) async {
final LocalDataState current = _requireState();
if (!_personExists(current.people, signal.personId)) {
throw ArgumentError('Profile does not exist for preference signal');
}
final List<PersonPreferenceSignal> nextSignals = _upsertItem(
items: current.preferenceSignals,
item: signal,
idOf: (PersonPreferenceSignal value) => value.id,
);
await _setState(current.copyWith(preferenceSignals: nextSignals));
}
Future<void> setPreferenceSignalStatus({
required String signalId,
required PreferenceSignalStatus status,
}) async {
final LocalDataState current = _requireState();
final List<PersonPreferenceSignal> nextSignals = current.preferenceSignals
.map((PersonPreferenceSignal signal) {
if (signal.id != signalId) {
return signal;
}
return signal.copyWith(status: status);
})
.toList(growable: false);
await _setState(current.copyWith(preferenceSignals: nextSignals));
}
Future<void> confirmPreferenceSignal(String signalId) {
return setPreferenceSignalStatus(
signalId: signalId,
status: PreferenceSignalStatus.confirmed,
);
}
Future<void> dismissPreferenceSignal(String signalId) {
return setPreferenceSignalStatus(
signalId: signalId,
status: PreferenceSignalStatus.dismissed,
);
}
Future<void> toggleTaskDone(String taskId) async {
final LocalDataState current = _requireState();
final List<DashboardTask> tasks = current.tasks
@@ -1026,6 +1188,11 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
reminders: current.reminders
.where((ReminderRule reminder) => reminder.personId != personId)
.toList(growable: false),
preferenceSignals: current.preferenceSignals
.where(
(PersonPreferenceSignal signal) => signal.personId != personId,
)
.toList(growable: false),
);
}
@@ -1419,6 +1586,65 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
return fallback;
}
bool _personExists(List<PersonProfile> people, String personId) {
return people.any((PersonProfile person) => person.id == personId);
}
String _normalizePreferenceKey(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9:_ -]+'), '')
.replaceAll(RegExp(r'\s+'), ' ');
}
double _clampUnit(double value) {
if (value.isNaN) {
return 0;
}
return value.clamp(0.0, 1.0).toDouble();
}
List<String> _mergeUniqueStrings(
List<String> existing,
String? incoming, {
int maxItems = 12,
}) {
final List<String> values = <String>[];
final Set<String> seen = <String>{};
for (final String raw in existing) {
final String value = raw.trim();
if (value.isEmpty) {
continue;
}
final String key = value.toLowerCase();
if (seen.add(key)) {
values.add(value);
}
}
final String? newValue = _trimToNull(incoming);
if (newValue != null) {
final String key = newValue.toLowerCase();
if (!seen.contains(key)) {
values.insert(0, newValue);
}
}
if (values.length <= maxItems) {
return values;
}
return values.take(maxItems).toList(growable: false);
}
String _truncate(String value, int maxChars) {
if (value.length <= maxChars) {
return value;
}
return value.substring(0, maxChars);
}
Future<void> _setState(LocalDataState next) async {
state = AsyncData<LocalDataState>(next);
await _persist(next);
@@ -80,6 +80,84 @@ void main() {
expect(afterDelete.moments.length, beforeCount);
});
test('upserts inferred preference signals and updates status', () async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
final LocalDataState state = await container.read(
localRepositoryProvider.future,
);
final String personId = state.people.first.id;
final PersonPreferenceSignal first = await container
.read(localRepositoryProvider.notifier)
.upsertInferredPreferenceSignalObservation(
personId: personId,
key: 'drink:tea',
label: 'Tea',
category: 'drink',
polarity: PreferenceSignalPolarity.like,
confidence: 0.7,
sourceApp: 'whatsapp',
evidenceMessageId: 'sm-1',
evidenceSnippet: 'I prefer tea over coffee in the evening.',
);
final PersonPreferenceSignal second = await container
.read(localRepositoryProvider.notifier)
.upsertInferredPreferenceSignalObservation(
personId: personId,
key: 'drink:tea',
label: 'Tea',
category: 'drink',
polarity: PreferenceSignalPolarity.like,
confidence: 0.9,
sourceApp: 'imessage',
evidenceMessageId: 'sm-2',
evidenceSnippet: 'Tea sounds perfect after dinner.',
);
final LocalDataState afterUpsert = await container.read(
localRepositoryProvider.future,
);
final PersonPreferenceSignal signal = afterUpsert.preferenceSignals
.firstWhere((PersonPreferenceSignal item) => item.id == first.id);
expect(first.id, second.id);
expect(signal.personId, personId);
expect(signal.occurrenceCount, 2);
expect(signal.status, PreferenceSignalStatus.inferred);
expect(signal.confidence, closeTo(0.8, 0.001));
expect(signal.sourceApps, containsAll(<String>['whatsapp', 'imessage']));
expect(signal.evidenceMessageIds, containsAll(<String>['sm-1', 'sm-2']));
await container
.read(localRepositoryProvider.notifier)
.confirmPreferenceSignal(signal.id);
final LocalDataState afterConfirm = await container.read(
localRepositoryProvider.future,
);
expect(
afterConfirm.preferenceSignals
.firstWhere((PersonPreferenceSignal item) => item.id == signal.id)
.status,
PreferenceSignalStatus.confirmed,
);
await container
.read(localRepositoryProvider.notifier)
.dismissPreferenceSignal(signal.id);
final LocalDataState afterDismiss = await container.read(
localRepositoryProvider.future,
);
expect(
afterDismiss.preferenceSignals
.firstWhere((PersonPreferenceSignal item) => item.id == signal.id)
.status,
PreferenceSignalStatus.dismissed,
);
});
test(
'ingestSharedMessage auto-creates profile and reuses source link for follow-up messages',
() async {