@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.
@@ -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 { resolve, dirname } from 'node:path';
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
- //If this function doesnt throw an error, then the template is valid
63
- const validTemplate = this.ValidateTemplateContextParamAlignment(template);
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
- let templateObj = {
67
- ...template.GetAll(),
68
- Params: template.Params.map((p) => p.To())
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
- const workerContext = {
71
- executionId: Date.now(),
72
- entity,
73
- entityDocument: entityDocument.To(),
74
- template: templateObj,
75
- templateContent: template.Content[0].To(),
76
- vectorDBClassKey: obj.vectorDBClassKey,
77
- vectorDBAPIKey: obj.vectorDBAPIKey,
78
- embeddingDriverClass: obj.embeddingDriverClass,
79
- embeddingAPIKey: obj.embeddingAPIKey,
80
- delayTimeMS
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
- //Handles vectorizing the records
83
- const VectorCreator = new BatchWorker({
84
- workerFile: resolve(__dirname, 'workers/VectorizeTemplates.js'),
85
- batchSize: params.VectorizeBatchCount || 50,
86
- concurrencyLimit: 2,
87
- contextUser: super.CurrentUser,
88
- workerContext,
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
- VectorCreator.on('error', (err) => {
91
- LogError('Error in VectorCreator worker', null, err);
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
- //Handles upserting the vectors into the vector database
94
- const VectorUpserter = new BatchWorker({
95
- workerFile: resolve(__dirname, 'workers/UpsertVectors.js'),
96
- batchSize: params.UpsertBatchCount || 50,
97
- contextUser: super.CurrentUser,
98
- workerContext
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
- VectorUpserter.on('error', (err) => {
101
- LogError('Error in VectorUpserter worker', null, err);
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
- //short for entity record document upserter
104
- // handles upserting entity record document records
105
- const ERCUpserter = new Transform({ objectMode: true, transform: (chunk, encoding, callback) => {
106
- this.UpsertEntityRecordDocumentRecords(chunk, super.CurrentUser).then(() => callback(null)).catch(callback);
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
- let pageRecordRequest = {
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.buildListFilter(entity, coreSchema, params.listID);
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 templateData = await this.GetTemplateData(entity, record, template, relatedData);
132
- //we need a reference to this record's ID for the upsert worker
133
- templateData.__mj_recordID = record[entity.FirstPrimaryKey.Name];
134
- templateData.__mj_compositeKey = entity.PrimaryKeys.map((key) => `${key.Name}|${record[key.Name]}`).join("||");
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
- // page data asynchrounously and add to the data stream
150
- getData();
151
- LogStatus('Starting pipeline');
152
- await pipeline(dataStream, VectorCreator, VectorUpserter, ERCUpserter, new PassThrough({ objectMode: true }));
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}: \$\{${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 `{{Entity.${field.Name}}}`;
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
- // this one is simple, we create a property by the provided name, and set the value to the record we are currently processing
416
- templateData[param.Name] = record;
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
- // here we need to grab the related data from another entity and filter it down for the record we are current processing so it only shows the related data
420
- // the metadata in the param tells us what we need to know
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
- // now filter down the data in d to just this record and set the value of the context data to the filtered data
427
- templateData[param.Name] = paramData.Data.filter((rdfr) => rdfr[param.LinkedParameterField] === record[entity.FirstPrimaryKey.Name]);
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 "Array":
430
- case "Scalar":
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
- // construct a filter for the related field so that we constrain the results to just the set of records linked to our recipients
448
- const quotes = entity.FirstPrimaryKey.NeedsQuotes ? "'" : "";
449
- const filter = `${relatedField} in (${records.map((record) => `${quotes}${record[entity.FirstPrimaryKey.Name]}${quotes}`).join(',')})`;
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
- const data = { ParamName: '', Data: [] };
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
- // Rather than having this logic inside the EntityRecordDocumentWorker class, it's placed here instead
469
- //so that the logic behind vectorizing entities isnt spread out across so many files
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
- let md = super.Metadata;
472
- let rv = super.RunView;
473
- let vectorIndex = await md.GetEntityObject('MJ: Vector Indexes', contextUser);
474
- let vectorIndexID = embeddingData.VectorIndexID.toString();
475
- let loadResult = await vectorIndex.Load(vectorIndexID);
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 ${embeddingData.VectorIndexID} not found`);
733
+ LogError(`Vector Index with ID ${vectorIndexID} not found`);
478
734
  return;
479
735
  }
480
- let entityDocument = embeddingData.EntityDocument;
481
- let entityID = entityDocument.EntityID;
482
- let existingRecords = [];
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 = '${entityDocument.ID}' AND RecordID in ('${embeddingData.__mj_recordID}')`,
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(`Error getting existing Entity Record Documents`, undefined, runViewResult.ErrorMessage);
750
+ LogError('Error getting existing Entity Record Documents', undefined, runViewResult.ErrorMessage);
493
751
  }
494
- let erdEntity = existingRecords.find((er) => er.Get("RecordID").toString() === embeddingData.__mj_recordID.toString());
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.Set("EntityID", entityID.toString());
500
- erdEntity.Set("RecordID", embeddingData.__mj_recordID.toString());
501
- erdEntity.Set("DocumentText", embeddingData.TemplateContent);
502
- erdEntity.Set("VectorID", embeddingData.VectorID.toString());
503
- erdEntity.Set("VectorJSON", JSON.stringify(embeddingData.Vector));
504
- erdEntity.Set("VectorIndexID", embeddingData.VectorIndexID.toString());
505
- erdEntity.Set("EntityRecordUpdatedAt", new Date());
506
- erdEntity.Set("EntityDocumentID", embeddingData.EntityDocument.ID.toString());
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
- let erdEntitySaveResult = await erdEntity.Save();
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("Upserting Entity Record Documents: Complete");
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
- buildListFilter(entity, listDetailsSchema, listId) {
800
+ BuildListFilter(entity, listDetailsSchema, listId) {
543
801
  const primaryKeys = entity.PrimaryKeys;
544
802
  if (primaryKeys.length === 1) {
545
803
  // Simple case: single primary key