Add local preference signal scaffolding
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user