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
|
- 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
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user