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
|
- 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.
|
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)
|
## Latest Milestone (2026-02-22): Local Preference Signal Scaffolding (Inferred/Confirmed)
|
||||||
|
|
||||||
Added a local-first data model and repository APIs for chat-derived preference
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/core/config/app_config.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/local_models.dart';
|
||||||
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
|
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
|
||||||
import 'package:relationship_saver/features/local/storage/local_data_store_hive.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 int _syncSchemaVersion = 1;
|
||||||
|
|
||||||
static const Uuid _uuid = Uuid();
|
static const Uuid _uuid = Uuid();
|
||||||
|
static const ChatPreferenceExtractor _chatPreferenceExtractor =
|
||||||
|
ChatPreferenceExtractor();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocalDataState> build() async {
|
Future<LocalDataState> build() async {
|
||||||
@@ -1856,8 +1859,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
moment,
|
moment,
|
||||||
...current.moments,
|
...current.moments,
|
||||||
];
|
];
|
||||||
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
|
final SharedMessageEntry sharedEntry = SharedMessageEntry(
|
||||||
SharedMessageEntry(
|
|
||||||
id: 'sm-${_uuid.v4()}',
|
id: 'sm-${_uuid.v4()}',
|
||||||
sourceApp: sourceApp,
|
sourceApp: sourceApp,
|
||||||
profileId: resolvedPerson.id,
|
profileId: resolvedPerson.id,
|
||||||
@@ -1868,7 +1870,9 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
sourceDisplayName: sourceDisplayName,
|
sourceDisplayName: sourceDisplayName,
|
||||||
sourceUserId: sourceUserId,
|
sourceUserId: sourceUserId,
|
||||||
sourceThreadId: sourceThreadId,
|
sourceThreadId: sourceThreadId,
|
||||||
),
|
);
|
||||||
|
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
|
||||||
|
sharedEntry,
|
||||||
...current.sharedMessages,
|
...current.sharedMessages,
|
||||||
];
|
];
|
||||||
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
|
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
|
||||||
@@ -1906,6 +1910,24 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
];
|
];
|
||||||
await _enqueueChanges(outbound);
|
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(
|
return SharedMessageIngestResult.imported(
|
||||||
profileId: resolvedPerson.id,
|
profileId: resolvedPerson.id,
|
||||||
profileName: resolvedPerson.name,
|
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(
|
test(
|
||||||
'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
|
'queues near-match conflict and then uses fingerprint mapping for follow-up shares',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user