Add chat preference extraction on shared ingest

This commit is contained in:
Rijad Zuzo
2026-02-22 23:22:46 +01:00
parent 8c79ba1345
commit 5ffe970179
5 changed files with 494 additions and 12 deletions
+33
View File
@@ -7,6 +7,39 @@ 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): MVP Chat Preference Extractor + Ingest Enrichment
Implemented the first local semantic-enrichment pass so shared chat messages can
start building inferred person preferences automatically.
- `lib/features/local/chat_preference_extractor.dart` (new)
- added a pure Dart, local-first rule-based extractor
- extracts evidence-backed preference candidates from message text:
- `prefer X over Y` (creates like/dislike pair)
- `favorite ... is ...`
- `I love/like/enjoy ...`
- `I hate/dislike/dont like ...`
- `allergic to ...`
- adds category heuristics (e.g. drink/food/hobby/environment/health)
- returns confidence-scored candidates with evidence snippets
- `lib/features/local/local_repository.dart`
- integrated extractor into resolved shared-message ingest flow
- when a chat share is imported and linked to a profile:
- store raw shared message + capture moment (existing behavior)
- extract preference candidates from message text
- upsert local inferred preference signals with evidence + source metadata
- Tests
- `test/features/local/chat_preference_extractor_test.dart` (new)
- extractor unit coverage for positive/negative/comparative/allergy patterns
- `test/features/local/local_repository_test.dart`
- added ingest test proving shared chat import creates inferred signals
- Validation
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-22): Local Preference Signal Scaffolding (Inferred/Confirmed) ## Latest Milestone (2026-02-22): Local Preference Signal Scaffolding (Inferred/Confirmed)
Added a local-first data model and repository APIs for chat-derived preference Added a local-first data model and repository APIs for chat-derived preference
@@ -0,0 +1,311 @@
import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/local/local_models.dart';
/// A lightweight, local-first rule-based extractor for chat-derived preferences.
///
/// This is intentionally conservative and evidence-backed:
/// - returns inferred candidates with confidence
/// - deduplicates by key + polarity
/// - is easy to replace or augment with backend NLP later
class ChatPreferenceExtractor {
const ChatPreferenceExtractor();
static const int _maxCandidates = 8;
/// Extracts preference candidates from a shared chat message.
List<ExtractedPreferenceSignalCandidate> extract(String messageText) {
final String text = messageText.trim();
if (text.isEmpty) {
return const <ExtractedPreferenceSignalCandidate>[];
}
final List<ExtractedPreferenceSignalCandidate> out =
<ExtractedPreferenceSignalCandidate>[];
void addCandidate(ExtractedPreferenceSignalCandidate candidate) {
final bool exists = out.any(
(ExtractedPreferenceSignalCandidate item) =>
item.key == candidate.key && item.polarity == candidate.polarity,
);
if (!exists && out.length < _maxCandidates) {
out.add(candidate);
}
}
for (final RegExpMatch match in _preferOverPattern.allMatches(text)) {
final String? preferred = _cleanPhrase(match.namedGroup('preferred'));
final String? over = _cleanPhrase(match.namedGroup('over'));
if (preferred == null || over == null) {
continue;
}
final String category = _inferCategory(preferred, fallback: 'general');
addCandidate(
_candidate(
phrase: preferred,
category: category,
polarity: PreferenceSignalPolarity.like,
confidence: 0.86,
evidenceSnippet: _extractEvidenceSnippet(text, match),
),
);
addCandidate(
_candidate(
phrase: over,
category: _inferCategory(over, fallback: category),
polarity: PreferenceSignalPolarity.dislike,
confidence: 0.72,
evidenceSnippet: _extractEvidenceSnippet(text, match),
),
);
}
for (final RegExpMatch match in _favoritePattern.allMatches(text)) {
final String? categoryRaw = _cleanPhrase(match.namedGroup('category'));
final String? thing = _cleanPhrase(match.namedGroup('thing'));
if (thing == null) {
continue;
}
final String category = _normalizeCategory(
categoryRaw ?? _inferCategory(thing, fallback: 'general'),
);
addCandidate(
_candidate(
phrase: thing,
category: category,
polarity: PreferenceSignalPolarity.like,
confidence: 0.9,
evidenceSnippet: _extractEvidenceSnippet(text, match),
),
);
}
for (final RegExpMatch match in _positivePattern.allMatches(text)) {
final String? phrase = _cleanPhrase(match.namedGroup('thing'));
if (phrase == null) {
continue;
}
addCandidate(
_candidate(
phrase: phrase,
category: _inferCategory(phrase, fallback: 'general'),
polarity: PreferenceSignalPolarity.like,
confidence: 0.74,
evidenceSnippet: _extractEvidenceSnippet(text, match),
),
);
}
for (final RegExpMatch match in _negativePattern.allMatches(text)) {
final String? phrase = _cleanPhrase(match.namedGroup('thing'));
if (phrase == null) {
continue;
}
addCandidate(
_candidate(
phrase: phrase,
category: _inferCategory(phrase, fallback: 'general'),
polarity: PreferenceSignalPolarity.dislike,
confidence: 0.8,
evidenceSnippet: _extractEvidenceSnippet(text, match),
),
);
}
for (final RegExpMatch match in _allergyPattern.allMatches(text)) {
final String? phrase = _cleanPhrase(match.namedGroup('thing'));
if (phrase == null) {
continue;
}
addCandidate(
_candidate(
phrase: phrase,
category: 'health',
polarity: PreferenceSignalPolarity.dislike,
confidence: 0.95,
evidenceSnippet: _extractEvidenceSnippet(text, match),
),
);
}
return out;
}
ExtractedPreferenceSignalCandidate _candidate({
required String phrase,
required String category,
required PreferenceSignalPolarity polarity,
required double confidence,
required String evidenceSnippet,
}) {
final String normalizedCategory = _normalizeCategory(category);
final String normalizedValue = _normalizeValue(phrase);
return ExtractedPreferenceSignalCandidate(
key: '$normalizedCategory:$normalizedValue',
label: _displayLabel(phrase),
category: normalizedCategory,
polarity: polarity,
confidence: confidence,
evidenceSnippet: evidenceSnippet,
);
}
String _inferCategory(String phrase, {required String fallback}) {
final String normalized = phrase.toLowerCase();
if (_containsAny(normalized, <String>[
'coffee',
'tea',
'matcha',
'espresso',
'latte',
'wine',
'beer',
'juice',
])) {
return 'drink';
}
if (_containsAny(normalized, <String>[
'sushi',
'ramen',
'pizza',
'pasta',
'burger',
'brunch',
'chocolate',
'dessert',
'spicy food',
])) {
return 'food';
}
if (_containsAny(normalized, <String>[
'book',
'books',
'bookstore',
'reading',
'novels',
'poetry',
])) {
return 'hobby';
}
if (_containsAny(normalized, <String>[
'music',
'jazz',
'vinyl',
'concerts',
])) {
return 'music';
}
if (_containsAny(normalized, <String>['morning', 'evening', 'night'])) {
return 'schedule';
}
if (_containsAny(normalized, <String>['crowds', 'clubs', 'noise'])) {
return 'environment';
}
return fallback;
}
bool _containsAny(String value, List<String> probes) {
for (final String probe in probes) {
if (value.contains(probe)) {
return true;
}
}
return false;
}
String _normalizeCategory(String value) {
final String normalized = value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), '_')
.replaceAll(RegExp(r'_+'), '_')
.replaceAll(RegExp(r'^_|_$'), '');
return normalized.isEmpty ? 'general' : normalized;
}
String _normalizeValue(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
String _displayLabel(String value) {
final String clean = value.trim();
if (clean.isEmpty) {
return value;
}
return clean[0].toUpperCase() + clean.substring(1);
}
String? _cleanPhrase(String? raw) {
if (raw == null) {
return null;
}
final String cleaned = raw
.trim()
.replaceAll(RegExp(r'^[\s,:;.-]+'), '')
.replaceAll(RegExp(r'[\s,:;.!?]+$'), '')
.replaceAll(RegExp(r'\s+'), ' ');
if (cleaned.isEmpty) {
return null;
}
if (cleaned.length > 42) {
return cleaned.substring(0, 42).trim();
}
return cleaned;
}
String _extractEvidenceSnippet(String text, RegExpMatch match) {
final int start = match.start < 0 ? 0 : match.start;
final int end = match.end > text.length ? text.length : match.end;
final int prefix = (start - 16).clamp(0, text.length).toInt();
final int suffix = (end + 16).clamp(0, text.length).toInt();
return text.substring(prefix, suffix).trim();
}
static final RegExp _preferOverPattern = RegExp(
r'\b(?:i\s+)?prefer\s+(?<preferred>[^,.!?]{1,40}?)\s+over\s+(?<over>[^,.!?]{1,40}?)(?=\s+(?:and|but)\b|[,.!?]|$)',
caseSensitive: false,
);
static final RegExp _favoritePattern = RegExp(
r'\b(?:my\s+)?favorite(?:\s+(?<category>food|drink|color|music|movie|book))?\s+is\s+(?<thing>[^,.!?]{1,40})\b',
caseSensitive: false,
);
static final RegExp _positivePattern = RegExp(
r'\b(?:i\s+)?(?:really\s+)?(?:love|like|enjoy)\s+(?<thing>[^,.!?]{1,40})\b',
caseSensitive: false,
);
static final RegExp _negativePattern = RegExp(
r"\b(?:i\s+)?(?:(?:really\s+)?(?:hate|dislike)|don't\s+like|do\s+not\s+like|can't\s+stand|cannot\s+stand)\s+(?<thing>[^,.!?]{1,40})\b",
caseSensitive: false,
);
static final RegExp _allergyPattern = RegExp(
r'\b(?:i\s+am\s+)?allergic\s+to\s+(?<thing>[^,.!?]{1,40})\b',
caseSensitive: false,
);
}
@immutable
class ExtractedPreferenceSignalCandidate {
const ExtractedPreferenceSignalCandidate({
required this.key,
required this.label,
required this.category,
required this.polarity,
required this.confidence,
required this.evidenceSnippet,
});
final String key;
final String label;
final String category;
final PreferenceSignalPolarity polarity;
final double confidence;
final String evidenceSnippet;
}
+34 -12
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/local/chat_preference_extractor.dart';
import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/storage/local_data_store.dart'; import 'package:relationship_saver/features/local/storage/local_data_store.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart';
@@ -81,6 +82,8 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
static const int _syncSchemaVersion = 1; static const int _syncSchemaVersion = 1;
static const Uuid _uuid = Uuid(); static const Uuid _uuid = Uuid();
static const ChatPreferenceExtractor _chatPreferenceExtractor =
ChatPreferenceExtractor();
@override @override
Future<LocalDataState> build() async { Future<LocalDataState> build() async {
@@ -1856,19 +1859,20 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
moment, moment,
...current.moments, ...current.moments,
]; ];
final SharedMessageEntry sharedEntry = SharedMessageEntry(
id: 'sm-${_uuid.v4()}',
sourceApp: sourceApp,
profileId: resolvedPerson.id,
messageText: summary,
sharedAt: sharedAt,
importedAt: importedAt,
resolvedAutomatically: resolvedAutomatically,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
);
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[ final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
SharedMessageEntry( sharedEntry,
id: 'sm-${_uuid.v4()}',
sourceApp: sourceApp,
profileId: resolvedPerson.id,
messageText: summary,
sharedAt: sharedAt,
importedAt: importedAt,
resolvedAutomatically: resolvedAutomatically,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
),
...current.sharedMessages, ...current.sharedMessages,
]; ];
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
@@ -1906,6 +1910,24 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
]; ];
await _enqueueChanges(outbound); await _enqueueChanges(outbound);
final List<ExtractedPreferenceSignalCandidate> extractedSignals =
_chatPreferenceExtractor.extract(summary);
for (final ExtractedPreferenceSignalCandidate candidate
in extractedSignals) {
await upsertInferredPreferenceSignalObservation(
personId: resolvedPerson.id,
key: candidate.key,
label: candidate.label,
category: candidate.category,
polarity: candidate.polarity,
confidence: candidate.confidence,
sourceApp: sourceApp,
evidenceMessageId: sharedEntry.id,
evidenceSnippet: candidate.evidenceSnippet,
observedAt: sharedAt,
);
}
return SharedMessageIngestResult.imported( return SharedMessageIngestResult.imported(
profileId: resolvedPerson.id, profileId: resolvedPerson.id,
profileName: resolvedPerson.name, profileName: resolvedPerson.name,
@@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/features/local/chat_preference_extractor.dart';
import 'package:relationship_saver/features/local/local_models.dart';
void main() {
const ChatPreferenceExtractor extractor = ChatPreferenceExtractor();
test('extracts prefer-over like/dislike signals', () {
final List<ExtractedPreferenceSignalCandidate> result = extractor.extract(
'I prefer tea over coffee, especially at night.',
);
expect(
result.any(
(ExtractedPreferenceSignalCandidate item) =>
item.key == 'drink:tea' &&
item.polarity == PreferenceSignalPolarity.like,
),
isTrue,
);
expect(
result.any(
(ExtractedPreferenceSignalCandidate item) =>
item.key == 'drink:coffee' &&
item.polarity == PreferenceSignalPolarity.dislike,
),
isTrue,
);
});
test('extracts positive and negative statements with categories', () {
final List<ExtractedPreferenceSignalCandidate> result = extractor.extract(
"I love bookstores and I don't like loud clubs.",
);
expect(
result.any(
(ExtractedPreferenceSignalCandidate item) =>
item.category == 'hobby' &&
item.polarity == PreferenceSignalPolarity.like,
),
isTrue,
);
expect(
result.any(
(ExtractedPreferenceSignalCandidate item) =>
item.category == 'environment' &&
item.polarity == PreferenceSignalPolarity.dislike,
),
isTrue,
);
});
test('extracts allergy as high-confidence dislike signal', () {
final List<ExtractedPreferenceSignalCandidate> result = extractor.extract(
'I am allergic to peanuts, please remember that.',
);
final ExtractedPreferenceSignalCandidate signal = result.firstWhere(
(ExtractedPreferenceSignalCandidate item) => item.key == 'health:peanuts',
);
expect(signal.polarity, PreferenceSignalPolarity.dislike);
expect(signal.confidence, greaterThan(0.9));
});
}
@@ -226,6 +226,57 @@ void main() {
}, },
); );
test(
'ingestSharedMessage extracts chat-derived preference signals',
() async {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);
await container.read(localRepositoryProvider.future);
final SharedMessageIngestResult result = await container
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
const SharedMessageIngestInput(
sourceApp: 'whatsapp',
sourceDisplayName: 'Nora Diaz',
sourceUserId: 'wa:nora_001',
messageText:
"I prefer tea over coffee and I don't like loud clubs.",
),
);
final LocalDataState after = await container.read(
localRepositoryProvider.future,
);
final List<PersonPreferenceSignal> signals = after.preferenceSignals
.where(
(PersonPreferenceSignal signal) =>
signal.personId == result.profileId,
)
.toList(growable: false);
expect(signals, isNotEmpty);
expect(
signals.any(
(PersonPreferenceSignal signal) =>
signal.key == 'drink:tea' &&
signal.polarity == PreferenceSignalPolarity.like &&
signal.status == PreferenceSignalStatus.inferred,
),
isTrue,
);
expect(
signals.any(
(PersonPreferenceSignal signal) =>
signal.key == 'drink:coffee' &&
signal.polarity == PreferenceSignalPolarity.dislike,
),
isTrue,
);
},
);
test( test(
'queues near-match conflict and then uses fingerprint mapping for follow-up shares', 'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
() async { () async {