From 0347ee1c0a6b9e21d99f9cb8ec3c07e48e675376 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 19 Feb 2026 00:52:20 +0100 Subject: [PATCH] Add stable share fingerprints and near-match conflict routing --- docs/SETUP.md | 4 + docs/progress.md | 28 +++ lib/features/local/local_models.dart | 18 +- lib/features/local/local_repository.dart | 171 ++++++++++++++++++ .../share_intake/share_inbox_view.dart | 1 + .../features/local/local_repository_test.dart | 82 +++++++++ 6 files changed, 303 insertions(+), 1 deletion(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 4ee1156..9d2a7b3 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -101,6 +101,10 @@ Behavior notes: - moment history (`type=whatsapp`) - ambiguous or missing-identity shares are queued in `Share Inbox` for manual resolution +- near-name conflicts now also route to `Share Inbox` to avoid accidental + profile mismatches +- follow-up routing prefers stable source fingerprint matching (when source + user/thread IDs are available) - app launch from initial share intent auto-opens the relevant destination: - resolved import -> `People` with imported profile selected - unresolved import -> `Share Inbox` diff --git a/docs/progress.md b/docs/progress.md index fcf612d..3d5ace6 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -41,6 +41,34 @@ navigation: - `test/features/local/local_repository_test.dart` - verifies merge rebinding across related records +## Latest Milestone (2026-02-18): Stable Share Fingerprints + Near-Match Conflict Detection + +Strengthened WhatsApp identity mapping to reduce accidental mis-links: + +- Added stable source fingerprint strategy: + - `lib/features/local/local_models.dart` + - `SourceProfileLink` now stores optional `sourceFingerprint` + - `SharedInboxEntry` also keeps the same fingerprint when unresolved + - fingerprint is derived from stable source identifiers (`sourceApp` + + source user/thread ids) +- Updated ingest matching order: + - `lib/features/local/local_repository.dart` + - fingerprint match is now the primary lookup path + - backward-compatible fallback to existing user/thread/display matching + - legacy links without stored fingerprint still match by derived fingerprint +- Added near-match conflict handling: + - new inbox reason: `nearProfileConflict` + - fuzzy name similarity heuristic (edit-distance + containment) now detects + likely profile conflicts and routes to Share Inbox for explicit user choice +- Updated Share Inbox UI reason labels: + - `lib/features/share_intake/share_inbox_view.dart` +- Added test coverage: + - `test/features/local/local_repository_test.dart` + - verifies: + - fingerprint persistence on source links + - near-match conflict queueing + - follow-up routing succeeds via fingerprint after manual resolution + ## Latest Milestone (2026-02-18): Share Inbox Resolution Flow Added manual resolution UX and data paths for ambiguous/unidentifiable WhatsApp diff --git a/lib/features/local/local_models.dart b/lib/features/local/local_models.dart index 3e620a5..fafd692 100644 --- a/lib/features/local/local_models.dart +++ b/lib/features/local/local_models.dart @@ -335,12 +335,14 @@ class SourceProfileLink { required this.lastSeenAt, this.sourceUserId, this.sourceThreadId, + this.sourceFingerprint, }); final String id; final String sourceApp; final String? sourceUserId; final String? sourceThreadId; + final String? sourceFingerprint; final String normalizedDisplayName; final String profileId; final DateTime firstSeenAt; @@ -351,6 +353,7 @@ class SourceProfileLink { String? sourceApp, String? sourceUserId, String? sourceThreadId, + String? sourceFingerprint, String? normalizedDisplayName, String? profileId, DateTime? firstSeenAt, @@ -361,6 +364,7 @@ class SourceProfileLink { sourceApp: sourceApp ?? this.sourceApp, sourceUserId: sourceUserId ?? this.sourceUserId, sourceThreadId: sourceThreadId ?? this.sourceThreadId, + sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint, normalizedDisplayName: normalizedDisplayName ?? this.normalizedDisplayName, profileId: profileId ?? this.profileId, @@ -375,6 +379,7 @@ class SourceProfileLink { 'sourceApp': sourceApp, 'sourceUserId': sourceUserId, 'sourceThreadId': sourceThreadId, + 'sourceFingerprint': sourceFingerprint, 'normalizedDisplayName': normalizedDisplayName, 'profileId': profileId, 'firstSeenAt': firstSeenAt.toUtc().toIso8601String(), @@ -388,6 +393,7 @@ class SourceProfileLink { sourceApp: json['sourceApp'] as String, sourceUserId: json['sourceUserId'] as String?, sourceThreadId: json['sourceThreadId'] as String?, + sourceFingerprint: json['sourceFingerprint'] as String?, normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', profileId: json['profileId'] as String, firstSeenAt: DateTime.parse(json['firstSeenAt'] as String).toLocal(), @@ -480,7 +486,11 @@ class SharedMessageEntry { } } -enum SharedInboxReason { ambiguousProfileMatch, missingIdentity } +enum SharedInboxReason { + ambiguousProfileMatch, + nearProfileConflict, + missingIdentity, +} @immutable class SharedInboxEntry { @@ -493,6 +503,7 @@ class SharedInboxEntry { required this.reason, required this.candidateProfileIds, required this.normalizedDisplayName, + this.sourceFingerprint, this.sourceDisplayName, this.sourceUserId, this.sourceThreadId, @@ -506,6 +517,7 @@ class SharedInboxEntry { final SharedInboxReason reason; final List candidateProfileIds; final String normalizedDisplayName; + final String? sourceFingerprint; final String? sourceDisplayName; final String? sourceUserId; final String? sourceThreadId; @@ -519,6 +531,7 @@ class SharedInboxEntry { SharedInboxReason? reason, List? candidateProfileIds, String? normalizedDisplayName, + String? sourceFingerprint, String? sourceDisplayName, String? sourceUserId, String? sourceThreadId, @@ -533,6 +546,7 @@ class SharedInboxEntry { candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds, normalizedDisplayName: normalizedDisplayName ?? this.normalizedDisplayName, + sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint, sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName, sourceUserId: sourceUserId ?? this.sourceUserId, sourceThreadId: sourceThreadId ?? this.sourceThreadId, @@ -549,6 +563,7 @@ class SharedInboxEntry { 'reason': reason.name, 'candidateProfileIds': candidateProfileIds, 'normalizedDisplayName': normalizedDisplayName, + 'sourceFingerprint': sourceFingerprint, 'sourceDisplayName': sourceDisplayName, 'sourceUserId': sourceUserId, 'sourceThreadId': sourceThreadId, @@ -573,6 +588,7 @@ class SharedInboxEntry { .map((dynamic id) => '$id') .toList(growable: false), normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', + sourceFingerprint: json['sourceFingerprint'] as String?, sourceDisplayName: json['sourceDisplayName'] as String?, sourceUserId: json['sourceUserId'] as String?, sourceThreadId: json['sourceThreadId'] as String?, diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index d40c352..2263cfc 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -183,10 +183,16 @@ class LocalRepository extends AsyncNotifier { ); final String? sourceUserId = _trimToNull(input.sourceUserId); final String? sourceThreadId = _trimToNull(input.sourceThreadId); + final String? sourceFingerprint = _buildStableSourceFingerprint( + sourceApp: sourceApp, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + ); final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: sourceApp, + sourceFingerprint: sourceFingerprint, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, normalizedDisplayName: normalizedDisplayName, @@ -215,6 +221,7 @@ class LocalRepository extends AsyncNotifier { sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, + sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.ambiguousProfileMatch, candidateProfileIds: matchingPeople @@ -223,6 +230,31 @@ class LocalRepository extends AsyncNotifier { ); } + final List nearMatchPeople = resolvedPerson == null + ? _findNearMatchPeople( + current.people, + normalizedDisplayName: normalizedDisplayName, + ) + : const []; + if (resolvedPerson == null && nearMatchPeople.isNotEmpty) { + return _queueSharedInbox( + current: current, + sourceApp: sourceApp, + messageText: trimmedMessage, + sharedAt: sharedAt, + receivedAt: now, + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + sourceFingerprint: sourceFingerprint, + normalizedDisplayName: normalizedDisplayName, + reason: SharedInboxReason.nearProfileConflict, + candidateProfileIds: nearMatchPeople + .map((PersonProfile person) => person.id) + .toList(growable: false), + ); + } + bool createdProfile = false; final List nextPeople = current.people.toList( growable: true, @@ -243,6 +275,7 @@ class LocalRepository extends AsyncNotifier { sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, + sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.missingIdentity, candidateProfileIds: const [], @@ -270,6 +303,7 @@ class LocalRepository extends AsyncNotifier { sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, + sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, resolvedPerson: resolvedPerson, matchedLink: matchedLink, @@ -299,6 +333,7 @@ class LocalRepository extends AsyncNotifier { final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: entry.sourceApp, + sourceFingerprint: entry.sourceFingerprint, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, normalizedDisplayName: entry.normalizedDisplayName, @@ -313,6 +348,7 @@ class LocalRepository extends AsyncNotifier { sourceDisplayName: entry.sourceDisplayName, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, + sourceFingerprint: entry.sourceFingerprint, normalizedDisplayName: entry.normalizedDisplayName, resolvedPerson: resolvedPerson, matchedLink: matchedLink, @@ -359,6 +395,7 @@ class LocalRepository extends AsyncNotifier { final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: entry.sourceApp, + sourceFingerprint: entry.sourceFingerprint, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, normalizedDisplayName: entry.normalizedDisplayName, @@ -378,6 +415,7 @@ class LocalRepository extends AsyncNotifier { sourceDisplayName: entry.sourceDisplayName, sourceUserId: entry.sourceUserId, sourceThreadId: entry.sourceThreadId, + sourceFingerprint: entry.sourceFingerprint, normalizedDisplayName: entry.normalizedDisplayName, resolvedPerson: createdPerson, matchedLink: matchedLink, @@ -1489,6 +1527,7 @@ class LocalRepository extends AsyncNotifier { required SharedInboxReason reason, required List candidateProfileIds, required String normalizedDisplayName, + String? sourceFingerprint, String? sourceDisplayName, String? sourceUserId, String? sourceThreadId, @@ -1505,6 +1544,7 @@ class LocalRepository extends AsyncNotifier { reason: reason, candidateProfileIds: candidateProfileIds, normalizedDisplayName: normalizedDisplayName, + sourceFingerprint: sourceFingerprint, sourceDisplayName: sourceDisplayName, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, @@ -1526,6 +1566,7 @@ class LocalRepository extends AsyncNotifier { required DateTime sharedAt, required DateTime importedAt, required String normalizedDisplayName, + required String? sourceFingerprint, required PersonProfile resolvedPerson, required bool createdProfile, required List people, @@ -1548,6 +1589,7 @@ class LocalRepository extends AsyncNotifier { sourceApp: sourceApp, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, + sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, profileId: resolvedPerson.id, firstSeenAt: sharedAt, @@ -1562,6 +1604,7 @@ class LocalRepository extends AsyncNotifier { nextLinks[index] = matchedLink.copyWith( sourceUserId: sourceUserId ?? matchedLink.sourceUserId, sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId, + sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint, normalizedDisplayName: normalizedDisplayName.isEmpty ? matchedLink.normalizedDisplayName : normalizedDisplayName, @@ -1655,10 +1698,27 @@ class LocalRepository extends AsyncNotifier { SourceProfileLink? _findExistingSourceLink({ required List links, required String sourceApp, + required String? sourceFingerprint, required String? sourceUserId, required String? sourceThreadId, required String normalizedDisplayName, }) { + if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) { + for (final SourceProfileLink link in links) { + final String? linkFingerprint = + link.sourceFingerprint ?? + _buildStableSourceFingerprint( + sourceApp: link.sourceApp, + sourceUserId: link.sourceUserId, + sourceThreadId: link.sourceThreadId, + ); + if (link.sourceApp == sourceApp && + linkFingerprint == sourceFingerprint) { + return link; + } + } + } + if (sourceUserId != null && sourceUserId.isNotEmpty) { for (final SourceProfileLink link in links) { if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) { @@ -1709,6 +1769,98 @@ class LocalRepository extends AsyncNotifier { .toList(growable: false); } + List _findNearMatchPeople( + List people, { + required String normalizedDisplayName, + }) { + if (normalizedDisplayName.isEmpty) { + return const []; + } + + final Map distanceById = {}; + final List candidates = []; + for (final PersonProfile person in people) { + final String personName = _normalizeDisplayName(person.name); + if (personName.isEmpty || personName == normalizedDisplayName) { + continue; + } + final int distance = _levenshteinDistance( + normalizedDisplayName, + personName, + ); + if (_isNearDisplayName( + normalizedDisplayName, + personName, + distance: distance, + )) { + distanceById[person.id] = distance; + candidates.add(person); + } + } + + candidates.sort((PersonProfile a, PersonProfile b) { + final int left = distanceById[a.id] ?? 999; + final int right = distanceById[b.id] ?? 999; + if (left != right) { + return left.compareTo(right); + } + return a.name.length.compareTo(b.name.length); + }); + if (candidates.length <= 5) { + return candidates; + } + return candidates.take(5).toList(growable: false); + } + + bool _isNearDisplayName(String left, String right, {required int distance}) { + final int maxLength = left.length > right.length + ? left.length + : right.length; + final int minLength = left.length < right.length + ? left.length + : right.length; + if (minLength >= 4 && (left.contains(right) || right.contains(left))) { + return true; + } + final int threshold = maxLength >= 14 ? 3 : 2; + if (distance <= threshold) { + return true; + } + return maxLength >= 8 && (distance / maxLength) <= 0.22; + } + + int _levenshteinDistance(String left, String right) { + if (left == right) { + return 0; + } + if (left.isEmpty) { + return right.length; + } + if (right.isEmpty) { + return left.length; + } + + List previous = List.generate( + right.length + 1, + (int index) => index, + growable: false, + ); + for (int i = 1; i <= left.length; i += 1) { + final List current = List.filled(right.length + 1, 0); + current[0] = i; + for (int j = 1; j <= right.length; j += 1) { + final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1; + final int deletion = previous[j] + 1; + final int insertion = current[j - 1] + 1; + final int substitution = previous[j - 1] + substitutionCost; + final int value = deletion < insertion ? deletion : insertion; + current[j] = value < substitution ? value : substitution; + } + previous = current; + } + return previous[right.length]; + } + SharedInboxEntry _requireSharedInboxEntry( LocalDataState current, String inboxEntryId, @@ -1731,6 +1883,25 @@ class LocalRepository extends AsyncNotifier { _trimToNull(sourceThreadId); } + String? _buildStableSourceFingerprint({ + required String sourceApp, + required String? sourceUserId, + required String? sourceThreadId, + }) { + final String? userId = _trimToNull(sourceUserId); + final String? threadId = _trimToNull(sourceThreadId); + if (userId == null && threadId == null) { + return null; + } + + final List parts = [ + sourceApp.trim().toLowerCase(), + if (userId != null) 'u:${userId.toLowerCase()}', + if (threadId != null) 't:${threadId.toLowerCase()}', + ]; + return parts.join('|'); + } + String _normalizeDisplayName(String? raw) { if (raw == null) { return ''; diff --git a/lib/features/share_intake/share_inbox_view.dart b/lib/features/share_intake/share_inbox_view.dart index 01d3a60..233821e 100644 --- a/lib/features/share_intake/share_inbox_view.dart +++ b/lib/features/share_intake/share_inbox_view.dart @@ -291,6 +291,7 @@ class _InboxEntryCard extends StatelessWidget { final bool hasCandidates = suggestedMatches.isNotEmpty; final String reasonLabel = switch (entry.reason) { SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match', + SharedInboxReason.nearProfileConflict => 'Near match conflict', SharedInboxReason.missingIdentity => 'Missing identity', }; diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index 4c0a00d..1ad2cf6 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -106,6 +106,10 @@ void main() { ); expect(first.createdProfile, isTrue); expect(afterFirst.people.length, before.people.length + 1); + final SourceProfileLink createdLink = afterFirst.sourceLinks.firstWhere( + (SourceProfileLink link) => link.profileId == first.profileId, + ); + expect(createdLink.sourceFingerprint, 'whatsapp|u:wa:taylor quinn'); expect( afterFirst.sourceLinks.any( (SourceProfileLink link) => link.profileId == first.profileId, @@ -144,6 +148,84 @@ void main() { }, ); + test( + 'queues near-match conflict and then uses fingerprint mapping for follow-up shares', + () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + await container.read(localRepositoryProvider.future); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Marek Havel', + relationship: 'Friend', + notes: '', + tags: const [], + ); + final LocalDataState peopleState = await container.read( + localRepositoryProvider.future, + ); + final String marekId = peopleState.people + .firstWhere((PersonProfile person) => person.name == 'Marek Havel') + .id; + + final SharedMessageIngestResult queued = await container + .read(localRepositoryProvider.notifier) + .ingestSharedMessage( + const SharedMessageIngestInput( + sourceApp: 'whatsapp', + sourceDisplayName: 'Marek Havl', + sourceUserId: 'wa:marek_882', + messageText: 'Saw this and thought of you.', + ), + ); + LocalDataState afterQueue = await container.read( + localRepositoryProvider.future, + ); + expect(queued.isQueuedForResolution, isTrue); + expect( + afterQueue.sharedInbox.first.reason, + SharedInboxReason.nearProfileConflict, + ); + expect( + afterQueue.sharedInbox.first.candidateProfileIds, + contains(marekId), + ); + + final SharedMessageIngestResult resolved = await container + .read(localRepositoryProvider.notifier) + .resolveSharedInboxToExistingProfile( + inboxEntryId: queued.inboxEntryId!, + profileId: marekId, + ); + expect(resolved.profileId, marekId); + + final SharedMessageIngestResult followUp = await container + .read(localRepositoryProvider.notifier) + .ingestSharedMessage( + const SharedMessageIngestInput( + sourceApp: 'whatsapp', + sourceDisplayName: 'Alias Name', + sourceUserId: 'wa:marek_882', + messageText: 'Second follow-up message.', + ), + ); + afterQueue = await container.read(localRepositoryProvider.future); + expect(followUp.isQueuedForResolution, isFalse); + expect(followUp.profileId, marekId); + expect(afterQueue.sharedInbox, isEmpty); + expect( + afterQueue.sourceLinks.any( + (SourceProfileLink link) => + link.profileId == marekId && + link.sourceFingerprint == 'whatsapp|u:wa:marek_882', + ), + isTrue, + ); + }, + ); + test( 'queues ambiguous shared message into inbox for manual resolution', () async {