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
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)
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: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/storage/local_data_store.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 Uuid _uuid = Uuid();
static const ChatPreferenceExtractor _chatPreferenceExtractor =
ChatPreferenceExtractor();
@override
Future<LocalDataState> build() async {
@@ -1856,19 +1859,20 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
moment,
...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>[
SharedMessageEntry(
id: 'sm-${_uuid.v4()}',
sourceApp: sourceApp,
profileId: resolvedPerson.id,
messageText: summary,
sharedAt: sharedAt,
importedAt: importedAt,
resolvedAutomatically: resolvedAutomatically,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
),
sharedEntry,
...current.sharedMessages,
];
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
@@ -1906,6 +1910,24 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
];
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(
profileId: resolvedPerson.id,
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(
'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
() async {