From 8c79ba1345d82dd0b73d75f236baa5727ea98a62 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 22 Feb 2026 23:19:49 +0100 Subject: [PATCH] Add local preference signal scaffolding --- docs/progress.md | 45 ++++ lib/features/local/local_models.dart | 150 ++++++++++++ lib/features/local/local_repository.dart | 226 ++++++++++++++++++ .../features/local/local_repository_test.dart | 78 ++++++ 4 files changed, 499 insertions(+) diff --git a/docs/progress.md b/docs/progress.md index c3e5819..23efadd 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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 diff --git a/lib/features/local/local_models.dart b/lib/features/local/local_models.dart index fafd692..8a8009b 100644 --- a/lib/features/local/local_models.dart +++ b/lib/features/local/local_models.dart @@ -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 [], + this.evidenceMessageIds = const [], + this.evidenceSnippets = const [], + }); + + 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 sourceApps; + final List evidenceMessageIds; + final List 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? sourceApps, + List? evidenceMessageIds, + List? 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 toJson() { + return { + '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 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? ?? []) + .map((dynamic item) => '$item') + .toList(growable: false), + evidenceMessageIds: + (json['evidenceMessageIds'] as List? ?? []) + .map((dynamic item) => '$item') + .toList(growable: false), + evidenceSnippets: + (json['evidenceSnippets'] as List? ?? []) + .map((dynamic item) => '$item') + .toList(growable: false), + ); + } +} + @immutable class DashboardSummary { const DashboardSummary({ @@ -622,6 +757,7 @@ class LocalDataState { this.sourceLinks = const [], this.sharedMessages = const [], this.sharedInbox = const [], + this.preferenceSignals = const [], }); final List people; @@ -632,6 +768,7 @@ class LocalDataState { final List sourceLinks; final List sharedMessages; final List sharedInbox; + final List preferenceSignals; LocalDataState copyWith({ List? people, @@ -642,6 +779,7 @@ class LocalDataState { List? sourceLinks, List? sharedMessages, List? sharedInbox, + List? 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), ) .toList(growable: false), + preferenceSignals: + (json['preferenceSignals'] as List? ?? []) + .map( + (dynamic signal) => PersonPreferenceSignal.fromJson( + signal as Map, + ), + ) + .toList(growable: false), ); } diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index 2263cfc..d6492e6 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -481,6 +481,10 @@ class LocalRepository extends AsyncNotifier { final List reminders = current.reminders .where((ReminderRule reminder) => reminder.personId != personId) .toList(growable: false); + final List preferenceSignals = current + .preferenceSignals + .where((PersonPreferenceSignal signal) => signal.personId != personId) + .toList(growable: false); await _setState( current.copyWith( @@ -488,6 +492,7 @@ class LocalRepository extends AsyncNotifier { moments: moments, ideas: ideas, reminders: reminders, + preferenceSignals: preferenceSignals, ), ); @@ -642,6 +647,15 @@ class LocalRepository extends AsyncNotifier { return entry.copyWith(candidateProfileIds: candidates); }) .toList(growable: false); + final List 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 { sourceLinks: nextLinks, sharedMessages: nextMessages, sharedInbox: nextInbox, + preferenceSignals: nextPreferenceSignals, ), ); @@ -964,6 +979,153 @@ class LocalRepository extends AsyncNotifier { ); } + /// 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 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 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 [] : [source], + evidenceMessageIds: messageId == null + ? const [] + : [messageId], + evidenceSnippets: snippet == null + ? const [] + : [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 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 nextSignals = _upsertItem( + items: current.preferenceSignals, + item: signal, + idOf: (PersonPreferenceSignal value) => value.id, + ); + await _setState(current.copyWith(preferenceSignals: nextSignals)); + } + + Future setPreferenceSignalStatus({ + required String signalId, + required PreferenceSignalStatus status, + }) async { + final LocalDataState current = _requireState(); + final List 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 confirmPreferenceSignal(String signalId) { + return setPreferenceSignalStatus( + signalId: signalId, + status: PreferenceSignalStatus.confirmed, + ); + } + + Future dismissPreferenceSignal(String signalId) { + return setPreferenceSignalStatus( + signalId: signalId, + status: PreferenceSignalStatus.dismissed, + ); + } + Future toggleTaskDone(String taskId) async { final LocalDataState current = _requireState(); final List tasks = current.tasks @@ -1026,6 +1188,11 @@ class LocalRepository extends AsyncNotifier { 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 { return fallback; } + bool _personExists(List 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 _mergeUniqueStrings( + List existing, + String? incoming, { + int maxItems = 12, + }) { + final List values = []; + final Set seen = {}; + + 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 _setState(LocalDataState next) async { state = AsyncData(next); await _persist(next); diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index 1ad2cf6..0d51df9 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -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(['whatsapp', 'imessage'])); + expect(signal.evidenceMessageIds, containsAll(['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 {