@memberjunction/ai-vector-sync 5.21.0 → 5.22.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/dist/generic/entityDocumentConfig.types.d.ts +185 -0
- package/dist/generic/entityDocumentConfig.types.d.ts.map +1 -0
- package/dist/generic/entityDocumentConfig.types.js +11 -0
- package/dist/generic/entityDocumentConfig.types.js.map +1 -0
- package/dist/generic/vectorSync.types.d.ts +42 -6
- package/dist/generic/vectorSync.types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/models/AsyncBatchTransform.d.ts +44 -0
- package/dist/models/AsyncBatchTransform.d.ts.map +1 -0
- package/dist/models/AsyncBatchTransform.js +80 -0
- package/dist/models/AsyncBatchTransform.js.map +1 -0
- package/dist/models/EntityDocumentSuggester.d.ts +74 -0
- package/dist/models/EntityDocumentSuggester.d.ts.map +1 -0
- package/dist/models/EntityDocumentSuggester.js +137 -0
- package/dist/models/EntityDocumentSuggester.js.map +1 -0
- package/dist/models/entityVectorSync.d.ts +56 -4
- package/dist/models/entityVectorSync.d.ts.map +1 -1
- package/dist/models/entityVectorSync.js +372 -114
- package/dist/models/entityVectorSync.js.map +1 -1
- package/dist/models/workers/UpsertVectors.js +1 -1
- package/dist/models/workers/UpsertVectors.js.map +1 -1
- package/dist/models/workers/VectorizeTemplates.js +4 -4
- package/dist/models/workers/VectorizeTemplates.js.map +1 -1
- package/package.json +13 -11
|
@@ -1,16 +1,13 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { BaseEmbeddings, GetAIAPIKey } from '@memberjunction/ai';
|
|
2
3
|
import { VectorDBBase } from '@memberjunction/ai-vectordb';
|
|
3
4
|
import { VectorBase } from '@memberjunction/ai-vectors';
|
|
4
|
-
import { LogError, LogStatus, Metadata, RunView } from '@memberjunction/core';
|
|
5
|
+
import { LogError, LogStatus, Metadata, RunView, ValidationResult } from '@memberjunction/core';
|
|
5
6
|
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
6
7
|
import { pipeline } from 'node:stream/promises';
|
|
7
|
-
import { BatchWorker } from './BatchWorker.js';
|
|
8
8
|
import { EntityDocumentCache } from './EntityDocumentCache.js';
|
|
9
9
|
import { PagedRecords } from './PagedRecords.js';
|
|
10
|
-
import {
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
-
const __dirname = dirname(__filename);
|
|
10
|
+
import { AsyncBatchTransform } from './AsyncBatchTransform.js';
|
|
14
11
|
import { PassThrough, Transform } from 'node:stream';
|
|
15
12
|
import { AIEngine } from '@memberjunction/aiengine';
|
|
16
13
|
import { TemplateEngineServer } from '@memberjunction/templates';
|
|
@@ -36,13 +33,16 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
36
33
|
if (!contextUser) {
|
|
37
34
|
throw new Error('ContextUser is required to vectorize the entity');
|
|
38
35
|
}
|
|
39
|
-
const delayTimeMS = 250;
|
|
40
36
|
const startTime = new Date().getTime();
|
|
41
37
|
super.CurrentUser = contextUser;
|
|
42
38
|
await TemplateEngineServer.Instance.Config(false, contextUser);
|
|
43
39
|
const entityDocument = await this.GetEntityDocument(params.entityDocumentID);
|
|
44
40
|
const vectorIndexEntity = await this.GetOrCreateVectorIndex(entityDocument);
|
|
45
41
|
const obj = await this.GetVectorDatabaseAndEmbeddingClassByEntityDocumentID(params.entityDocumentID);
|
|
42
|
+
// Parse configuration for pipeline tuning
|
|
43
|
+
const docConfig = this.parseDocumentConfig(entityDocument);
|
|
44
|
+
const pipelineConfig = docConfig.pipeline;
|
|
45
|
+
const delayTimeMS = pipelineConfig?.delayBetweenCallsMs ?? 250;
|
|
46
46
|
const md = new Metadata();
|
|
47
47
|
const entity = md.Entities.find((e) => UUIDsEqual(e.ID, params.entityID));
|
|
48
48
|
if (!entity) {
|
|
@@ -59,52 +59,310 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
59
59
|
if (template.Content.length > 1) {
|
|
60
60
|
throw new Error('Templates used by Entity Documents should only have one associated Template Content record.');
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const pageSize = params.listBatchCount || 50;
|
|
62
|
+
this.ValidateTemplateContextParamAlignment(template);
|
|
63
|
+
// Pipeline tuning: explicit params override config, config overrides defaults
|
|
64
|
+
const pageSize = params.listBatchCount || pipelineConfig?.fetchBatchSize || 50;
|
|
65
65
|
const dataStream = new PagedRecords();
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
const templateContent = template.Content[0];
|
|
67
|
+
const vectorCreator = this.createVectorCreator(template, templateContent, obj.embedding, delayTimeMS, params.VectorizeBatchCount || pipelineConfig?.vectorizeBatchSize, pipelineConfig?.maxConcurrentEmbeddings);
|
|
68
|
+
const vectorUpserter = this.createVectorUpserter(entityDocument, templateContent, obj.vectorDB, vectorIndexEntity.Name, delayTimeMS, params.UpsertBatchCount || pipelineConfig?.upsertBatchSize);
|
|
69
|
+
const erdUpserter = new AsyncBatchTransform({
|
|
70
|
+
batchSize: 10,
|
|
71
|
+
concurrencyLimit: 2,
|
|
72
|
+
processBatch: async (batch) => {
|
|
73
|
+
await Promise.all(batch.map(item => this.UpsertEntityRecordDocumentRecords(item, super.CurrentUser)));
|
|
74
|
+
return batch;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
// Track progress: count records fed into the paging stream and records that exit the pipeline
|
|
78
|
+
let totalRecordsFed = 0;
|
|
79
|
+
let processedRecords = 0;
|
|
80
|
+
const onProgress = params.OnProgress;
|
|
81
|
+
// Wrap addPage to track total records fed into the stream
|
|
82
|
+
const originalAddPage = dataStream.addPage.bind(dataStream);
|
|
83
|
+
dataStream.addPage = (items) => {
|
|
84
|
+
totalRecordsFed += items.length;
|
|
85
|
+
originalAddPage(items);
|
|
69
86
|
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
// Progress tracker sits at the end of the pipeline and emits updates.
|
|
88
|
+
// Throttled to emit only when the percentage changes to avoid flooding PubSub.
|
|
89
|
+
let lastEmittedPct = -1;
|
|
90
|
+
let dataStreamEnded = false;
|
|
91
|
+
const progressTracker = new Transform({
|
|
92
|
+
objectMode: true,
|
|
93
|
+
transform(chunk, _encoding, callback) {
|
|
94
|
+
processedRecords++;
|
|
95
|
+
if (onProgress) {
|
|
96
|
+
const elapsed = new Date().getTime() - startTime;
|
|
97
|
+
const pct = totalRecordsFed > 0 ? Math.min(Math.round((processedRecords / totalRecordsFed) * 100), 99) : 0;
|
|
98
|
+
// Detect when all records have been processed and data stream is done
|
|
99
|
+
if (dataStreamEnded && processedRecords >= totalRecordsFed) {
|
|
100
|
+
onProgress({
|
|
101
|
+
TotalRecords: totalRecordsFed,
|
|
102
|
+
ProcessedRecords: processedRecords,
|
|
103
|
+
Stage: 'complete',
|
|
104
|
+
PercentComplete: 100,
|
|
105
|
+
ElapsedMs: elapsed,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Throttle: only emit on 5% boundaries to avoid flooding PubSub/WebSocket
|
|
110
|
+
const bucket = Math.floor(pct / 5) * 5;
|
|
111
|
+
if (bucket !== lastEmittedPct) {
|
|
112
|
+
lastEmittedPct = bucket;
|
|
113
|
+
onProgress({
|
|
114
|
+
TotalRecords: totalRecordsFed,
|
|
115
|
+
ProcessedRecords: processedRecords,
|
|
116
|
+
Stage: 'upserting',
|
|
117
|
+
PercentComplete: pct,
|
|
118
|
+
ElapsedMs: elapsed,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
callback(null, chunk);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
// Track when the data stream finishes feeding records
|
|
127
|
+
const originalEndStream = dataStream.endStream.bind(dataStream);
|
|
128
|
+
dataStream.endStream = () => {
|
|
129
|
+
dataStreamEnded = true;
|
|
130
|
+
originalEndStream();
|
|
81
131
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
132
|
+
this.startDataPaging(dataStream, params, md, entity, template, vectorIndexEntity, entityDocument, pageSize);
|
|
133
|
+
LogStatus('Starting pipeline');
|
|
134
|
+
await pipeline(dataStream, vectorCreator, vectorUpserter, erdUpserter, progressTracker, new PassThrough({ objectMode: true }));
|
|
135
|
+
const elapsedMs = new Date().getTime() - startTime;
|
|
136
|
+
const elapsedSeconds = elapsedMs / 1000;
|
|
137
|
+
LogStatus(`Finished vectorizing ${entityDocument.Entity} entity in ${elapsedSeconds} seconds (${(elapsedSeconds / 60).toFixed(1)} minutes)`);
|
|
138
|
+
// Emit final 100% completion
|
|
139
|
+
if (onProgress) {
|
|
140
|
+
onProgress({
|
|
141
|
+
TotalRecords: totalRecordsFed,
|
|
142
|
+
ProcessedRecords: processedRecords,
|
|
143
|
+
Stage: 'complete',
|
|
144
|
+
PercentComplete: 100,
|
|
145
|
+
ElapsedMs: elapsedMs,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return { success: true, status: 'Complete', errorMessage: '' };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Creates an AsyncBatchTransform that renders templates and generates embeddings
|
|
152
|
+
* in the main thread. This replaces the worker_threads-based VectorizeTemplates
|
|
153
|
+
* worker, which couldn't access ClassFactory registrations in its isolated V8 context.
|
|
154
|
+
*/
|
|
155
|
+
createVectorCreator(template, templateContent, embedding, delayTimeMS, batchSize, concurrencyLimit) {
|
|
156
|
+
return new AsyncBatchTransform({
|
|
157
|
+
batchSize: batchSize || 50,
|
|
158
|
+
concurrencyLimit: concurrencyLimit ?? 2,
|
|
159
|
+
processBatch: (batch) => this.renderAndEmbedBatch(batch, template, templateContent, embedding, delayTimeMS),
|
|
89
160
|
});
|
|
90
|
-
|
|
91
|
-
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Creates an AsyncBatchTransform that upserts vectors to the vector database
|
|
164
|
+
* in the main thread. This replaces the worker_threads-based UpsertVectors
|
|
165
|
+
* worker for the same ClassFactory reasons.
|
|
166
|
+
*/
|
|
167
|
+
createVectorUpserter(entityDocument, templateContent, vectorDB, indexName, delayTimeMS, batchSize) {
|
|
168
|
+
return new AsyncBatchTransform({
|
|
169
|
+
batchSize: batchSize || 50,
|
|
170
|
+
concurrencyLimit: 2,
|
|
171
|
+
processBatch: (batch) => this.upsertBatchToVectorDB(batch, entityDocument, templateContent, vectorDB, indexName, delayTimeMS),
|
|
92
172
|
});
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Renders templates for a batch of entity records and generates embeddings for the rendered text.
|
|
176
|
+
*/
|
|
177
|
+
async renderAndEmbedBatch(batch, template, templateContent, embedding, delayTimeMS) {
|
|
178
|
+
TemplateEngineServer.Instance.SetupNunjucks();
|
|
179
|
+
const validEntries = [];
|
|
180
|
+
for (const entityData of batch) {
|
|
181
|
+
const validationResult = this.validateTemplateInput(template, entityData);
|
|
182
|
+
if (!validationResult.Success) {
|
|
183
|
+
LogError(`Validation error for record`, undefined, validationResult.Errors.map(e => e.Message).join('\n'));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const result = await TemplateEngineServer.Instance.RenderTemplate(template, templateContent, entityData, true);
|
|
187
|
+
if (result.Success) {
|
|
188
|
+
validEntries.push({ text: result.Output, record: entityData });
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
LogError(`Error rendering template`, undefined, result.Message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (validEntries.length === 0) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
const embeddings = await embedding.EmbedTexts({ texts: validEntries.map(e => e.text), model: null });
|
|
198
|
+
await new Promise((resolve) => setTimeout(resolve, delayTimeMS));
|
|
199
|
+
return embeddings.vectors.map((vector, index) => ({
|
|
200
|
+
ID: index,
|
|
201
|
+
Vector: vector,
|
|
202
|
+
EntityData: validEntries[index].record,
|
|
203
|
+
__mj_recordID: String(validEntries[index].record.__mj_recordID),
|
|
204
|
+
__mj_compositeKey: String(validEntries[index].record.__mj_compositeKey ?? ''),
|
|
205
|
+
EntityDocument: validEntries[index].record.__mj_entityDocument,
|
|
206
|
+
VectorID: String(validEntries[index].record.VectorID ?? ''),
|
|
207
|
+
VectorIndexID: String(validEntries[index].record.VectorIndexID ?? ''),
|
|
208
|
+
TemplateContent: templateContent.TemplateText,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Upserts a batch of embedding data as vector records into the vector database.
|
|
213
|
+
*/
|
|
214
|
+
/** Default max chars for large text fields (nvarchar(MAX) or MaxLength > 5000) in vector metadata */
|
|
215
|
+
static { this.DEFAULT_LARGE_FIELD_TRUNCATION = 1000; }
|
|
216
|
+
/**
|
|
217
|
+
* Parse the EntityDocumentConfiguration JSON from an entity document.
|
|
218
|
+
* Returns an empty object if the Configuration column is null or invalid JSON.
|
|
219
|
+
*/
|
|
220
|
+
parseDocumentConfig(entityDocument) {
|
|
221
|
+
const raw = entityDocument.Configuration;
|
|
222
|
+
if (!raw)
|
|
223
|
+
return {};
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(raw);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
LogError(`Invalid JSON in EntityDocument.Configuration for "${entityDocument.Name}", using defaults`);
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Get fields to include in vector metadata for display in search results.
|
|
234
|
+
* Respects EntityDocumentConfiguration.metadata.fieldStrategy and per-field overrides.
|
|
235
|
+
*
|
|
236
|
+
* Default behavior (no config or fieldStrategy = "all"):
|
|
237
|
+
* Include all fields except PKs, binary types, and system (__mj_*) fields.
|
|
238
|
+
*
|
|
239
|
+
* "include" strategy: only fields explicitly listed in config.metadata.fields with included=true.
|
|
240
|
+
* "exclude" strategy: all eligible fields except those listed with included=false.
|
|
241
|
+
*/
|
|
242
|
+
getDisplayFields(entityInfo, metadataConfig) {
|
|
243
|
+
if (!entityInfo)
|
|
244
|
+
return [];
|
|
245
|
+
const skipTypes = new Set(['uniqueidentifier', 'varbinary', 'image', 'binary', 'timestamp', 'rowversion']);
|
|
246
|
+
const allEligible = entityInfo.Fields.filter(f => !f.IsPrimaryKey &&
|
|
247
|
+
!f.Name.startsWith('__mj_') &&
|
|
248
|
+
!skipTypes.has(f.Type.toLowerCase()));
|
|
249
|
+
const strategy = metadataConfig?.fieldStrategy ?? 'all';
|
|
250
|
+
const fieldOverrides = metadataConfig?.fields ?? {};
|
|
251
|
+
switch (strategy) {
|
|
252
|
+
case 'include':
|
|
253
|
+
// Only include fields explicitly marked as included
|
|
254
|
+
return allEligible.filter(f => fieldOverrides[f.Name]?.included === true);
|
|
255
|
+
case 'exclude':
|
|
256
|
+
// Include all except those explicitly excluded
|
|
257
|
+
return allEligible.filter(f => fieldOverrides[f.Name]?.included !== false);
|
|
258
|
+
case 'all':
|
|
259
|
+
default:
|
|
260
|
+
// Include everything, but respect individual field exclusions
|
|
261
|
+
return allEligible.filter(f => fieldOverrides[f.Name]?.included !== false);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Get the truncation limit for a field based on its MaxLength and
|
|
266
|
+
* optional per-field or global overrides from EntityDocumentConfiguration.
|
|
267
|
+
*/
|
|
268
|
+
getFieldTruncationLimit(field, metadataConfig) {
|
|
269
|
+
// Check for per-field override first
|
|
270
|
+
const fieldConfig = metadataConfig?.fields?.[field.Name];
|
|
271
|
+
if (fieldConfig?.truncationLimit != null && fieldConfig.truncationLimit > 0) {
|
|
272
|
+
return fieldConfig.truncationLimit;
|
|
273
|
+
}
|
|
274
|
+
// For small fields, use the field's own MaxLength
|
|
275
|
+
if (field.MaxLength && field.MaxLength > 0 && field.MaxLength <= 5000) {
|
|
276
|
+
return field.MaxLength;
|
|
277
|
+
}
|
|
278
|
+
// Large field — use global override or default
|
|
279
|
+
return metadataConfig?.defaultTruncationLimit ?? EntityVectorSyncer.DEFAULT_LARGE_FIELD_TRUNCATION;
|
|
280
|
+
}
|
|
281
|
+
async upsertBatchToVectorDB(batch, entityDocument, templateContent, vectorDB, indexName, delayTimeMS) {
|
|
282
|
+
// Parse entity document configuration for metadata enrichment settings
|
|
283
|
+
const docConfig = this.parseDocumentConfig(entityDocument);
|
|
284
|
+
const metadataConfig = docConfig.metadata;
|
|
285
|
+
// Get entity metadata for enriching vector metadata with display fields
|
|
286
|
+
const md = new Metadata();
|
|
287
|
+
const entityInfo = md.Entities.find(e => UUIDsEqual(e.ID, entityDocument.EntityID));
|
|
288
|
+
const displayFields = this.getDisplayFields(entityInfo, metadataConfig);
|
|
289
|
+
const vectorRecords = batch.map((embeddingItem) => {
|
|
290
|
+
// Deterministic vector ID: SHA-1 hash of entityDocumentID + compositeKey
|
|
291
|
+
// ensures re-syncing upserts in place (no duplicates) and stays under
|
|
292
|
+
// Pinecone's 512-byte ID limit (hash is 40 chars)
|
|
293
|
+
const raw = `${entityDocument.ID}_${embeddingItem.__mj_compositeKey}`;
|
|
294
|
+
const hash = createHash('sha1').update(raw).digest('hex');
|
|
295
|
+
const vectorId = hash;
|
|
296
|
+
embeddingItem.VectorID = vectorId;
|
|
297
|
+
// Build enriched metadata with display fields from the record
|
|
298
|
+
const metadata = {
|
|
299
|
+
RecordID: String(embeddingItem.__mj_compositeKey ?? ''),
|
|
300
|
+
Entity: entityDocument.Entity,
|
|
301
|
+
TemplateID: templateContent.ID,
|
|
302
|
+
};
|
|
303
|
+
// Add entity icon if available (respects includeEntityIcon config, default true)
|
|
304
|
+
if (entityInfo?.Icon && (metadataConfig?.includeEntityIcon !== false)) {
|
|
305
|
+
metadata['EntityIcon'] = entityInfo.Icon;
|
|
306
|
+
}
|
|
307
|
+
// Add __mj_UpdatedAt for recency sorting (respects includeUpdatedAt config, default true)
|
|
308
|
+
const record = embeddingItem.EntityData;
|
|
309
|
+
if (record['__mj_UpdatedAt'] && (metadataConfig?.includeUpdatedAt !== false)) {
|
|
310
|
+
metadata['__mj_UpdatedAt'] = String(record['__mj_UpdatedAt']);
|
|
311
|
+
}
|
|
312
|
+
// Add display fields with appropriate truncation (respects config overrides)
|
|
313
|
+
for (const field of displayFields) {
|
|
314
|
+
const val = record[field.Name];
|
|
315
|
+
if (val != null) {
|
|
316
|
+
const strVal = String(val);
|
|
317
|
+
const limit = this.getFieldTruncationLimit(field, metadataConfig);
|
|
318
|
+
metadata[field.Name] = strVal.length > limit ? strVal.substring(0, limit) : strVal;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
id: vectorId,
|
|
323
|
+
values: embeddingItem.Vector,
|
|
324
|
+
metadata
|
|
325
|
+
};
|
|
99
326
|
});
|
|
100
|
-
|
|
101
|
-
|
|
327
|
+
const response = await vectorDB.CreateRecords(vectorRecords, indexName);
|
|
328
|
+
if (!response.success) {
|
|
329
|
+
LogError('Unable to save records to vector database', undefined, response.message);
|
|
330
|
+
}
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, delayTimeMS));
|
|
332
|
+
return batch;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Validates template input data against template parameter definitions
|
|
336
|
+
*/
|
|
337
|
+
validateTemplateInput(template, data) {
|
|
338
|
+
const result = new ValidationResult();
|
|
339
|
+
const params = template.Params;
|
|
340
|
+
if (!params) {
|
|
341
|
+
result.Errors.push({ Source: '', Message: 'Params property not found on the template.', Value: '', Type: 'Failure' });
|
|
342
|
+
}
|
|
343
|
+
params?.forEach((p) => {
|
|
344
|
+
if (p.IsRequired) {
|
|
345
|
+
// For Record type params, fields are spread to root level (flat convention)
|
|
346
|
+
// so check that data has any keys, not a specific key matching the param name
|
|
347
|
+
if (p.Type === 'Record') {
|
|
348
|
+
if (Object.keys(data).length === 0) {
|
|
349
|
+
result.Errors.push({ Source: p.Name, Message: `Parameter ${p.Name} is required.`, Value: undefined, Type: 'Failure' });
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const val = data[p.Name];
|
|
354
|
+
if (val === undefined || val === null || (typeof val === 'string' && val.trim() === '')) {
|
|
355
|
+
result.Errors.push({ Source: p.Name, Message: `Parameter ${p.Name} is required.`, Value: val, Type: 'Failure' });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
102
358
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
359
|
+
result.Success = !result.Errors.some(e => e.Type === 'Failure');
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Starts the async data paging loop that feeds records into the stream pipeline.
|
|
364
|
+
*/
|
|
365
|
+
startDataPaging(dataStream, params, md, entity, template, vectorIndexEntity, entityDocument, pageSize) {
|
|
108
366
|
const getData = async () => {
|
|
109
367
|
let pageNumber = 0;
|
|
110
368
|
if (params.StartingOffset) {
|
|
@@ -113,7 +371,7 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
113
371
|
}
|
|
114
372
|
let hasMore = true;
|
|
115
373
|
while (hasMore) {
|
|
116
|
-
|
|
374
|
+
const pageRecordRequest = {
|
|
117
375
|
EntityID: params.entityID,
|
|
118
376
|
PageNumber: pageNumber,
|
|
119
377
|
PageSize: pageSize,
|
|
@@ -121,20 +379,20 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
121
379
|
};
|
|
122
380
|
if (params.listID) {
|
|
123
381
|
const coreSchema = md.ConfigData.MJCoreSchemaName;
|
|
124
|
-
pageRecordRequest.Filter = this.
|
|
382
|
+
pageRecordRequest.Filter = this.BuildListFilter(entity, coreSchema, params.listID);
|
|
125
383
|
}
|
|
126
384
|
const recordsPage = await super.PageRecordsByEntityID(pageRecordRequest);
|
|
127
385
|
const relatedData = await this.GetRelatedTemplateDataForBatch(entity, recordsPage, template);
|
|
128
386
|
const items = [];
|
|
129
387
|
LogStatus(`Fetched page ${pageNumber + 1} with ${recordsPage.length} records to process`);
|
|
130
388
|
for (const record of recordsPage) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
templateData.__mj_recordID =
|
|
134
|
-
templateData.__mj_compositeKey = entity.PrimaryKeys.map((key) => `${key.Name}|${
|
|
135
|
-
//we also need a reference to the vector index's ID
|
|
389
|
+
const typedRecord = record;
|
|
390
|
+
const templateData = await this.GetTemplateData(entity, typedRecord, template, relatedData);
|
|
391
|
+
templateData.__mj_recordID = typedRecord[entity.FirstPrimaryKey.Name];
|
|
392
|
+
templateData.__mj_compositeKey = entity.PrimaryKeys.map((key) => `${key.Name}|${typedRecord[key.Name]}`).join('||');
|
|
136
393
|
templateData.VectorIndexID = vectorIndexEntity.ID;
|
|
137
394
|
templateData.TemplateContent = template.Content[0].TemplateText;
|
|
395
|
+
templateData.__mj_entityDocument = { ID: entityDocument.ID, EntityID: entityDocument.EntityID, Name: entityDocument.Name };
|
|
138
396
|
items.push(templateData);
|
|
139
397
|
}
|
|
140
398
|
dataStream.addPage(items);
|
|
@@ -146,16 +404,10 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
146
404
|
}
|
|
147
405
|
dataStream.endStream();
|
|
148
406
|
};
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const endTime = new Date().getTime();
|
|
154
|
-
//convert ms to seconds
|
|
155
|
-
const elapsedSeconds = (endTime - startTime) / 1000;
|
|
156
|
-
const elapsedMinutes = elapsedSeconds / 60;
|
|
157
|
-
LogStatus(`Finished vectorizing ${entityDocument.Entity} entity in ${elapsedSeconds} seconds (${elapsedMinutes} minutes)`);
|
|
158
|
-
return null;
|
|
407
|
+
getData().catch((error) => {
|
|
408
|
+
LogError('Error during data paging', undefined, error);
|
|
409
|
+
dataStream.endStream();
|
|
410
|
+
});
|
|
159
411
|
}
|
|
160
412
|
/**
|
|
161
413
|
* This method will create a default Entity Document for the given entityID, vectorDatabase, and AIModel
|
|
@@ -170,7 +422,7 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
170
422
|
if (!entity)
|
|
171
423
|
throw new Error(`Entity with ID ${EntityID} not found.`);
|
|
172
424
|
const EDTemplate = entity.Fields.map((ef) => {
|
|
173
|
-
return `${ef.Name}:
|
|
425
|
+
return `${ef.Name}: {{${ef.Name}}}`;
|
|
174
426
|
}).join(' ');
|
|
175
427
|
const rv = new RunView();
|
|
176
428
|
const rvResult = await rv.RunView({
|
|
@@ -306,9 +558,6 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
306
558
|
vectorIndexEntity.VectorDatabaseID = entityDocument.VectorDatabaseID;
|
|
307
559
|
vectorIndexEntity.EmbeddingModelID = entityDocument.AIModelID;
|
|
308
560
|
vectorIndexEntity.Name = `Vector Index for entityDocument ${entityDocument.EntityID}`;
|
|
309
|
-
vectorIndexEntity.Set('EntityRecordUpdatedAt', new Date());
|
|
310
|
-
vectorIndexEntity.Set('EntityDocumentID', entityDocument.ID);
|
|
311
|
-
//not a very descriptive description, but the view has the name of the vectorDB and embedding model used
|
|
312
561
|
vectorIndexEntity.Description = `Vector Index that uses the Vector database ${entityDocument.VectorDatabaseID} and ${entityDocument.AIModelID} as the embedding model`;
|
|
313
562
|
const saveResult = await super.SaveEntity(vectorIndexEntity);
|
|
314
563
|
if (saveResult) {
|
|
@@ -387,7 +636,7 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
387
636
|
}
|
|
388
637
|
BuildTemplateContent(entityFields) {
|
|
389
638
|
return entityFields.map((field) => {
|
|
390
|
-
return `{{
|
|
639
|
+
return `{{${field.Name}}}`;
|
|
391
640
|
}).join(' ');
|
|
392
641
|
}
|
|
393
642
|
async GetEntityFieldsForSimilaritySearch(entityID) {
|
|
@@ -407,29 +656,35 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
407
656
|
async GetTemplateData(entity, record, template, relatedData) {
|
|
408
657
|
const templateData = {};
|
|
409
658
|
for (const param of template.Params) {
|
|
410
|
-
if (templateData[param.Name]) {
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
659
|
switch (param.Type) {
|
|
414
660
|
case 'Record':
|
|
415
|
-
//
|
|
416
|
-
|
|
661
|
+
// NEW convention: main entity fields are TOP-LEVEL variables (no Entity. prefix).
|
|
662
|
+
// Spread record fields directly into the root context so templates use {{FieldName}}.
|
|
663
|
+
Object.assign(templateData, record);
|
|
417
664
|
break;
|
|
418
|
-
case 'Entity':
|
|
419
|
-
|
|
420
|
-
|
|
665
|
+
case 'Entity': {
|
|
666
|
+
if (templateData[param.Name]) {
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
421
669
|
const paramData = relatedData.find((rd) => rd.ParamName === param.Name);
|
|
422
670
|
if (!paramData) {
|
|
423
671
|
LogError(`No related data found for param ${param.Name} in template ${template.ID}`);
|
|
424
672
|
break;
|
|
425
673
|
}
|
|
426
|
-
//
|
|
427
|
-
|
|
674
|
+
// Related entities use their relationship name as prefix: {{RelationshipName.FieldName}}
|
|
675
|
+
const pkValue = record[entity.FirstPrimaryKey.Name];
|
|
676
|
+
templateData[param.Name] = paramData.Data.filter((rdfr) => {
|
|
677
|
+
const typedRdfr = rdfr;
|
|
678
|
+
return typedRdfr[param.LinkedParameterField] === pkValue;
|
|
679
|
+
});
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
case 'Scalar':
|
|
683
|
+
// Flat convention: entity fields are top-level, so pull directly from record
|
|
684
|
+
templateData[param.Name] = record[param.Name] ?? '';
|
|
428
685
|
break;
|
|
429
|
-
case
|
|
430
|
-
case
|
|
431
|
-
case "Object":
|
|
432
|
-
// do nothing here, as we don't directly support these param types
|
|
686
|
+
case 'Array':
|
|
687
|
+
case 'Object':
|
|
433
688
|
LogError(`Unsupported parameter type ${param.Type} for parameter ${param.Name} in template ${template.ID}`);
|
|
434
689
|
break;
|
|
435
690
|
}
|
|
@@ -444,9 +699,12 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
444
699
|
}
|
|
445
700
|
const relatedEntity = templateParam.Entity;
|
|
446
701
|
const relatedField = templateParam.LinkedParameterField;
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
const filter = `${relatedField} in (${records.map((record) =>
|
|
702
|
+
const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : '';
|
|
703
|
+
const pkName = entity.FirstPrimaryKey.Name;
|
|
704
|
+
const filter = `${relatedField} in (${records.map((record) => {
|
|
705
|
+
const typedRecord = record;
|
|
706
|
+
return `${quotes}${typedRecord[pkName]}${quotes}`;
|
|
707
|
+
}).join(',')})`;
|
|
450
708
|
const finalFilter = templateParam.ExtraFilter ? `(${filter}) AND (${templateParam.ExtraFilter})` : filter;
|
|
451
709
|
const result = await super.RunView.RunView({
|
|
452
710
|
EntityName: relatedEntity,
|
|
@@ -454,10 +712,7 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
454
712
|
ResultType: 'simple'
|
|
455
713
|
}, super.CurrentUser);
|
|
456
714
|
if (result && result.Success) {
|
|
457
|
-
|
|
458
|
-
data.ParamName = templateParam.Name,
|
|
459
|
-
data.Data = result.Results;
|
|
460
|
-
relatedData.push(data);
|
|
715
|
+
relatedData.push({ ParamName: templateParam.Name, Data: result.Results });
|
|
461
716
|
}
|
|
462
717
|
else {
|
|
463
718
|
LogError(`Error getting related data for entity ${relatedEntity} with filter ${finalFilter}`, undefined, result.ErrorMessage);
|
|
@@ -465,52 +720,55 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
465
720
|
}
|
|
466
721
|
return relatedData;
|
|
467
722
|
}
|
|
468
|
-
|
|
469
|
-
|
|
723
|
+
/**
|
|
724
|
+
* Creates or updates an Entity Record Document record linking a source record to its vector embedding.
|
|
725
|
+
*/
|
|
470
726
|
async UpsertEntityRecordDocumentRecords(embeddingData, contextUser) {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
727
|
+
const md = super.Metadata;
|
|
728
|
+
const rv = super.RunView;
|
|
729
|
+
const vectorIndexID = String(embeddingData.VectorIndexID);
|
|
730
|
+
const vectorIndex = await md.GetEntityObject('MJ: Vector Indexes', contextUser);
|
|
731
|
+
const loadResult = await vectorIndex.Load(vectorIndexID);
|
|
476
732
|
if (!loadResult) {
|
|
477
|
-
LogError(`Vector Index with ID ${
|
|
733
|
+
LogError(`Vector Index with ID ${vectorIndexID} not found`);
|
|
478
734
|
return;
|
|
479
735
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
736
|
+
const entityDocument = embeddingData.EntityDocument;
|
|
737
|
+
const entityID = String(entityDocument.EntityID);
|
|
738
|
+
const recordID = String(embeddingData.__mj_recordID);
|
|
739
|
+
const entityDocumentID = String(entityDocument.ID);
|
|
483
740
|
const runViewResult = await rv.RunView({
|
|
484
741
|
EntityName: 'MJ: Entity Record Documents',
|
|
485
|
-
ExtraFilter: `EntityID = '${entityID}' AND EntityDocumentID = '${
|
|
742
|
+
ExtraFilter: `EntityID = '${entityID}' AND EntityDocumentID = '${entityDocumentID}' AND RecordID in ('${recordID}')`,
|
|
486
743
|
ResultType: 'entity_object'
|
|
487
744
|
}, contextUser);
|
|
745
|
+
let existingRecords = [];
|
|
488
746
|
if (runViewResult.Success) {
|
|
489
747
|
existingRecords = runViewResult.Results;
|
|
490
748
|
}
|
|
491
749
|
else {
|
|
492
|
-
LogError(
|
|
750
|
+
LogError('Error getting existing Entity Record Documents', undefined, runViewResult.ErrorMessage);
|
|
493
751
|
}
|
|
494
|
-
let erdEntity = existingRecords.find((er) => er.
|
|
752
|
+
let erdEntity = existingRecords.find((er) => er.RecordID === recordID);
|
|
495
753
|
if (!erdEntity) {
|
|
496
754
|
erdEntity = await md.GetEntityObject('MJ: Entity Record Documents', contextUser);
|
|
497
755
|
erdEntity.NewRecord();
|
|
498
756
|
}
|
|
499
|
-
erdEntity.
|
|
500
|
-
erdEntity.
|
|
501
|
-
erdEntity.
|
|
502
|
-
erdEntity.
|
|
503
|
-
erdEntity.
|
|
504
|
-
erdEntity.
|
|
505
|
-
erdEntity.
|
|
506
|
-
erdEntity.
|
|
757
|
+
erdEntity.EntityID = entityID;
|
|
758
|
+
erdEntity.RecordID = recordID;
|
|
759
|
+
erdEntity.DocumentText = embeddingData.TemplateContent ?? null;
|
|
760
|
+
erdEntity.VectorID = embeddingData.VectorID != null ? String(embeddingData.VectorID) : null;
|
|
761
|
+
erdEntity.VectorJSON = JSON.stringify(embeddingData.Vector);
|
|
762
|
+
erdEntity.VectorIndexID = vectorIndexID;
|
|
763
|
+
erdEntity.EntityRecordUpdatedAt = new Date();
|
|
764
|
+
erdEntity.EntityDocumentID = entityDocumentID;
|
|
507
765
|
erdEntity.ContextCurrentUser = contextUser;
|
|
508
|
-
|
|
766
|
+
const erdEntitySaveResult = await erdEntity.Save();
|
|
509
767
|
if (!erdEntitySaveResult) {
|
|
510
768
|
LogError('Error saving Entity Record Document Entity', undefined, erdEntity.LatestResult);
|
|
511
769
|
}
|
|
512
770
|
else {
|
|
513
|
-
LogStatus(
|
|
771
|
+
LogStatus('Upserting Entity Record Documents: Complete');
|
|
514
772
|
}
|
|
515
773
|
}
|
|
516
774
|
/**
|
|
@@ -539,7 +797,7 @@ export class EntityVectorSyncer extends VectorBase {
|
|
|
539
797
|
* For single PK entities, uses a simple IN clause.
|
|
540
798
|
* For composite PK entities, uses an EXISTS clause that concatenates PK columns to match the RecordID format.
|
|
541
799
|
*/
|
|
542
|
-
|
|
800
|
+
BuildListFilter(entity, listDetailsSchema, listId) {
|
|
543
801
|
const primaryKeys = entity.PrimaryKeys;
|
|
544
802
|
if (primaryKeys.length === 1) {
|
|
545
803
|
// Simple case: single primary key
|