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 - 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. 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 ## Latest Milestone (2026-02-22): Person Editor Inline Validation + Duplicate Warning
Improved the People add/edit profile experience to make validation explicit and 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 ReminderCadence { daily, weekly, monthly }
enum PreferenceSignalPolarity { like, dislike, neutral }
enum PreferenceSignalStatus { inferred, confirmed, dismissed }
@immutable @immutable
class PersonProfile { class PersonProfile {
const 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 @immutable
class DashboardSummary { class DashboardSummary {
const DashboardSummary({ const DashboardSummary({
@@ -622,6 +757,7 @@ class LocalDataState {
this.sourceLinks = const <SourceProfileLink>[], this.sourceLinks = const <SourceProfileLink>[],
this.sharedMessages = const <SharedMessageEntry>[], this.sharedMessages = const <SharedMessageEntry>[],
this.sharedInbox = const <SharedInboxEntry>[], this.sharedInbox = const <SharedInboxEntry>[],
this.preferenceSignals = const <PersonPreferenceSignal>[],
}); });
final List<PersonProfile> people; final List<PersonProfile> people;
@@ -632,6 +768,7 @@ class LocalDataState {
final List<SourceProfileLink> sourceLinks; final List<SourceProfileLink> sourceLinks;
final List<SharedMessageEntry> sharedMessages; final List<SharedMessageEntry> sharedMessages;
final List<SharedInboxEntry> sharedInbox; final List<SharedInboxEntry> sharedInbox;
final List<PersonPreferenceSignal> preferenceSignals;
LocalDataState copyWith({ LocalDataState copyWith({
List<PersonProfile>? people, List<PersonProfile>? people,
@@ -642,6 +779,7 @@ class LocalDataState {
List<SourceProfileLink>? sourceLinks, List<SourceProfileLink>? sourceLinks,
List<SharedMessageEntry>? sharedMessages, List<SharedMessageEntry>? sharedMessages,
List<SharedInboxEntry>? sharedInbox, List<SharedInboxEntry>? sharedInbox,
List<PersonPreferenceSignal>? preferenceSignals,
}) { }) {
return LocalDataState( return LocalDataState(
people: people ?? this.people, people: people ?? this.people,
@@ -652,6 +790,7 @@ class LocalDataState {
sourceLinks: sourceLinks ?? this.sourceLinks, sourceLinks: sourceLinks ?? this.sourceLinks,
sharedMessages: sharedMessages ?? this.sharedMessages, sharedMessages: sharedMessages ?? this.sharedMessages,
sharedInbox: sharedInbox ?? this.sharedInbox, sharedInbox: sharedInbox ?? this.sharedInbox,
preferenceSignals: preferenceSignals ?? this.preferenceSignals,
); );
} }
@@ -697,6 +836,9 @@ class LocalDataState {
'sharedInbox': sharedInbox 'sharedInbox': sharedInbox
.map((SharedInboxEntry entry) => entry.toJson()) .map((SharedInboxEntry entry) => entry.toJson())
.toList(growable: false), .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>), SharedInboxEntry.fromJson(entry as Map<String, dynamic>),
) )
.toList(growable: false), .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 final List<ReminderRule> reminders = current.reminders
.where((ReminderRule reminder) => reminder.personId != personId) .where((ReminderRule reminder) => reminder.personId != personId)
.toList(growable: false); .toList(growable: false);
final List<PersonPreferenceSignal> preferenceSignals = current
.preferenceSignals
.where((PersonPreferenceSignal signal) => signal.personId != personId)
.toList(growable: false);
await _setState( await _setState(
current.copyWith( current.copyWith(
@@ -488,6 +492,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
moments: moments, moments: moments,
ideas: ideas, ideas: ideas,
reminders: reminders, reminders: reminders,
preferenceSignals: preferenceSignals,
), ),
); );
@@ -642,6 +647,15 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
return entry.copyWith(candidateProfileIds: candidates); return entry.copyWith(candidateProfileIds: candidates);
}) })
.toList(growable: false); .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( await _setState(
current.copyWith( current.copyWith(
@@ -652,6 +666,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
sourceLinks: nextLinks, sourceLinks: nextLinks,
sharedMessages: nextMessages, sharedMessages: nextMessages,
sharedInbox: nextInbox, 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 { Future<void> toggleTaskDone(String taskId) async {
final LocalDataState current = _requireState(); final LocalDataState current = _requireState();
final List<DashboardTask> tasks = current.tasks final List<DashboardTask> tasks = current.tasks
@@ -1026,6 +1188,11 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
reminders: current.reminders reminders: current.reminders
.where((ReminderRule reminder) => reminder.personId != personId) .where((ReminderRule reminder) => reminder.personId != personId)
.toList(growable: false), .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; 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 { Future<void> _setState(LocalDataState next) async {
state = AsyncData<LocalDataState>(next); state = AsyncData<LocalDataState>(next);
await _persist(next); await _persist(next);
@@ -80,6 +80,84 @@ void main() {
expect(afterDelete.moments.length, beforeCount); 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( test(
'ingestSharedMessage auto-creates profile and reuses source link for follow-up messages', 'ingestSharedMessage auto-creates profile and reuses source link for follow-up messages',
() async { () async {