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; }