diff --git a/docs/progress.md b/docs/progress.md index 23efadd..0c368b7 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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/don’t 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 diff --git a/lib/features/local/chat_preference_extractor.dart b/lib/features/local/chat_preference_extractor.dart new file mode 100644 index 0000000..bba147a --- /dev/null +++ b/lib/features/local/chat_preference_extractor.dart @@ -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 extract(String messageText) { + final String text = messageText.trim(); + if (text.isEmpty) { + return const []; + } + + final List out = + []; + + 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, [ + 'coffee', + 'tea', + 'matcha', + 'espresso', + 'latte', + 'wine', + 'beer', + 'juice', + ])) { + return 'drink'; + } + if (_containsAny(normalized, [ + 'sushi', + 'ramen', + 'pizza', + 'pasta', + 'burger', + 'brunch', + 'chocolate', + 'dessert', + 'spicy food', + ])) { + return 'food'; + } + if (_containsAny(normalized, [ + 'book', + 'books', + 'bookstore', + 'reading', + 'novels', + 'poetry', + ])) { + return 'hobby'; + } + if (_containsAny(normalized, [ + 'music', + 'jazz', + 'vinyl', + 'concerts', + ])) { + return 'music'; + } + if (_containsAny(normalized, ['morning', 'evening', 'night'])) { + return 'schedule'; + } + if (_containsAny(normalized, ['crowds', 'clubs', 'noise'])) { + return 'environment'; + } + return fallback; + } + + bool _containsAny(String value, List 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+(?[^,.!?]{1,40}?)\s+over\s+(?[^,.!?]{1,40}?)(?=\s+(?:and|but)\b|[,.!?]|$)', + caseSensitive: false, + ); + + static final RegExp _favoritePattern = RegExp( + r'\b(?:my\s+)?favorite(?:\s+(?food|drink|color|music|movie|book))?\s+is\s+(?[^,.!?]{1,40})\b', + caseSensitive: false, + ); + + static final RegExp _positivePattern = RegExp( + r'\b(?:i\s+)?(?:really\s+)?(?:love|like|enjoy)\s+(?[^,.!?]{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+(?[^,.!?]{1,40})\b", + caseSensitive: false, + ); + + static final RegExp _allergyPattern = RegExp( + r'\b(?:i\s+am\s+)?allergic\s+to\s+(?[^,.!?]{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; +} diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index d6492e6..bf95d91 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -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 { static const int _syncSchemaVersion = 1; static const Uuid _uuid = Uuid(); + static const ChatPreferenceExtractor _chatPreferenceExtractor = + ChatPreferenceExtractor(); @override Future build() async { @@ -1856,19 +1859,20 @@ class LocalRepository extends AsyncNotifier { 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 nextMessages = [ - 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 nextInbox = consumedInboxEntryId == null @@ -1906,6 +1910,24 @@ class LocalRepository extends AsyncNotifier { ]; await _enqueueChanges(outbound); + final List 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, diff --git a/test/features/local/chat_preference_extractor_test.dart b/test/features/local/chat_preference_extractor_test.dart new file mode 100644 index 0000000..1cc3d09 --- /dev/null +++ b/test/features/local/chat_preference_extractor_test.dart @@ -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 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 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 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)); + }); +} diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index 0d51df9..f82291d 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -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 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 {