Add chat preference extraction on shared ingest
This commit is contained in:
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user