feat: add local-first private AI digest workflow
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
@@ -0,0 +1,883 @@
|
||||
part of 'relationship_repository.dart';
|
||||
|
||||
/// Share capture intake, person resolution, and inbox triage.
|
||||
extension LocalRepositoryShareOperations on LocalRepository {
|
||||
Future<SharedMessageIngestResult> ingestSharedMessage(
|
||||
SharedMessageIngestInput input,
|
||||
) async {
|
||||
final DateTime now = DateTime.now();
|
||||
final SharedPayload payload = SharedPayload(
|
||||
sourceApp: input.sourceApp.trim().toLowerCase(),
|
||||
payloadType: SharedPayloadType.text,
|
||||
rawText: input.messageText.trim(),
|
||||
createdAt: input.sharedAt ?? now,
|
||||
receivedAt: now,
|
||||
platform: 'legacy',
|
||||
sourceDisplayName: _trimToNull(input.sourceDisplayName),
|
||||
sourceUserId: _trimToNull(input.sourceUserId),
|
||||
sourceThreadId: _trimToNull(input.sourceThreadId),
|
||||
dedupeKey: _dedupeKeyForShare(
|
||||
sourceApp: input.sourceApp,
|
||||
rawText: input.messageText,
|
||||
url: null,
|
||||
sourceUserId: input.sourceUserId,
|
||||
sourceThreadId: input.sourceThreadId,
|
||||
),
|
||||
);
|
||||
if (payload.rawText.isEmpty) {
|
||||
throw ArgumentError('Shared message text cannot be empty');
|
||||
}
|
||||
|
||||
final LocalDataState current = _requireState();
|
||||
final String sourceApp = payload.sourceApp;
|
||||
final String? sourceDisplayName = payload.sourceDisplayName;
|
||||
final String normalizedDisplayName = _normalizeDisplayName(
|
||||
sourceDisplayName,
|
||||
);
|
||||
final String? sourceUserId = payload.sourceUserId;
|
||||
final String? sourceThreadId = payload.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,
|
||||
);
|
||||
|
||||
PersonProfile? resolvedPerson;
|
||||
if (matchedLink != null) {
|
||||
resolvedPerson = _findPersonById(current.people, matchedLink.profileId);
|
||||
}
|
||||
|
||||
final List<PersonProfile> matchingPeople = normalizedDisplayName.isEmpty
|
||||
? const <PersonProfile>[]
|
||||
: _findPeopleByNormalizedName(current.people, normalizedDisplayName);
|
||||
|
||||
if (resolvedPerson == null && matchingPeople.length == 1) {
|
||||
resolvedPerson = matchingPeople.first;
|
||||
}
|
||||
|
||||
if (resolvedPerson == null && matchingPeople.length > 1) {
|
||||
return _queueSharedInbox(
|
||||
current: current,
|
||||
payload: payload,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
reason: SharedInboxReason.ambiguousProfileMatch,
|
||||
candidateProfileIds: matchingPeople
|
||||
.map((PersonProfile person) => person.id)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
final List<PersonProfile> nearMatchPeople = resolvedPerson == null
|
||||
? _findNearMatchPeople(
|
||||
current.people,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
)
|
||||
: const <PersonProfile>[];
|
||||
if (resolvedPerson == null && nearMatchPeople.isNotEmpty) {
|
||||
return _queueSharedInbox(
|
||||
current: current,
|
||||
payload: payload,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
reason: SharedInboxReason.nearProfileConflict,
|
||||
candidateProfileIds: nearMatchPeople
|
||||
.map((PersonProfile person) => person.id)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
bool createdProfile = false;
|
||||
final List<PersonProfile> nextPeople = current.people.toList(
|
||||
growable: true,
|
||||
);
|
||||
if (resolvedPerson == null) {
|
||||
final String? inferredName = _deriveAutoProfileName(
|
||||
sourceDisplayName: sourceDisplayName,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
);
|
||||
if (inferredName == null) {
|
||||
return _queueSharedInbox(
|
||||
current: current,
|
||||
payload: payload,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
reason: SharedInboxReason.missingIdentity,
|
||||
candidateProfileIds: const <String>[],
|
||||
);
|
||||
}
|
||||
createdProfile = true;
|
||||
resolvedPerson = PersonProfile(
|
||||
id: 'p-${_uuid.v4()}',
|
||||
name: inferredName,
|
||||
relationship: 'WhatsApp Contact',
|
||||
affinityScore: 70,
|
||||
nextMoment: now.add(const Duration(days: 2)),
|
||||
tags: const <String>['whatsapp'],
|
||||
notes: 'Auto-created from shared message.',
|
||||
aliases: sourceDisplayName == null || sourceDisplayName == inferredName
|
||||
? const <String>[]
|
||||
: <String>[sourceDisplayName],
|
||||
lastUpdatedAt: now,
|
||||
);
|
||||
nextPeople.insert(0, resolvedPerson);
|
||||
}
|
||||
|
||||
return _ingestIntoResolvedProfile(
|
||||
current: current,
|
||||
importedAt: now,
|
||||
payload: payload,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
resolvedPerson: resolvedPerson,
|
||||
matchedLink: matchedLink,
|
||||
createdProfile: createdProfile,
|
||||
people: nextPeople,
|
||||
resolvedAutomatically: true,
|
||||
draft: _defaultDraftForPayload(payload),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> resolveSharedInboxToExistingProfile({
|
||||
required String inboxEntryId,
|
||||
required String profileId,
|
||||
CapturedFactDraft? draft,
|
||||
}) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final SharedInboxEntry entry = _requireSharedInboxEntry(
|
||||
current,
|
||||
inboxEntryId,
|
||||
);
|
||||
final PersonProfile? resolvedPerson = _findPersonById(
|
||||
current.people,
|
||||
profileId,
|
||||
);
|
||||
if (resolvedPerson == null) {
|
||||
throw ArgumentError('Profile not found for inbox entry');
|
||||
}
|
||||
return captureSharedPayloadToExistingProfile(
|
||||
payload: entry.payload,
|
||||
profileId: profileId,
|
||||
draft: draft ?? entry.draft,
|
||||
consumedInboxEntryId: entry.id,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
resolvedAutomatically: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> resolveSharedInboxByCreatingProfile({
|
||||
required String inboxEntryId,
|
||||
String? name,
|
||||
String relationship = 'WhatsApp Contact',
|
||||
String? location,
|
||||
List<String> aliases = const <String>[],
|
||||
CapturedFactDraft? draft,
|
||||
}) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final SharedInboxEntry entry = _requireSharedInboxEntry(
|
||||
current,
|
||||
inboxEntryId,
|
||||
);
|
||||
return captureSharedPayloadByCreatingProfile(
|
||||
payload: entry.payload,
|
||||
name: name,
|
||||
relationship: relationship,
|
||||
location: location,
|
||||
aliases: aliases,
|
||||
draft: draft ?? entry.draft,
|
||||
consumedInboxEntryId: entry.id,
|
||||
normalizedDisplayName: entry.normalizedDisplayName,
|
||||
sourceFingerprint: entry.sourceFingerprint,
|
||||
resolvedAutomatically: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> saveSharedPayloadToInbox({
|
||||
required SharedPayload payload,
|
||||
SharedInboxReason reason = SharedInboxReason.missingIdentity,
|
||||
List<String> candidateProfileIds = const <String>[],
|
||||
String? normalizedDisplayName,
|
||||
String? targetPersonId,
|
||||
String? sourceFingerprint,
|
||||
CapturedFactDraft? draft,
|
||||
}) async {
|
||||
final LocalDataState current = _requireState();
|
||||
return _queueSharedInbox(
|
||||
current: current,
|
||||
payload: payload,
|
||||
sourceFingerprint:
|
||||
sourceFingerprint ??
|
||||
_buildStableSourceFingerprint(
|
||||
sourceApp: payload.sourceApp,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
),
|
||||
normalizedDisplayName:
|
||||
normalizedDisplayName ??
|
||||
_normalizeDisplayName(payload.sourceDisplayName),
|
||||
reason: reason,
|
||||
candidateProfileIds: candidateProfileIds,
|
||||
targetPersonId: targetPersonId,
|
||||
draft: draft,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> captureSharedPayloadToExistingProfile({
|
||||
required SharedPayload payload,
|
||||
required String profileId,
|
||||
CapturedFactDraft? draft,
|
||||
bool resolvedAutomatically = false,
|
||||
String? consumedInboxEntryId,
|
||||
String? normalizedDisplayName,
|
||||
String? sourceFingerprint,
|
||||
}) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final PersonProfile? resolvedPerson = _findPersonById(
|
||||
current.people,
|
||||
profileId,
|
||||
);
|
||||
if (resolvedPerson == null) {
|
||||
throw ArgumentError('Profile not found');
|
||||
}
|
||||
final String effectiveNormalizedDisplayName =
|
||||
normalizedDisplayName ??
|
||||
_normalizeDisplayName(payload.sourceDisplayName);
|
||||
final String? effectiveSourceFingerprint =
|
||||
sourceFingerprint ??
|
||||
_buildStableSourceFingerprint(
|
||||
sourceApp: payload.sourceApp,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
);
|
||||
final SourceProfileLink? matchedLink = _findExistingSourceLink(
|
||||
links: current.sourceLinks,
|
||||
sourceApp: payload.sourceApp,
|
||||
sourceFingerprint: effectiveSourceFingerprint,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
normalizedDisplayName: effectiveNormalizedDisplayName,
|
||||
);
|
||||
return _ingestIntoResolvedProfile(
|
||||
current: current,
|
||||
importedAt: DateTime.now(),
|
||||
payload: payload,
|
||||
sourceFingerprint: effectiveSourceFingerprint,
|
||||
normalizedDisplayName: effectiveNormalizedDisplayName,
|
||||
resolvedPerson: resolvedPerson,
|
||||
matchedLink: matchedLink,
|
||||
createdProfile: false,
|
||||
people: current.people,
|
||||
resolvedAutomatically: resolvedAutomatically,
|
||||
consumedInboxEntryId: consumedInboxEntryId,
|
||||
draft: draft ?? _defaultDraftForPayload(payload),
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> captureSharedPayloadByCreatingProfile({
|
||||
required SharedPayload payload,
|
||||
String? name,
|
||||
String relationship = 'Contact',
|
||||
String? location,
|
||||
List<String> aliases = const <String>[],
|
||||
CapturedFactDraft? draft,
|
||||
bool resolvedAutomatically = false,
|
||||
String? consumedInboxEntryId,
|
||||
String? normalizedDisplayName,
|
||||
String? sourceFingerprint,
|
||||
}) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final DateTime now = DateTime.now();
|
||||
final String profileName =
|
||||
_trimToNull(name) ??
|
||||
_deriveAutoProfileName(
|
||||
sourceDisplayName: payload.sourceDisplayName,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
) ??
|
||||
'New Contact';
|
||||
final String normalizedRelationship = relationship.trim().isEmpty
|
||||
? 'Contact'
|
||||
: relationship.trim();
|
||||
final PersonProfile createdPerson = PersonProfile(
|
||||
id: 'p-${_uuid.v4()}',
|
||||
name: profileName,
|
||||
relationship: normalizedRelationship,
|
||||
affinityScore: 70,
|
||||
nextMoment: now.add(const Duration(days: 2)),
|
||||
tags: <String>[
|
||||
if (payload.sourceApp.trim().isNotEmpty) payload.sourceApp.trim(),
|
||||
],
|
||||
notes: 'Created from shared capture.',
|
||||
aliases: _normalizeAliases(<String>[
|
||||
...aliases,
|
||||
if (payload.sourceDisplayName != null) payload.sourceDisplayName!,
|
||||
]),
|
||||
location: _trimToNull(location),
|
||||
lastUpdatedAt: now,
|
||||
lastInteractedAt: now,
|
||||
);
|
||||
return _ingestIntoResolvedProfile(
|
||||
current: current,
|
||||
importedAt: now,
|
||||
payload: payload,
|
||||
sourceFingerprint:
|
||||
sourceFingerprint ??
|
||||
_buildStableSourceFingerprint(
|
||||
sourceApp: payload.sourceApp,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
),
|
||||
normalizedDisplayName:
|
||||
normalizedDisplayName ??
|
||||
_normalizeDisplayName(payload.sourceDisplayName),
|
||||
resolvedPerson: createdPerson,
|
||||
matchedLink: null,
|
||||
createdProfile: true,
|
||||
people: <PersonProfile>[createdPerson, ...current.people],
|
||||
resolvedAutomatically: resolvedAutomatically,
|
||||
consumedInboxEntryId: consumedInboxEntryId,
|
||||
draft: draft ?? _defaultDraftForPayload(payload),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dismissSharedInboxEntry(String inboxEntryId) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final List<SharedInboxEntry> nextInbox = current.sharedInbox
|
||||
.where((SharedInboxEntry entry) => entry.id != inboxEntryId)
|
||||
.toList(growable: false);
|
||||
if (nextInbox.length == current.sharedInbox.length) {
|
||||
return;
|
||||
}
|
||||
await _setState(current.copyWith(sharedInbox: nextInbox));
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> _queueSharedInbox({
|
||||
required LocalDataState current,
|
||||
required SharedPayload payload,
|
||||
required SharedInboxReason reason,
|
||||
required List<String> candidateProfileIds,
|
||||
required String normalizedDisplayName,
|
||||
String? sourceFingerprint,
|
||||
String? targetPersonId,
|
||||
CapturedFactDraft? draft,
|
||||
}) async {
|
||||
final SharedPayload storedPayload = payload.copyWith(
|
||||
rawText: _truncate(payload.rawText, 1800),
|
||||
dedupeKey:
|
||||
payload.dedupeKey ??
|
||||
_dedupeKeyForShare(
|
||||
sourceApp: payload.sourceApp,
|
||||
rawText: payload.rawText,
|
||||
url: payload.url,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
),
|
||||
);
|
||||
final SharedInboxEntry entry = SharedInboxEntry(
|
||||
id: 'si-${_uuid.v4()}',
|
||||
payload: storedPayload,
|
||||
reason: reason,
|
||||
candidateProfileIds: candidateProfileIds,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
assignmentStatus: ShareAssignmentStatus.savedToInbox,
|
||||
targetPersonId: targetPersonId,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
draft: draft ?? _defaultDraftForPayload(storedPayload),
|
||||
);
|
||||
await _setState(
|
||||
current.copyWith(
|
||||
sharedInbox: <SharedInboxEntry>[entry, ...current.sharedInbox],
|
||||
),
|
||||
);
|
||||
return SharedMessageIngestResult.queuedForResolution(
|
||||
inboxEntryId: entry.id,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedMessageIngestResult> _ingestIntoResolvedProfile({
|
||||
required LocalDataState current,
|
||||
required SharedPayload payload,
|
||||
required DateTime importedAt,
|
||||
required String normalizedDisplayName,
|
||||
required String? sourceFingerprint,
|
||||
required PersonProfile resolvedPerson,
|
||||
required bool createdProfile,
|
||||
required List<PersonProfile> people,
|
||||
required bool resolvedAutomatically,
|
||||
required CapturedFactDraft draft,
|
||||
SourceProfileLink? matchedLink,
|
||||
String? consumedInboxEntryId,
|
||||
}) async {
|
||||
final String sourceApp = payload.sourceApp;
|
||||
final DateTime sharedAt = payload.createdAt;
|
||||
final String? sourceUserId = payload.sourceUserId;
|
||||
final String? sourceThreadId = payload.sourceThreadId;
|
||||
final bool createdSourceLink = matchedLink == null;
|
||||
final List<SourceProfileLink> nextLinks = current.sourceLinks.toList(
|
||||
growable: true,
|
||||
);
|
||||
if (matchedLink == null) {
|
||||
nextLinks.insert(
|
||||
0,
|
||||
SourceProfileLink(
|
||||
id: 'sl-${_uuid.v4()}',
|
||||
sourceApp: sourceApp,
|
||||
sourceUserId: sourceUserId,
|
||||
sourceThreadId: sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName,
|
||||
profileId: resolvedPerson.id,
|
||||
firstSeenAt: sharedAt,
|
||||
lastSeenAt: sharedAt,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final int index = nextLinks.indexWhere(
|
||||
(SourceProfileLink link) => link.id == matchedLink.id,
|
||||
);
|
||||
if (index >= 0) {
|
||||
nextLinks[index] = matchedLink.copyWith(
|
||||
sourceUserId: sourceUserId ?? matchedLink.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId,
|
||||
sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint,
|
||||
normalizedDisplayName: normalizedDisplayName.isEmpty
|
||||
? matchedLink.normalizedDisplayName
|
||||
: normalizedDisplayName,
|
||||
profileId: resolvedPerson.id,
|
||||
lastSeenAt: sharedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final String summary = _truncate(payload.rawText, 1800);
|
||||
final RelationshipMoment moment = RelationshipMoment(
|
||||
id: 'm-${_uuid.v4()}',
|
||||
personId: resolvedPerson.id,
|
||||
title: _titleFromSummary(summary),
|
||||
summary: summary,
|
||||
at: sharedAt,
|
||||
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
|
||||
);
|
||||
|
||||
final SharedMessageEntry sharedEntry = SharedMessageEntry(
|
||||
id: 'sm-${_uuid.v4()}',
|
||||
profileId: resolvedPerson.id,
|
||||
payload: payload.copyWith(
|
||||
rawText: summary,
|
||||
dedupeKey:
|
||||
payload.dedupeKey ??
|
||||
_dedupeKeyForShare(
|
||||
sourceApp: payload.sourceApp,
|
||||
rawText: payload.rawText,
|
||||
url: payload.url,
|
||||
sourceUserId: payload.sourceUserId,
|
||||
sourceThreadId: payload.sourceThreadId,
|
||||
),
|
||||
),
|
||||
importedAt: importedAt,
|
||||
resolvedAutomatically: resolvedAutomatically,
|
||||
assignmentStatus: ShareAssignmentStatus.assignedDirectly,
|
||||
draft: draft,
|
||||
);
|
||||
final List<SharedInboxEntry> nextInbox = consumedInboxEntryId == null
|
||||
? current.sharedInbox
|
||||
: current.sharedInbox
|
||||
.where(
|
||||
(SharedInboxEntry entry) => entry.id != consumedInboxEntryId,
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
await _setState(
|
||||
current.copyWith(
|
||||
people: _touchPeople(
|
||||
people,
|
||||
personId: resolvedPerson.id,
|
||||
updatedAt: importedAt,
|
||||
interactedAt: sharedAt,
|
||||
),
|
||||
moments: <RelationshipMoment>[moment, ...current.moments],
|
||||
sourceLinks: nextLinks,
|
||||
sharedMessages: <SharedMessageEntry>[
|
||||
sharedEntry,
|
||||
...current.sharedMessages,
|
||||
],
|
||||
sharedInbox: nextInbox,
|
||||
personFacts: _applyFactDraftToFacts(
|
||||
current.personFacts,
|
||||
personId: resolvedPerson.id,
|
||||
sharedEntry: sharedEntry,
|
||||
draft: draft,
|
||||
consumedInboxEntryId: consumedInboxEntryId,
|
||||
observedAt: sharedAt,
|
||||
),
|
||||
importantDates: _applyFactDraftToDates(
|
||||
current.importantDates,
|
||||
personId: resolvedPerson.id,
|
||||
draft: draft,
|
||||
sourceApp: payload.sourceApp,
|
||||
consumedInboxEntryId: consumedInboxEntryId,
|
||||
observedAt: sharedAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final List<ChangeEnvelope> outbound = <ChangeEnvelope>[
|
||||
if (createdProfile)
|
||||
_buildEnvelope(
|
||||
entityType: 'person',
|
||||
entityId: resolvedPerson.id,
|
||||
op: ChangeOperation.upsert,
|
||||
payload: _personPayload(resolvedPerson),
|
||||
),
|
||||
_buildEnvelope(
|
||||
entityType: 'capture',
|
||||
entityId: moment.id,
|
||||
op: ChangeOperation.upsert,
|
||||
payload: _momentPayload(moment),
|
||||
),
|
||||
];
|
||||
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,
|
||||
createdProfile: createdProfile,
|
||||
createdSourceLink: createdSourceLink,
|
||||
momentId: moment.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CapturedFactDraft _defaultDraftForPayload(SharedPayload payload) {
|
||||
return ShareCaptureDraftSuggester.suggestForPayload(
|
||||
payload,
|
||||
).copyWith(needsReview: true);
|
||||
}
|
||||
|
||||
String _dedupeKeyForShare({
|
||||
required String sourceApp,
|
||||
required String rawText,
|
||||
required String? url,
|
||||
required String? sourceUserId,
|
||||
required String? sourceThreadId,
|
||||
}) {
|
||||
final String text = rawText.trim().toLowerCase().replaceAll(
|
||||
RegExp(r'\s+'),
|
||||
' ',
|
||||
);
|
||||
return <String>[
|
||||
sourceApp.trim().toLowerCase(),
|
||||
sourceUserId?.trim().toLowerCase() ?? '',
|
||||
sourceThreadId?.trim().toLowerCase() ?? '',
|
||||
url?.trim().toLowerCase() ?? '',
|
||||
text,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
List<PersonFact> _applyFactDraftToFacts(
|
||||
List<PersonFact> currentFacts, {
|
||||
required String personId,
|
||||
required SharedMessageEntry sharedEntry,
|
||||
required CapturedFactDraft draft,
|
||||
required String? consumedInboxEntryId,
|
||||
required DateTime observedAt,
|
||||
}) {
|
||||
if (draft.type == CapturedFactType.importantDate ||
|
||||
draft.text.trim().isEmpty) {
|
||||
return currentFacts;
|
||||
}
|
||||
final PersonFact fact = PersonFact(
|
||||
id: 'pf-${_uuid.v4()}',
|
||||
personId: personId,
|
||||
type: draft.type,
|
||||
text: draft.text.trim(),
|
||||
label: _trimToNull(draft.label),
|
||||
sourceKind: consumedInboxEntryId == null
|
||||
? CaptureSourceKind.shareSheet
|
||||
: CaptureSourceKind.shareInbox,
|
||||
sourceApp: sharedEntry.sourceApp,
|
||||
sourceUrl: sharedEntry.url,
|
||||
sharedMessageId: sharedEntry.id,
|
||||
inboxEntryId: consumedInboxEntryId,
|
||||
confidence: draft.confidence,
|
||||
needsReview: draft.needsReview,
|
||||
isSensitive: draft.isSensitive,
|
||||
createdAt: observedAt,
|
||||
updatedAt: observedAt,
|
||||
);
|
||||
return <PersonFact>[fact, ...currentFacts];
|
||||
}
|
||||
|
||||
List<PersonImportantDate> _applyFactDraftToDates(
|
||||
List<PersonImportantDate> currentDates, {
|
||||
required String personId,
|
||||
required CapturedFactDraft draft,
|
||||
required String sourceApp,
|
||||
required String? consumedInboxEntryId,
|
||||
required DateTime observedAt,
|
||||
}) {
|
||||
if (draft.type != CapturedFactType.importantDate || draft.dateValue == null) {
|
||||
return currentDates;
|
||||
}
|
||||
final PersonImportantDate value = PersonImportantDate(
|
||||
id: 'pd-${_uuid.v4()}',
|
||||
personId: personId,
|
||||
label: _trimToNull(draft.label) ?? draft.text.trim(),
|
||||
date: draft.dateValue!,
|
||||
classification: 'important',
|
||||
sourceKind: consumedInboxEntryId == null
|
||||
? CaptureSourceKind.shareSheet
|
||||
: CaptureSourceKind.shareInbox,
|
||||
sourceApp: sourceApp,
|
||||
inboxEntryId: consumedInboxEntryId,
|
||||
isSensitive: draft.isSensitive,
|
||||
createdAt: observedAt,
|
||||
updatedAt: observedAt,
|
||||
);
|
||||
return <PersonImportantDate>[value, ...currentDates];
|
||||
}
|
||||
|
||||
SourceProfileLink? _findExistingSourceLink({
|
||||
required List<SourceProfileLink> 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) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceThreadId != null && sourceThreadId.isNotEmpty) {
|
||||
for (final SourceProfileLink link in links) {
|
||||
if (link.sourceApp == sourceApp &&
|
||||
link.sourceThreadId == sourceThreadId) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedDisplayName.isNotEmpty) {
|
||||
for (final SourceProfileLink link in links) {
|
||||
if (link.sourceApp == sourceApp &&
|
||||
link.normalizedDisplayName == normalizedDisplayName) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
List<PersonProfile> _findPeopleByNormalizedName(
|
||||
List<PersonProfile> people,
|
||||
String normalizedDisplayName,
|
||||
) {
|
||||
return people
|
||||
.where((PersonProfile person) {
|
||||
if (_normalizeDisplayName(person.name) == normalizedDisplayName) {
|
||||
return true;
|
||||
}
|
||||
for (final String alias in person.aliases) {
|
||||
if (_normalizeDisplayName(alias) == normalizedDisplayName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<PersonProfile> _findNearMatchPeople(
|
||||
List<PersonProfile> people, {
|
||||
required String normalizedDisplayName,
|
||||
}) {
|
||||
if (normalizedDisplayName.isEmpty) {
|
||||
return const <PersonProfile>[];
|
||||
}
|
||||
|
||||
final Map<String, int> distanceById = <String, int>{};
|
||||
final List<PersonProfile> candidates = <PersonProfile>[];
|
||||
for (final PersonProfile person in people) {
|
||||
final List<String> probes = <String>[person.name, ...person.aliases];
|
||||
for (final String probe in probes) {
|
||||
final String personName = _normalizeDisplayName(probe);
|
||||
if (personName.isEmpty || personName == normalizedDisplayName) {
|
||||
continue;
|
||||
}
|
||||
final int distance = _levenshteinDistance(
|
||||
normalizedDisplayName,
|
||||
personName,
|
||||
);
|
||||
if (_isNearDisplayName(
|
||||
normalizedDisplayName,
|
||||
personName,
|
||||
distance: distance,
|
||||
)) {
|
||||
distanceById[person.id] = distance;
|
||||
if (!candidates.any((PersonProfile item) => item.id == person.id)) {
|
||||
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<int> previous = List<int>.generate(
|
||||
right.length + 1,
|
||||
(int index) => index,
|
||||
growable: false,
|
||||
);
|
||||
for (int i = 1; i <= left.length; i += 1) {
|
||||
final List<int> current = List<int>.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,
|
||||
) {
|
||||
for (final SharedInboxEntry entry in current.sharedInbox) {
|
||||
if (entry.id == inboxEntryId) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
throw ArgumentError('Shared inbox entry not found');
|
||||
}
|
||||
|
||||
String? _deriveAutoProfileName({
|
||||
required String? sourceDisplayName,
|
||||
required String? sourceUserId,
|
||||
required String? sourceThreadId,
|
||||
}) {
|
||||
return _trimToNull(sourceDisplayName) ??
|
||||
_trimToNull(sourceUserId) ??
|
||||
_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<String> parts = <String>[
|
||||
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 '';
|
||||
}
|
||||
final String compact = raw
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
|
||||
.trim();
|
||||
return compact.replaceAll(RegExp(r'\s+'), ' ');
|
||||
}
|
||||
Reference in New Issue
Block a user