@memberjunction/ai-vector-dupe 5.42.0 → 5.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -7
- package/dist/duplicateRecordDetector.d.ts +167 -2
- package/dist/duplicateRecordDetector.d.ts.map +1 -1
- package/dist/duplicateRecordDetector.js +397 -57
- package/dist/duplicateRecordDetector.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/reasoning/DuplicateReasoningProvider.d.ts +84 -0
- package/dist/reasoning/DuplicateReasoningProvider.d.ts.map +1 -0
- package/dist/reasoning/DuplicateReasoningProvider.js +215 -0
- package/dist/reasoning/DuplicateReasoningProvider.js.map +1 -0
- package/dist/reasoning/DuplicateReasoningTypes.d.ts +159 -0
- package/dist/reasoning/DuplicateReasoningTypes.d.ts.map +1 -0
- package/dist/reasoning/DuplicateReasoningTypes.js +14 -0
- package/dist/reasoning/DuplicateReasoningTypes.js.map +1 -0
- package/dist/reasoning/MatchedSetDeltaBuilder.d.ts +66 -0
- package/dist/reasoning/MatchedSetDeltaBuilder.d.ts.map +1 -0
- package/dist/reasoning/MatchedSetDeltaBuilder.js +95 -0
- package/dist/reasoning/MatchedSetDeltaBuilder.js.map +1 -0
- package/dist/reasoning/PromptReasoningProvider.d.ts +32 -0
- package/dist/reasoning/PromptReasoningProvider.d.ts.map +1 -0
- package/dist/reasoning/PromptReasoningProvider.js +95 -0
- package/dist/reasoning/PromptReasoningProvider.js.map +1 -0
- package/package.json +13 -10
|
@@ -22,6 +22,8 @@ import { KnowledgeHubMetadataEngine, } from "@memberjunction/core-entities";
|
|
|
22
22
|
import { VectorBase } from "@memberjunction/ai-vectors";
|
|
23
23
|
import { EntityDocumentTemplateParser, EntityVectorSyncer } from "@memberjunction/ai-vector-sync";
|
|
24
24
|
import { TemplateEngineServer } from "@memberjunction/templates";
|
|
25
|
+
import { DuplicateReasoningProvider } from "./reasoning/DuplicateReasoningProvider.js";
|
|
26
|
+
import { MatchedSetDeltaBuilder } from "./reasoning/MatchedSetDeltaBuilder.js";
|
|
25
27
|
/** Default number of nearest neighbors to retrieve per record */
|
|
26
28
|
const DEFAULT_TOP_K = 5;
|
|
27
29
|
/** Default concurrency limit for parallel vector queries */
|
|
@@ -49,6 +51,12 @@ const SAVE_BATCH_SIZE = 20;
|
|
|
49
51
|
export class DuplicateRecordDetector extends VectorBase {
|
|
50
52
|
constructor() {
|
|
51
53
|
super(...arguments);
|
|
54
|
+
/**
|
|
55
|
+
* The embedding model's API identifier (e.g. 'Xenova/gte-small', 'text-embedding-3-small'),
|
|
56
|
+
* resolved from the entity document's AIModel. Passed to `EmbedTexts` — required by local
|
|
57
|
+
* providers (which load the named ONNX pipeline) and honored by cloud providers.
|
|
58
|
+
*/
|
|
59
|
+
this.embeddingModelAPIName = null;
|
|
52
60
|
/**
|
|
53
61
|
* Tracks already-seen source↔match pairs across the entire run to suppress inverse duplicates.
|
|
54
62
|
* If A→B is persisted, B→A is skipped. Key format: "smallerID::largerID" for consistent ordering.
|
|
@@ -195,10 +203,22 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
195
203
|
const record = records.Results[0];
|
|
196
204
|
const templateParser = EntityDocumentTemplateParser.CreateInstance();
|
|
197
205
|
const templateTexts = await this.GenerateTemplateTexts(templateParser, entityDocument, [record], ContextUser);
|
|
198
|
-
const embedResult = await this.embedding.EmbedTexts({ texts: templateTexts, model:
|
|
206
|
+
const embedResult = await this.embedding.EmbedTexts({ texts: templateTexts, model: this.embeddingModelAPIName });
|
|
199
207
|
const topK = options.TopK ?? DEFAULT_TOP_K;
|
|
200
208
|
const queryResults = await this.QueryDuplicatesForRecords([record], embedResult.vectors, templateTexts, entityDocument, topK, options, this.GetQueryConcurrency(entityDocument));
|
|
201
|
-
|
|
209
|
+
if (queryResults.length === 0) {
|
|
210
|
+
return new PotentialDuplicateResult();
|
|
211
|
+
}
|
|
212
|
+
// Annotate the (non-persisted) single-record result with LLM reasoning when the entity
|
|
213
|
+
// has it enabled and the set clears the gate — same gate as the batch path. There are no
|
|
214
|
+
// match rows to stamp run ids onto here; the verdict rides on the returned result so a
|
|
215
|
+
// real-time "is this a duplicate?" caller gets recommendation + resolved field map too.
|
|
216
|
+
const single = queryResults[0];
|
|
217
|
+
const reasoning = await this.RunReasoningForSet(single, entityInfo, entityDocument, ContextUser);
|
|
218
|
+
if (reasoning) {
|
|
219
|
+
this.applyReasoningToResult(single.Duplicates, reasoning.Output, reasoning.FieldMap);
|
|
220
|
+
}
|
|
221
|
+
return single.Duplicates;
|
|
202
222
|
}
|
|
203
223
|
// ─────────────────────────────────────────────
|
|
204
224
|
// Batch Processing
|
|
@@ -253,7 +273,7 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
253
273
|
// Embed this sub-batch
|
|
254
274
|
this.reportProgress(options, 'Embedding', totalRecords, processedSoFar, matchesSoFar, startTime);
|
|
255
275
|
const subTemplateTexts = await this.GenerateTemplateTexts(templateParser, entityDocument, subRecords, contextUser);
|
|
256
|
-
const subEmbedResult = await this.embedding.EmbedTexts({ texts: subTemplateTexts, model:
|
|
276
|
+
const subEmbedResult = await this.embedding.EmbedTexts({ texts: subTemplateTexts, model: this.embeddingModelAPIName });
|
|
257
277
|
// Query vector DB for each record in the sub-batch with concurrency control
|
|
258
278
|
this.reportProgress(options, 'Querying', totalRecords, processedSoFar, matchesSoFar, startTime);
|
|
259
279
|
const subQueryResults = await this.QueryDuplicatesForRecords(subRecords, subEmbedResult.vectors, subTemplateTexts, entityDocument, topK, options, concurrency);
|
|
@@ -265,7 +285,7 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
265
285
|
await this.FilterNonExistentMatches(allQueryResults, entityInfo);
|
|
266
286
|
// 6e: Persist match results and update run details
|
|
267
287
|
this.reportProgress(options, 'Matching', totalRecords, processedSoFar + records.length, matchesSoFar, startTime);
|
|
268
|
-
const results = await this.PersistMatchResults(allQueryResults, duplicateRunDetails, entityDocument, options, startTime);
|
|
288
|
+
const results = await this.PersistMatchResults(allQueryResults, duplicateRunDetails, entityInfo, entityDocument, options, startTime, contextUser);
|
|
269
289
|
const batchMatches = results.reduce((sum, r) => sum + r.Duplicates.length, 0);
|
|
270
290
|
return { Results: results, MatchesFound: batchMatches };
|
|
271
291
|
}
|
|
@@ -368,6 +388,11 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
368
388
|
return;
|
|
369
389
|
}
|
|
370
390
|
const aiModel = this.GetAIModel(entityDocument.AIModelID);
|
|
391
|
+
// The embedding model's API name (e.g. 'Xenova/gte-small') — local providers REQUIRE it
|
|
392
|
+
// to load the right ONNX pipeline; cloud providers fall back to their own default if absent.
|
|
393
|
+
this.embeddingModelAPIName = aiModel.APIName;
|
|
394
|
+
// Captured for the vector query id when the provider keys by EntityDocumentID (SVS).
|
|
395
|
+
this.entityDocumentID = entityDocument.ID;
|
|
371
396
|
const vectorDB = this.GetVectorDatabase(entityDocument.VectorDatabaseID);
|
|
372
397
|
// Resolve API keys. Empty/null is legitimate for local providers — local
|
|
373
398
|
// embeddings (ONNX runtime) and the in-process SimpleVectorServiceProvider need
|
|
@@ -645,6 +670,10 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
645
670
|
* Execute a vector query — uses hybrid search with RRF when the provider supports it.
|
|
646
671
|
*/
|
|
647
672
|
async executeVectorQuery(vector, templateText, topK, options) {
|
|
673
|
+
// Providers that store vectors in MJ keyed by EntityDocumentID (the in-process
|
|
674
|
+
// SimpleVectorServiceProvider) need the EntityDocumentID as the query id; external/
|
|
675
|
+
// colocated providers key by the logical index name. See VectorDBBase.QueryKeyIsEntityDocumentID.
|
|
676
|
+
const queryId = this.vectorDB.QueryKeyIsEntityDocumentID ? this.entityDocumentID : this.indexName;
|
|
648
677
|
if (this.vectorDB.SupportsHybridSearch && templateText) {
|
|
649
678
|
return this.vectorDB.HybridQuery({
|
|
650
679
|
vector,
|
|
@@ -654,15 +683,15 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
654
683
|
FusionMethod: options.FusionMethod ?? 'rrf',
|
|
655
684
|
includeMetadata: true,
|
|
656
685
|
includeValues: false,
|
|
657
|
-
});
|
|
686
|
+
}, this.CurrentUser);
|
|
658
687
|
}
|
|
659
688
|
return this.vectorDB.QueryIndex({
|
|
660
|
-
id:
|
|
689
|
+
id: queryId,
|
|
661
690
|
vector,
|
|
662
691
|
topK,
|
|
663
692
|
includeMetadata: true,
|
|
664
693
|
includeValues: false,
|
|
665
|
-
});
|
|
694
|
+
}, this.CurrentUser);
|
|
666
695
|
}
|
|
667
696
|
/**
|
|
668
697
|
* Parse raw vector DB matches into a PotentialDuplicateResult.
|
|
@@ -680,7 +709,14 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
680
709
|
continue;
|
|
681
710
|
}
|
|
682
711
|
const duplicate = new PotentialDuplicate();
|
|
683
|
-
|
|
712
|
+
this.loadCandidateKey(duplicate, match.metadata.RecordID, sourceKey);
|
|
713
|
+
// Upstream self-match filter: every record is its own nearest neighbor (cosine ~1.0),
|
|
714
|
+
// so the source record itself comes back as a top "match". Drop it here, the moment we
|
|
715
|
+
// parse it, with a normalized key-string compare that does NOT depend on
|
|
716
|
+
// CompositeKey.Equals downstream — a record must never be flagged as its own duplicate.
|
|
717
|
+
if (sourceKey && this.isSameRecord(duplicate, sourceKey)) {
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
684
720
|
duplicate.ProbabilityScore = match.score;
|
|
685
721
|
// Capture the full vector metadata for rich UI display
|
|
686
722
|
duplicate.VectorMetadata = { ...match.metadata };
|
|
@@ -688,6 +724,42 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
688
724
|
}
|
|
689
725
|
return result;
|
|
690
726
|
}
|
|
727
|
+
/**
|
|
728
|
+
* True when two composite keys identify the same record. Compares the normalized
|
|
729
|
+
* (trimmed, lower-cased) URL-segment representation, so it is robust to UUID-casing
|
|
730
|
+
* differences across platforms (SQL Server upper- vs PostgreSQL lower-case) and to a
|
|
731
|
+
* bare-PK vs concatenated key shape.
|
|
732
|
+
*/
|
|
733
|
+
isSameRecord(a, b) {
|
|
734
|
+
return a.Values().trim().toLowerCase() === b.Values().trim().toLowerCase();
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Populate a candidate's composite key from a vector match's RecordID, tolerant of the two
|
|
738
|
+
* formats different vector providers emit:
|
|
739
|
+
* - **Concatenated** ("ID|<guid>", or "F1|v1||F2|v2" for composite PKs) — e.g. providers that
|
|
740
|
+
* stored the full key. Parsed via {@link CompositeKey.LoadFromConcatenatedString}.
|
|
741
|
+
* - **Bare primary-key value** ("<guid>") — what the in-process SimpleVectorServiceProvider
|
|
742
|
+
* returns, since it stores `MJ: Entity Record Documents.RecordID` as the raw PK value. For
|
|
743
|
+
* this form we rebuild the key using the source record's PK field name(s).
|
|
744
|
+
*
|
|
745
|
+
* Getting this wrong leaves `KeyValuePairs` empty (the bare value has no field delimiter, so
|
|
746
|
+
* `LoadFromConcatenatedString` no-ops), which silently breaks self-match filtering, candidate
|
|
747
|
+
* field-delta loading (the reasoner sees an empty candidate), and `MatchRecordID` persistence.
|
|
748
|
+
*/
|
|
749
|
+
loadCandidateKey(candidate, recordID, sourceKey) {
|
|
750
|
+
if (recordID.includes(CompositeKey.DefaultValueDelimiter)) {
|
|
751
|
+
candidate.LoadFromConcatenatedString(recordID);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const pkFieldNames = sourceKey?.KeyValuePairs?.map(kv => kv.FieldName) ?? [];
|
|
755
|
+
if (pkFieldNames.length === 1) {
|
|
756
|
+
// Bare single-column PK value — pair it with the source's PK field name.
|
|
757
|
+
candidate.KeyValuePairs = [{ FieldName: pkFieldNames[0], Value: recordID }];
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// Composite PK delivered without delimiters, or unknown field names — best effort.
|
|
761
|
+
candidate.LoadFromConcatenatedString(recordID);
|
|
762
|
+
}
|
|
691
763
|
/**
|
|
692
764
|
* Filter out self-matches where the candidate is the same record as the source.
|
|
693
765
|
*/
|
|
@@ -862,50 +934,77 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
862
934
|
}
|
|
863
935
|
/**
|
|
864
936
|
* Persist match results and update run detail records.
|
|
937
|
+
*
|
|
938
|
+
* When LLM reasoning is enabled on the entity document AND a matched set clears the
|
|
939
|
+
* reasoning gate (top vector score >= ReasoningThreshold), reasoning runs ONCE for the
|
|
940
|
+
* whole set; the verdict is written onto every match row's LLM* columns and carried on
|
|
941
|
+
* the result for the auto-merge step. When reasoning is disabled the loop is unchanged.
|
|
865
942
|
*/
|
|
866
|
-
async PersistMatchResults(queryResults, duplicateRunDetails, entityDocument, options, startTime) {
|
|
943
|
+
async PersistMatchResults(queryResults, duplicateRunDetails, entityInfo, entityDocument, options, startTime, contextUser) {
|
|
867
944
|
const results = [];
|
|
868
945
|
let matchesFound = 0;
|
|
869
946
|
for (const qr of queryResults) {
|
|
870
|
-
|
|
871
|
-
const sourceId = qr.SourceKey.Values();
|
|
872
|
-
qr.Duplicates.Duplicates = qr.Duplicates.Duplicates.filter(dupe => {
|
|
873
|
-
const matchId = dupe.Values();
|
|
874
|
-
const pairKey = sourceId < matchId ? `${sourceId}::${matchId}` : `${matchId}::${sourceId}`;
|
|
875
|
-
if (this._seenPairs.has(pairKey)) {
|
|
876
|
-
return false; // Inverse already recorded
|
|
877
|
-
}
|
|
878
|
-
this._seenPairs.add(pairKey);
|
|
879
|
-
return true;
|
|
880
|
-
});
|
|
947
|
+
this.suppressInverseDuplicates(qr);
|
|
881
948
|
results.push(qr.Duplicates);
|
|
882
949
|
matchesFound += qr.Duplicates.Duplicates.length;
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
});
|
|
889
|
-
if (detail) {
|
|
890
|
-
const matchRecords = await this.CreateMatchRecordsForDetail(detail.ID, qr.Duplicates);
|
|
891
|
-
qr.Duplicates.DuplicateRunDetailMatchRecordIDs = matchRecords.map((m) => m.ID);
|
|
892
|
-
detail.MatchStatus = 'Complete';
|
|
893
|
-
const success = await this.SaveEntity(detail);
|
|
894
|
-
if (!success) {
|
|
895
|
-
LogError(`Failed to update Duplicate Run Detail record ${detail.ID}`);
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
else {
|
|
899
|
-
LogError(`No Duplicate Run Detail found for ${qr.SourceKey.ToString()}`);
|
|
950
|
+
// Reasoning gate: runs once per set, only when enabled + above threshold.
|
|
951
|
+
// Returns undefined (and is byte-for-byte inert) when reasoning is disabled.
|
|
952
|
+
const reasoning = await this.RunReasoningForSet(qr, entityInfo, entityDocument, contextUser);
|
|
953
|
+
if (reasoning) {
|
|
954
|
+
this.applyReasoningToResult(qr.Duplicates, reasoning.Output, reasoning.FieldMap);
|
|
900
955
|
}
|
|
956
|
+
await this.persistDetailMatches(qr, duplicateRunDetails, reasoning?.Output);
|
|
901
957
|
this.reportProgress(options, 'Matching', queryResults.length, results.length, matchesFound, startTime);
|
|
902
958
|
}
|
|
903
959
|
return results;
|
|
904
960
|
}
|
|
961
|
+
/**
|
|
962
|
+
* Drop inverse duplicates in place: if A→B was already persisted this run, skip B→A.
|
|
963
|
+
*/
|
|
964
|
+
suppressInverseDuplicates(qr) {
|
|
965
|
+
const sourceId = qr.SourceKey.Values();
|
|
966
|
+
qr.Duplicates.Duplicates = qr.Duplicates.Duplicates.filter(dupe => {
|
|
967
|
+
const matchId = dupe.Values();
|
|
968
|
+
const pairKey = sourceId < matchId ? `${sourceId}::${matchId}` : `${matchId}::${sourceId}`;
|
|
969
|
+
if (this._seenPairs.has(pairKey)) {
|
|
970
|
+
return false; // Inverse already recorded
|
|
971
|
+
}
|
|
972
|
+
this._seenPairs.add(pairKey);
|
|
973
|
+
return true;
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Locate the matching run-detail row, create the per-candidate match records (carrying
|
|
978
|
+
* the set's LLM verdict when present), mark the detail complete, and stamp the created
|
|
979
|
+
* match-record IDs back onto the result (kept in lockstep with Duplicates for auto-merge).
|
|
980
|
+
*/
|
|
981
|
+
async persistDetailMatches(qr, duplicateRunDetails, reasoning) {
|
|
982
|
+
const sourceKey = qr.SourceKey;
|
|
983
|
+
const detail = duplicateRunDetails.find((d) => {
|
|
984
|
+
const detailKey = new CompositeKey();
|
|
985
|
+
detailKey.LoadFromConcatenatedString(d.RecordID);
|
|
986
|
+
return detailKey.Equals(sourceKey);
|
|
987
|
+
});
|
|
988
|
+
if (!detail) {
|
|
989
|
+
LogError(`No Duplicate Run Detail found for ${qr.SourceKey.ToString()}`);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const matchRecords = await this.CreateMatchRecordsForDetail(detail.ID, qr.Duplicates, reasoning);
|
|
993
|
+
qr.Duplicates.DuplicateRunDetailMatchRecordIDs = matchRecords.map((m) => m.ID);
|
|
994
|
+
detail.MatchStatus = 'Complete';
|
|
995
|
+
const success = await this.SaveEntity(detail);
|
|
996
|
+
if (!success) {
|
|
997
|
+
LogError(`Failed to update Duplicate Run Detail record ${detail.ID}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
905
1000
|
/**
|
|
906
1001
|
* Create match records for a single run detail, saving in parallel batches.
|
|
1002
|
+
*
|
|
1003
|
+
* When a per-set {@link DuplicateReasoningOutput} is supplied, each match row gets THIS
|
|
1004
|
+
* candidate's own verdict (recommendation / confidence / reasoning), while the set-level
|
|
1005
|
+
* proposed survivor / field map / run id ride along on every row.
|
|
907
1006
|
*/
|
|
908
|
-
async CreateMatchRecordsForDetail(duplicateRunDetailID, duplicateResult) {
|
|
1007
|
+
async CreateMatchRecordsForDetail(duplicateRunDetailID, duplicateResult, reasoning) {
|
|
909
1008
|
const matchRecords = [];
|
|
910
1009
|
for (const batch of chunkArray(duplicateResult.Duplicates, SAVE_BATCH_SIZE)) {
|
|
911
1010
|
const batchResults = await Promise.all(batch.map(async (dupe) => {
|
|
@@ -919,6 +1018,9 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
919
1018
|
match.ApprovalStatus = 'Pending';
|
|
920
1019
|
match.MergeStatus = 'Pending';
|
|
921
1020
|
match.RecordMetadata = dupe.VectorMetadata ? JSON.stringify(dupe.VectorMetadata) : null;
|
|
1021
|
+
if (reasoning && reasoning.Success) {
|
|
1022
|
+
this.applyReasoningToMatch(match, reasoning, dupe.Values());
|
|
1023
|
+
}
|
|
922
1024
|
const success = await this.SaveEntity(match);
|
|
923
1025
|
return success ? match : null;
|
|
924
1026
|
}));
|
|
@@ -929,6 +1031,213 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
929
1031
|
}
|
|
930
1032
|
return matchRecords;
|
|
931
1033
|
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Write the LLM verdict + run id onto a single match row's generated columns. The
|
|
1036
|
+
* recommendation / confidence / reasoning are taken from THIS candidate's own verdict (judged
|
|
1037
|
+
* independently), so a false-positive candidate reads NotDuplicate even when another candidate
|
|
1038
|
+
* in the same set is a confident Merge. Falls back to the set-level summary only when the
|
|
1039
|
+
* reasoner returned no per-candidate verdict for this record. The proposed survivor + field
|
|
1040
|
+
* map stay set-level (they describe the merge of the set's true duplicates).
|
|
1041
|
+
*
|
|
1042
|
+
* @param candidateRecordID this candidate's record id (matches the input candidate RecordID)
|
|
1043
|
+
*/
|
|
1044
|
+
applyReasoningToMatch(match, reasoning, candidateRecordID) {
|
|
1045
|
+
const verdict = reasoning.CandidateVerdicts.find(v => this.recordIdMatches(v.RecordID, candidateRecordID));
|
|
1046
|
+
match.LLMRecommendation = verdict ? verdict.Recommendation : reasoning.Recommendation;
|
|
1047
|
+
match.LLMConfidence = verdict ? verdict.Confidence : reasoning.Confidence;
|
|
1048
|
+
match.LLMReasoning = (verdict?.Reasoning || reasoning.Reasoning) || null;
|
|
1049
|
+
match.LLMProposedSurvivorRecordID = reasoning.SurvivorRecordID;
|
|
1050
|
+
match.LLMProposedFieldMap = reasoning.FieldChoices.length > 0
|
|
1051
|
+
? JSON.stringify(reasoning.FieldChoices)
|
|
1052
|
+
: null;
|
|
1053
|
+
match.AIPromptRunID = reasoning.AIPromptRunID ?? null;
|
|
1054
|
+
match.AIAgentRunID = reasoning.AIAgentRunID ?? null;
|
|
1055
|
+
}
|
|
1056
|
+
// ─────────────────────────────────────────────
|
|
1057
|
+
// LLM Reasoning (gated, per matched set)
|
|
1058
|
+
// ─────────────────────────────────────────────
|
|
1059
|
+
/**
|
|
1060
|
+
* Run LLM reasoning for one source record's matched set, ONCE, only when:
|
|
1061
|
+
* 1. the entity document has EnableLLMReasoning = true, AND
|
|
1062
|
+
* 2. the set is non-empty, AND
|
|
1063
|
+
* 3. the set's top MatchProbability >= the (non-null) ReasoningThreshold.
|
|
1064
|
+
*
|
|
1065
|
+
* Returns `undefined` in every other case — including when reasoning is disabled — so
|
|
1066
|
+
* the surrounding persist/merge path stays byte-for-byte identical to the vector-only
|
|
1067
|
+
* behavior. A failed reasoning call returns an output with Success = false (never throws),
|
|
1068
|
+
* so one bad set never aborts the run.
|
|
1069
|
+
*/
|
|
1070
|
+
async RunReasoningForSet(qr, entityInfo, entityDocument, contextUser) {
|
|
1071
|
+
if (!this.IsReasoningGateOpen(qr, entityDocument)) {
|
|
1072
|
+
return undefined;
|
|
1073
|
+
}
|
|
1074
|
+
const provider = this.ResolveReasoningProvider(entityDocument);
|
|
1075
|
+
if (!provider) {
|
|
1076
|
+
return undefined;
|
|
1077
|
+
}
|
|
1078
|
+
const input = await this.BuildReasoningInput(qr, entityInfo, entityDocument, contextUser);
|
|
1079
|
+
const output = await provider.Reason(input, { Provider: this._provider, ContextUser: contextUser });
|
|
1080
|
+
if (!output.Success) {
|
|
1081
|
+
LogError(`Reasoning failed for set ${qr.SourceKey.ToString()}: ${output.ErrorMessage ?? 'unknown error'}`);
|
|
1082
|
+
return { Output: output, FieldMap: [] };
|
|
1083
|
+
}
|
|
1084
|
+
// Guard the LLM's structured choices against the actual set before persisting/merging:
|
|
1085
|
+
// - a SurvivorRecordID that isn't one of the set's records is hallucinated → null it out.
|
|
1086
|
+
// - per-field choices are resolved to literal values (and empty overrides dropped) below.
|
|
1087
|
+
this.validateSurvivor(output, input);
|
|
1088
|
+
// Resolve the reasoner's per-field choices to literal survivor values now, while the
|
|
1089
|
+
// matched-set deltas (loaded for the prompt) are in hand.
|
|
1090
|
+
const fieldMap = this.resolveReasoningFieldMap(output, input.FieldDeltas);
|
|
1091
|
+
return { Output: output, FieldMap: fieldMap };
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Null out a hallucinated survivor: the reasoner can return a SurvivorRecordID that isn't
|
|
1095
|
+
* any record in the set (a made-up GUID, or a candidate that was filtered out). Persisting
|
|
1096
|
+
* it would carry a phantom id to the UI and auto-merge. Validate against the set's record
|
|
1097
|
+
* ids (source + candidates) and drop it if it doesn't match.
|
|
1098
|
+
*/
|
|
1099
|
+
validateSurvivor(output, input) {
|
|
1100
|
+
if (!output.SurvivorRecordID) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const setRecordIDs = [input.SourceRecord.RecordID, ...input.Candidates.map(c => c.RecordID)];
|
|
1104
|
+
const matches = setRecordIDs.some(id => this.recordIdMatches(id, output.SurvivorRecordID));
|
|
1105
|
+
if (!matches) {
|
|
1106
|
+
LogError(`Reasoning returned a SurvivorRecordID not in the set ('${output.SurvivorRecordID}') — ignoring.`);
|
|
1107
|
+
output.SurvivorRecordID = null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Resolve the reasoner's per-field survivor choices ({FieldName, SourceRecordID}) into
|
|
1112
|
+
* literal `{FieldName, Value}` entries for {@link RecordMergeRequest.FieldMap}, by reading
|
|
1113
|
+
* the chosen record's value out of the matched-set deltas. Choices whose field or record
|
|
1114
|
+
* can't be located in the deltas are skipped (the survivor's existing value then stands),
|
|
1115
|
+
* so a partial/garbled choice can never inject an undefined override into the merge.
|
|
1116
|
+
*/
|
|
1117
|
+
resolveReasoningFieldMap(output, fieldDeltas) {
|
|
1118
|
+
const resolved = [];
|
|
1119
|
+
for (const choice of output.FieldChoices ?? []) {
|
|
1120
|
+
const delta = fieldDeltas.find(d => d.FieldName === choice.FieldName);
|
|
1121
|
+
const cell = delta?.Values.find(v => this.recordIdMatches(v.RecordID, choice.SourceRecordID));
|
|
1122
|
+
// Skip a choice whose resolved value is empty: overriding the survivor's field with an
|
|
1123
|
+
// empty value would BLANK OUT good data. An empty override carries no information, so
|
|
1124
|
+
// the survivor's existing value should stand.
|
|
1125
|
+
if (cell && cell.Value != null && cell.Value.trim().length > 0) {
|
|
1126
|
+
resolved.push({ FieldName: choice.FieldName, Value: cell.Value });
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return resolved;
|
|
1130
|
+
}
|
|
1131
|
+
/** Case-insensitive, trimmed equality of two record-id (URL-segment) strings. */
|
|
1132
|
+
recordIdMatches(a, b) {
|
|
1133
|
+
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Gate predicate: reasoning enabled + non-empty set + top score clears the threshold.
|
|
1137
|
+
* A null ReasoningThreshold means "no gate" — reasoning runs for any non-empty set.
|
|
1138
|
+
*/
|
|
1139
|
+
IsReasoningGateOpen(qr, entityDocument) {
|
|
1140
|
+
if (!entityDocument.EnableLLMReasoning) {
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
const dupes = qr.Duplicates.Duplicates;
|
|
1144
|
+
if (dupes.length === 0) {
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
const threshold = entityDocument.ReasoningThreshold;
|
|
1148
|
+
if (threshold == null) {
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
const topScore = Math.max(...dupes.map(d => d.ProbabilityScore));
|
|
1152
|
+
return topScore >= threshold;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Resolve the reasoning provider for this entity document's ReasoningMode via the
|
|
1156
|
+
* ClassFactory. Returns null when ClassFactory falls back to the abstract base (no
|
|
1157
|
+
* implementation registered for the mode) so the caller can skip gracefully.
|
|
1158
|
+
*/
|
|
1159
|
+
ResolveReasoningProvider(entityDocument) {
|
|
1160
|
+
const provider = MJGlobal.Instance.ClassFactory.CreateInstance(DuplicateReasoningProvider, entityDocument.ReasoningMode);
|
|
1161
|
+
// A bare abstract-base instance means no concrete provider was registered for the
|
|
1162
|
+
// mode (CreateInstance falls back to the base class). Its Reason() is abstract, so
|
|
1163
|
+
// treat it as "no provider" rather than calling and throwing.
|
|
1164
|
+
if (!provider || provider.constructor === DuplicateReasoningProvider) {
|
|
1165
|
+
LogError(`No DuplicateReasoningProvider registered for ReasoningMode '${entityDocument.ReasoningMode}'`);
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
return provider;
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Assemble the reasoning input for a matched set: source description, candidate
|
|
1172
|
+
* descriptions, and the differing-field deltas loaded for the whole set.
|
|
1173
|
+
*/
|
|
1174
|
+
async BuildReasoningInput(qr, entityInfo, entityDocument, contextUser) {
|
|
1175
|
+
// Load the field deltas first — the same pass yields a record-id → name label map, which
|
|
1176
|
+
// gives BOTH the source and the candidates real names (loaded from the records) instead of
|
|
1177
|
+
// raw GUIDs. The vector-metadata name is a weaker fallback; the GUID is the last resort.
|
|
1178
|
+
const deltaBuilder = new MatchedSetDeltaBuilder(this.RunView);
|
|
1179
|
+
const allKeys = [qr.SourceKey, ...qr.Duplicates.Duplicates];
|
|
1180
|
+
const { FieldDeltas: fieldDeltas, Labels: labels } = await deltaBuilder.Build(entityInfo, allKeys, contextUser);
|
|
1181
|
+
const candidates = qr.Duplicates.Duplicates.map(d => ({
|
|
1182
|
+
RecordID: d.Values(),
|
|
1183
|
+
Label: this.resolveRecordLabel(labels, d.Values(), this.reasoningLabel(d.VectorMetadata)),
|
|
1184
|
+
VectorScore: d.ProbabilityScore,
|
|
1185
|
+
Provenance: 'Local',
|
|
1186
|
+
DependentCount: entityInfo.RelatedEntities.length,
|
|
1187
|
+
}));
|
|
1188
|
+
const sourceRecordID = qr.SourceKey.Values();
|
|
1189
|
+
return {
|
|
1190
|
+
EntityName: entityInfo.Name,
|
|
1191
|
+
EntityDescription: entityInfo.Description ?? null,
|
|
1192
|
+
EntityDocument: entityDocument,
|
|
1193
|
+
SourceRecord: {
|
|
1194
|
+
RecordID: sourceRecordID,
|
|
1195
|
+
Label: this.resolveRecordLabel(labels, sourceRecordID, null),
|
|
1196
|
+
Provenance: 'Local',
|
|
1197
|
+
DependentCount: entityInfo.RelatedEntities.length,
|
|
1198
|
+
},
|
|
1199
|
+
Candidates: candidates,
|
|
1200
|
+
FieldDeltas: fieldDeltas,
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Resolve a record's display label: prefer the name loaded from the record (via the delta
|
|
1205
|
+
* builder), then a secondary fallback (e.g. a candidate's vector-metadata name), then the
|
|
1206
|
+
* raw record-id key as a last resort. Guards against the loaded label itself being the
|
|
1207
|
+
* record key (the engine's own fallback) so we still try the secondary source.
|
|
1208
|
+
*/
|
|
1209
|
+
resolveRecordLabel(labels, recordID, fallback) {
|
|
1210
|
+
const loaded = labels.get(recordID);
|
|
1211
|
+
if (loaded && loaded.trim().length > 0 && loaded !== recordID) {
|
|
1212
|
+
return loaded;
|
|
1213
|
+
}
|
|
1214
|
+
if (fallback && fallback.trim().length > 0) {
|
|
1215
|
+
return fallback;
|
|
1216
|
+
}
|
|
1217
|
+
return recordID;
|
|
1218
|
+
}
|
|
1219
|
+
/** Extract a display label from a candidate's vector metadata snapshot, if present. */
|
|
1220
|
+
reasoningLabel(metadata) {
|
|
1221
|
+
if (metadata && typeof metadata['Name'] === 'string' && metadata['Name'].trim().length > 0) {
|
|
1222
|
+
return metadata['Name'];
|
|
1223
|
+
}
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Carry the set-level verdict + resolved survivor field map onto the result so the
|
|
1228
|
+
* auto-merge step (AutoMergeAboveAbsolute) can consult the recommendation and apply the
|
|
1229
|
+
* literal {FieldName, Value} overrides via {@link RecordMergeRequest.FieldMap}. The UI
|
|
1230
|
+
* still reads the persisted per-row {@link MJDuplicateRunDetailMatchEntity.LLMProposedFieldMap}
|
|
1231
|
+
* (the raw choices) and lets the reviewer override before a manual merge.
|
|
1232
|
+
*/
|
|
1233
|
+
applyReasoningToResult(result, output, fieldMap) {
|
|
1234
|
+
if (!output.Success) {
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
result.ReasoningRecommendation = output.Recommendation;
|
|
1238
|
+
result.ReasoningFieldMap = fieldMap.length > 0 ? fieldMap : undefined;
|
|
1239
|
+
result.ReasoningText = output.Reasoning?.trim() ? output.Reasoning : undefined;
|
|
1240
|
+
}
|
|
932
1241
|
// ─────────────────────────────────────────────
|
|
933
1242
|
// Auto-Merge
|
|
934
1243
|
// ─────────────────────────────────────────────
|
|
@@ -948,30 +1257,61 @@ export class DuplicateRecordDetector extends VectorBase {
|
|
|
948
1257
|
const absoluteThreshold = options.AbsoluteMatchThreshold ?? entityDocument.AbsoluteMatchThreshold;
|
|
949
1258
|
for (const dupeResult of response.PotentialDuplicateResult) {
|
|
950
1259
|
for (const [index, dupe] of dupeResult.Duplicates.entries()) {
|
|
951
|
-
if (dupe
|
|
1260
|
+
if (!this.IsAutoMergeEligible(dupe, dupeResult, entityDocument, absoluteThreshold)) {
|
|
952
1261
|
continue;
|
|
953
1262
|
}
|
|
954
|
-
|
|
955
|
-
mergeParams.EntityName = entityDocument.Entity;
|
|
956
|
-
mergeParams.SurvivingRecordCompositeKey = dupeResult.RecordCompositeKey;
|
|
957
|
-
mergeParams.RecordsToMerge = [dupe];
|
|
958
|
-
// Guard each merge so a single failure (e.g. a permission or FK issue on
|
|
959
|
-
// one pair) is logged and skipped rather than aborting the entire run.
|
|
960
|
-
try {
|
|
961
|
-
const mergeResult = await this.Metadata.MergeRecords(mergeParams, this.CurrentUser);
|
|
962
|
-
if (mergeResult.Success) {
|
|
963
|
-
await this.updateMatchRecordAfterMerge(dupeResult.DuplicateRunDetailMatchRecordIDs[index]);
|
|
964
|
-
}
|
|
965
|
-
else {
|
|
966
|
-
LogError(`Failed to merge ${dupeResult.RecordCompositeKey.ToString()} and ${dupe.ToString()}: ${mergeResult.OverallStatus ?? 'unknown error'}`);
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
catch (err) {
|
|
970
|
-
LogError(`Auto-merge threw for ${dupeResult.RecordCompositeKey.ToString()} and ${dupe.ToString()}`, undefined, err);
|
|
971
|
-
}
|
|
1263
|
+
await this.executeAutoMerge(dupe, dupeResult, entityDocument, index);
|
|
972
1264
|
}
|
|
973
1265
|
}
|
|
974
1266
|
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Decide whether a single candidate is eligible for automatic merge.
|
|
1269
|
+
*
|
|
1270
|
+
* Back-compat path (EnableLLMReasoning = false): eligible iff the vector score meets the
|
|
1271
|
+
* absolute threshold — byte-for-byte the original behavior. `AutomationLevel` is ignored.
|
|
1272
|
+
*
|
|
1273
|
+
* Reasoning path (EnableLLMReasoning = true): `AutomationLevel` governs.
|
|
1274
|
+
* - ReviewAll / LLMGated → never auto-merge (everything goes to human review).
|
|
1275
|
+
* - AutoMergeAboveAbsolute → at/above the absolute threshold AND the set's LLM
|
|
1276
|
+
* recommendation is 'Merge'.
|
|
1277
|
+
*/
|
|
1278
|
+
IsAutoMergeEligible(dupe, dupeResult, entityDocument, absoluteThreshold) {
|
|
1279
|
+
const aboveAbsolute = dupe.ProbabilityScore >= absoluteThreshold;
|
|
1280
|
+
if (!entityDocument.EnableLLMReasoning) {
|
|
1281
|
+
return aboveAbsolute;
|
|
1282
|
+
}
|
|
1283
|
+
if (entityDocument.AutomationLevel !== 'AutoMergeAboveAbsolute') {
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
return aboveAbsolute && dupeResult.ReasoningRecommendation === 'Merge';
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Execute one guarded auto-merge for an eligible candidate, applying the LLM's resolved
|
|
1290
|
+
* field map when present, and stamp the match record on success.
|
|
1291
|
+
*/
|
|
1292
|
+
async executeAutoMerge(dupe, dupeResult, entityDocument, index) {
|
|
1293
|
+
const mergeParams = new RecordMergeRequest();
|
|
1294
|
+
mergeParams.EntityName = entityDocument.Entity;
|
|
1295
|
+
mergeParams.SurvivingRecordCompositeKey = dupeResult.RecordCompositeKey;
|
|
1296
|
+
mergeParams.RecordsToMerge = [dupe];
|
|
1297
|
+
if (dupeResult.ReasoningFieldMap && dupeResult.ReasoningFieldMap.length > 0) {
|
|
1298
|
+
mergeParams.FieldMap = dupeResult.ReasoningFieldMap;
|
|
1299
|
+
}
|
|
1300
|
+
// Guard each merge so a single failure (e.g. a permission or FK issue on
|
|
1301
|
+
// one pair) is logged and skipped rather than aborting the entire run.
|
|
1302
|
+
try {
|
|
1303
|
+
const mergeResult = await this.Metadata.MergeRecords(mergeParams, this.CurrentUser);
|
|
1304
|
+
if (mergeResult.Success) {
|
|
1305
|
+
await this.updateMatchRecordAfterMerge(dupeResult.DuplicateRunDetailMatchRecordIDs[index]);
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
LogError(`Failed to merge ${dupeResult.RecordCompositeKey.ToString()} and ${dupe.ToString()}: ${mergeResult.OverallStatus ?? 'unknown error'}`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
catch (err) {
|
|
1312
|
+
LogError(`Auto-merge threw for ${dupeResult.RecordCompositeKey.ToString()} and ${dupe.ToString()}`, undefined, err);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
975
1315
|
/**
|
|
976
1316
|
* Update a match record's status after a successful merge.
|
|
977
1317
|
*/
|