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