Add chat preference extraction on shared ingest
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,8 +1859,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
moment,
|
||||
...current.moments,
|
||||
];
|
||||
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
|
||||
SharedMessageEntry(
|
||||
final SharedMessageEntry sharedEntry = SharedMessageEntry(
|
||||
id: 'sm-${_uuid.v4()}',
|
||||
sourceApp: sourceApp,
|
||||
profileId: resolvedPerson.id,
|
||||
@@ -1868,7 +1870,9 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
),
|
||||
);
|
||||
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user