@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.
@@ -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: null });
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
- return queryResults.length > 0 ? queryResults[0].Duplicates : new PotentialDuplicateResult();
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: null });
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: this.indexName,
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
- duplicate.LoadFromConcatenatedString(match.metadata.RecordID);
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
- // Filter out inverse duplicates: if A→B was already persisted, skip B→A
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
- const sourceKey = qr.SourceKey;
884
- const detail = duplicateRunDetails.find((d) => {
885
- const detailKey = new CompositeKey();
886
- detailKey.LoadFromConcatenatedString(d.RecordID);
887
- return detailKey.Equals(sourceKey);
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.ProbabilityScore < absoluteThreshold) {
1260
+ if (!this.IsAutoMergeEligible(dupe, dupeResult, entityDocument, absoluteThreshold)) {
952
1261
  continue;
953
1262
  }
954
- const mergeParams = new RecordMergeRequest();
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
  */