312 lines
8.9 KiB
Dart
312 lines
8.9 KiB
Dart
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;
|
|
}
|