@memberjunction/content-autotagging 5.48.0 → 5.50.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.
@@ -6,7 +6,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  var AutotagBaseEngine_1;
8
8
  import { BaseEngine, RunView, LogError, LogStatus } from '@memberjunction/core';
9
- import { MJGlobal, UUIDsEqual, NormalizeUUID, RegisterClass } from '@memberjunction/global';
9
+ import { MJGlobal, UUIDsEqual, NormalizeUUID, IsValidUUID, RegisterClass, uuidv4 } from '@memberjunction/global';
10
10
  import { ContentSourceTypeParams } from './content.types.js';
11
11
  import { RateLimiter } from './RateLimiter.js';
12
12
  import pdfParse from 'pdf-parse';
@@ -22,12 +22,29 @@ import { BaseEmbeddings, GetAIAPIKey } from '@memberjunction/ai';
22
22
  import { AIEngine } from '@memberjunction/aiengine';
23
23
  import { AIPromptRunner, AIModelRunner } from '@memberjunction/ai-prompts';
24
24
  import { AIPromptParams } from '@memberjunction/ai-core-plus';
25
- import { TextChunker } from '@memberjunction/ai-vectors';
25
+ import { FIXED_WINDOW_SEGMENTER_KEY, ResolveSegmenter, } from '@memberjunction/ai-segmentation';
26
26
  import { VectorDBBase } from '@memberjunction/ai-vectordb';
27
27
  import { TagEngine } from '@memberjunction/tag-engine';
28
28
  import { KnowledgeHubMetadataEngine } from '@memberjunction/core-entities';
29
+ /** Column types that cannot be stored in vector metadata at all (binary/rowversion). */
30
+ const UNSTORABLE_METADATA_TYPES = new Set(['varbinary', 'image', 'binary', 'timestamp', 'rowversion']);
31
+ /** SQL column types stored as JS numbers in vector metadata without an explicit StoreAs. */
32
+ const NUMERIC_SQL_TYPES = new Set(['int', 'bigint', 'smallint', 'tinyint', 'float', 'real', 'decimal', 'numeric', 'money', 'smallmoney']);
33
+ /** Default truncation limit (characters) for large string metadata fields. */
34
+ const DEFAULT_METADATA_TRUNCATION = 1000;
35
+ /** Default vector-storage behavior when neither the ContentSource nor its ContentType configures it. */
36
+ const DEFAULT_VECTOR_ID_STRATEGY = 'recordId';
37
+ const DEFAULT_CHUNK_TEXT_STORAGE = 'alwaysChunk';
29
38
  /** Default batch size for vectorization processing */
30
39
  const DEFAULT_VECTORIZE_BATCH_SIZE = 20;
40
+ /** Default cap on how many soft-deleted chunks a single PurgeDeletedChunks run processes. */
41
+ const DEFAULT_PURGE_BATCH_SIZE = 1000;
42
+ /** Vector-DB delete sub-batch size — bounds how many records hit a 3rd-party store per call. */
43
+ const PURGE_VECTORDB_SUBBATCH = 50;
44
+ /** Default cap on how many pending chunks a single EmbedPendingChunks run processes. */
45
+ const DEFAULT_CHUNK_EMBED_BATCH_SIZE = 1000;
46
+ /** Embed + upsert sub-batch size for pending-chunk embedding — bounds per-call load. */
47
+ const CHUNK_EMBED_SUBBATCH = 50;
31
48
  /**
32
49
  * Core engine for content autotagging. Extends BaseEngine to cache content metadata
33
50
  * (types, source types, file types, attributes) at startup. Uses AIEngine via composition
@@ -370,6 +387,14 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
370
387
  if (status === 'Complete') {
371
388
  item.LastTaggedAt = new Date();
372
389
  }
390
+ // When tagging STARTS, the item is being (re)processed because its content is new or
391
+ // changed (the provider's checksum comparison already filtered out unchanged items).
392
+ // Reset its embedding state to 'Pending' so the vectorization phase re-embeds it — the
393
+ // tagging and embedding "needs work" signals are otherwise independent, and a changed
394
+ // item that was previously 'Complete' would never get re-embedded.
395
+ if (status === 'Processing') {
396
+ item.EmbeddingStatus = 'Pending';
397
+ }
373
398
  await item.Save();
374
399
  }
375
400
  catch {
@@ -598,7 +623,7 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
598
623
  const additionalAttributePrompts = this.GetAdditionalContentTypePrompt(params.contentTypeID);
599
624
  const hasPreviousResults = Object.keys(previousResults).length > 0;
600
625
  // Check if this source type requires content type validation in the prompt
601
- const sourceType = this.ContentSourceTypes.find(st => UUIDsEqual(st.ID, params.contentSourceTypeID));
626
+ const sourceType = this.khEngine.GetContentSourceTypeByID(params.contentSourceTypeID);
602
627
  const sourceConfig = sourceType?.ConfigurationObject;
603
628
  const requiresContentType = sourceConfig?.RequiresContentType !== false;
604
629
  return {
@@ -637,7 +662,7 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
637
662
  if (!contentSourceID) {
638
663
  return null;
639
664
  }
640
- const source = this.khEngine.ContentSources.find(s => UUIDsEqual(s.ID, contentSourceID));
665
+ const source = this.khEngine.GetContentSourceByID(contentSourceID);
641
666
  if (!source) {
642
667
  return null;
643
668
  }
@@ -652,7 +677,7 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
652
677
  params.classificationContext = await this.resolveClassificationContext(params, contextUser);
653
678
  const prompt = this.getAutotagPrompt();
654
679
  const tokenLimit = this.resolveTokenLimit(params.modelID);
655
- const chunks = this.chunkExtractedText(params.text, tokenLimit);
680
+ const chunks = await this.chunkExtractedText(params.text, tokenLimit);
656
681
  if (chunks.length === 0 || (chunks.length === 1 && (!chunks[0] || chunks[0].trim().length === 0))) {
657
682
  LogError(`[Autotag] No text to process for item ${params.contentItemID}`);
658
683
  return {};
@@ -761,28 +786,57 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
761
786
  await contentItem.Delete();
762
787
  }
763
788
  /**
764
- * Chunks text using the shared TextChunker utility for token-aware splitting.
765
- * Falls back to simple character-based splitting when TextChunker is not available.
789
+ * Registration key of the segmentation strategy this engine uses.
790
+ *
791
+ * Defaults to `FixedWindow`, which reproduces the engine's historical token-window
792
+ * behavior exactly — routing through the strategy layer is a refactor, not a behavior
793
+ * change. Override in a subclass (or, once the config field lands, resolve it from the
794
+ * Content Source / Content Type `Configuration`) to opt into structure-aware
795
+ * (`StructuralText`), topic-aware (`SemanticText`), or transcript-based (`Transcript`)
796
+ * segmentation.
797
+ *
798
+ * @see [Content Segmentation Guide](../../../../../guides/CONTENT_SEGMENTATION_GUIDE.md)
799
+ */
800
+ resolveSegmenterKey() {
801
+ return FIXED_WINDOW_SEGMENTER_KEY;
802
+ }
803
+ /**
804
+ * Run the resolved segmentation strategy over `text` and return its text payloads.
805
+ *
806
+ * Returns null when segmentation fails, so callers can apply their own fallback rather
807
+ * than silently embedding nothing.
766
808
  */
767
- chunkExtractedText(text, tokenLimit) {
809
+ async segmentTextForChunking(text, options) {
810
+ const segmenter = ResolveSegmenter(this.resolveSegmenterKey(), FIXED_WINDOW_SEGMENTER_KEY);
811
+ const result = await segmenter.Segment({ Text: text, Options: options });
812
+ if (!result.Success) {
813
+ LogError(`[Autotag] Segmentation failed (${result.SegmenterKey}): ${result.ErrorMessage ?? 'unknown error'}`);
814
+ return null;
815
+ }
816
+ const texts = result.Segments.map(s => s.Text ?? '').filter(t => t.trim().length > 0);
817
+ return texts.length > 0 ? texts : null;
818
+ }
819
+ /**
820
+ * Chunks text for the LLM tagging pass, sized to the *tagging model's* context window.
821
+ *
822
+ * Note the budget here is deliberately independent of the embedding budget
823
+ * ({@link MAX_EMBEDDING_TOKENS}) — these two chunk sites feed different consumers and
824
+ * must not be collapsed into one call. Tagging chunks are transient and never persisted.
825
+ */
826
+ async chunkExtractedText(text, tokenLimit) {
768
827
  try {
769
828
  const maxChunkTokens = Math.ceil(tokenLimit / 1.5);
829
+ // Short-circuit: text that already fits is passed through verbatim, preserving its
830
+ // original whitespace (a segmenter would return a normalized reflow of the same text).
770
831
  if (text.length <= maxChunkTokens * 4) {
771
832
  return [text];
772
833
  }
773
- try {
774
- const chunkParams = {
775
- Text: text,
776
- MaxChunkTokens: maxChunkTokens,
777
- OverlapTokens: Math.ceil(maxChunkTokens * 0.1),
778
- Strategy: 'sentence',
779
- };
780
- const chunks = TextChunker.ChunkText(chunkParams);
781
- return chunks.map(c => c.Text);
782
- }
783
- catch {
784
- return this.fallbackChunkText(text, maxChunkTokens);
785
- }
834
+ const segments = await this.segmentTextForChunking(text, {
835
+ MaxSegmentTokens: maxChunkTokens,
836
+ OverlapTokens: Math.ceil(maxChunkTokens * 0.1),
837
+ TextStrategy: 'sentence',
838
+ });
839
+ return segments ?? this.fallbackChunkText(text, maxChunkTokens);
786
840
  }
787
841
  catch {
788
842
  LogError('Could not chunk the text');
@@ -1017,7 +1071,7 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1017
1071
  throw new Error(`Failed to retrieve last run date for content source with ID ${contentSourceID}`);
1018
1072
  }
1019
1073
  GetContentItemParams(contentTypeID) {
1020
- const contentType = this.ContentTypes.find(ct => UUIDsEqual(ct.ID, contentTypeID));
1074
+ const contentType = this.khEngine.GetContentTypeByID(contentTypeID);
1021
1075
  if (!contentType) {
1022
1076
  throw new Error(`Content Type with ID ${contentTypeID} not found in cached metadata`);
1023
1077
  }
@@ -1028,14 +1082,14 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1028
1082
  };
1029
1083
  }
1030
1084
  GetContentSourceTypeName(contentSourceTypeID) {
1031
- const sourceType = this.ContentSourceTypes.find(st => UUIDsEqual(st.ID, contentSourceTypeID));
1085
+ const sourceType = this.khEngine.GetContentSourceTypeByID(contentSourceTypeID);
1032
1086
  if (!sourceType) {
1033
1087
  throw new Error(`Content Source Type with ID ${contentSourceTypeID} not found in cached metadata`);
1034
1088
  }
1035
1089
  return sourceType.Name;
1036
1090
  }
1037
1091
  GetContentTypeName(contentTypeID) {
1038
- const contentType = this.ContentTypes.find(ct => UUIDsEqual(ct.ID, contentTypeID));
1092
+ const contentType = this.khEngine.GetContentTypeByID(contentTypeID);
1039
1093
  if (!contentType) {
1040
1094
  throw new Error(`Content Type with ID ${contentTypeID} not found in cached metadata`);
1041
1095
  }
@@ -1280,7 +1334,7 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1280
1334
  for (let i = 0; i < items.length; i += batchSize) {
1281
1335
  const batch = items.slice(i, i + batchSize);
1282
1336
  // Build chunks for each item — items with long text produce multiple chunks
1283
- const allChunks = this.buildChunksForBatch(batch);
1337
+ const allChunks = await this.buildChunksForBatch(batch);
1284
1338
  const texts = allChunks.map(c => c.text);
1285
1339
  // Rate limit embedding API call
1286
1340
  await this.EmbeddingRateLimiter.Acquire(texts.reduce((sum, t) => sum + Math.ceil(t.length / 4), 0));
@@ -1291,6 +1345,7 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1291
1345
  PromptID: embeddingPromptID,
1292
1346
  ContextUser: contextUser,
1293
1347
  Description: `Content vectorization batch: ${batch.length} items, ${allChunks.length} chunks`,
1348
+ Dimensions: infra.dimensions,
1294
1349
  });
1295
1350
  if (!runResult.Success || runResult.Vectors.length !== allChunks.length) {
1296
1351
  LogError(`VectorizeContentItems: embedding returned ${runResult.Vectors.length} vectors for ${allChunks.length} texts — ${runResult.ErrorMessage ?? 'unknown error'}`);
@@ -1302,10 +1357,13 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1302
1357
  if (runResult.PromptRunID) {
1303
1358
  promptRunIDs.push(runResult.PromptRunID);
1304
1359
  }
1305
- const records = this.buildVectorRecords(allChunks, runResult.Vectors, tagMap);
1360
+ const records = this.buildVectorRecords(allChunks, runResult.Vectors, tagMap, infra);
1306
1361
  const batchSuccess = await this.upsertVectorRecords(records, infra);
1307
1362
  if (batchSuccess) {
1308
1363
  vectorized += batch.length;
1364
+ // Persist the vector-DB record identifiers back into MJ before the status save,
1365
+ // so single-chunk items carry their VectorRecordID in the same 'Complete' write.
1366
+ await this.persistVectorReferences(batch, allChunks, records, contextUser);
1309
1367
  await this.updateEmbeddingStatusBatch(batch, 'Complete', contextUser, infra.embeddingModelID);
1310
1368
  }
1311
1369
  else {
@@ -1333,27 +1391,548 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1333
1391
  * Build text chunks for a batch of content items. Items with long text
1334
1392
  * produce multiple chunks via TextChunker.
1335
1393
  */
1336
- buildChunksForBatch(batch) {
1394
+ async buildChunksForBatch(batch) {
1337
1395
  const allChunks = [];
1338
1396
  for (const item of batch) {
1339
- const chunks = this.buildEmbeddingChunks(item);
1397
+ const chunks = await this.buildEmbeddingChunks(item);
1340
1398
  for (let ci = 0; ci < chunks.length; ci++) {
1341
- allChunks.push({ item, chunkIndex: ci, text: chunks[ci] });
1399
+ // Mint a stable per-chunk id up front. This becomes BOTH the ContentItemChunk row's
1400
+ // PK and (under the 'recordId' strategy) its vector-DB id, so a re-chunk produces
1401
+ // NEW rows with NEW ids — old (soft-deleted) and new chunks never collide on a
1402
+ // vector id, which is what makes the purge safe.
1403
+ allChunks.push({ item, chunkIndex: ci, text: chunks[ci], chunkID: uuidv4() });
1342
1404
  }
1343
1405
  }
1344
1406
  return allChunks;
1345
1407
  }
1346
1408
  /**
1347
- * Build VectorRecord objects from embedding chunks and their corresponding vectors.
1409
+ * Build VectorRecord objects from embedding chunks and their corresponding vectors. Resolves
1410
+ * each item's storage config once and shares the item-level-vs-chunk decision between the
1411
+ * vector id and the metadata so the two always agree.
1412
+ */
1413
+ buildVectorRecords(allChunks, vectors, tagMap, infra) {
1414
+ const countByItem = new Map();
1415
+ for (const c of allChunks) {
1416
+ countByItem.set(c.item.ID, (countByItem.get(c.item.ID) ?? 0) + 1);
1417
+ }
1418
+ // The ContentItem entity metadata drives config-selected display fields (types, MaxLength,
1419
+ // eligibility). Resolved once per batch — every chunk shares the same source entity.
1420
+ const contentItemEntity = this.ProviderToUse.EntityByName('MJ: Content Items');
1421
+ return allChunks.map((chunk, idx) => this.buildVectorRecord(chunk, vectors[idx], countByItem.get(chunk.item.ID) ?? 1, tagMap.get(chunk.item.ID), contentItemEntity, infra));
1422
+ }
1423
+ /**
1424
+ * Build the single {@link VectorRecord} for one embedding chunk. Resolves the item's storage
1425
+ * config, decides item-level-vs-chunk once, and shares that decision between the vector id and
1426
+ * the metadata so the two always agree. `protected` so subclasses can override per-record
1427
+ * shaping (id, metadata, or provider directives) without reimplementing the batch loop in
1428
+ * {@link buildVectorRecords}.
1429
+ *
1430
+ * @param chunk The embedding unit (item slice + minted chunk id).
1431
+ * @param vector The embedding produced for `chunk`.
1432
+ * @param chunkCountForItem Total chunks produced for `chunk.item` this batch — drives the
1433
+ * item-level-vs-chunk decision ({@link isItemLevelVector}).
1434
+ * @param tags Resolved tag names for `chunk.item`, if any.
1435
+ * @param contentItemEntity The 'MJ: Content Items' entity metadata, for display-field resolution.
1436
+ * @param infra Resolved vector infrastructure (source of provider directives).
1437
+ */
1438
+ buildVectorRecord(chunk, vector, chunkCountForItem, tags, contentItemEntity, infra) {
1439
+ const config = this.resolveItemVectorStorageConfig(chunk.item);
1440
+ const itemLevel = this.isItemLevelVector(config, chunkCountForItem);
1441
+ const record = {
1442
+ id: this.resolveChunkVectorID(chunk, config, itemLevel),
1443
+ values: vector,
1444
+ metadata: this.buildVectorMetadata(chunk, itemLevel, tags, config, contentItemEntity)
1445
+ };
1446
+ const directives = this.buildProviderDirectives(chunk.item, infra);
1447
+ if (directives) {
1448
+ record.providerTemporaryDirectives = directives;
1449
+ }
1450
+ return record;
1451
+ }
1452
+ /**
1453
+ * Compute a vector record's per-record provider directives (e.g. Pinecone namespace) from the
1454
+ * source content item and the index's ProviderConfig. Returns undefined when the index has no
1455
+ * ProviderConfig — the common case — so no provider routing is applied and the base
1456
+ * BuildProviderDirectives is not even invoked. The item's full field set (GetAll) is handed to
1457
+ * the driver so it can read whatever field its config names (e.g. `namespaceField: 'OrganizationID'`).
1458
+ * Namespace is resolved from the parent ContentItem (org-level), the same source for a chunk's
1459
+ * vector as for an item-level vector.
1460
+ */
1461
+ buildProviderDirectives(item, infra) {
1462
+ if (!infra.providerConfig) {
1463
+ return undefined;
1464
+ }
1465
+ return infra.vectorDB.BuildProviderDirectives(item.GetAll(), infra.providerConfig);
1466
+ }
1467
+ /**
1468
+ * Choose the vector-DB record id for one chunk based on the item's resolved config.
1469
+ * - 'recordId' (default): the chunk's own id (chunkID), which is also the ContentItemChunk PK.
1470
+ * Purge-safe — a re-chunk mints fresh ids, so a superseded chunk and its replacement never
1471
+ * collide.
1472
+ * - 'hash': deterministic. An item-level single vector uses the bare item hash; every other
1473
+ * case uses a per-(item, chunkIndex) hash. Deterministic ⇒ unsafe with re-chunk + purge
1474
+ * (documented on the config), but preserves 5.49 EntityDocument parity when opted into.
1475
+ */
1476
+ resolveChunkVectorID(chunk, config, isItemLevel) {
1477
+ if (config.vectorIDStrategy === 'hash') {
1478
+ return isItemLevel
1479
+ ? this.contentItemVectorID(chunk.item.ID)
1480
+ : this.contentItemChunkVectorID(chunk.item.ID, chunk.chunkIndex);
1481
+ }
1482
+ return chunk.chunkID;
1483
+ }
1484
+ /**
1485
+ * Persist the vector-database record identifiers produced during this batch back into MJ.
1486
+ *
1487
+ * Where the id lands is governed by the item's resolved {@link ChunkTextStorage}:
1488
+ * - 'mixed' + single chunk: the item embeds as one vector, so its record id is stored directly
1489
+ * on ContentItem.VectorRecordID (set in-memory here; persisted by the subsequent 'Complete'
1490
+ * status Save so no extra write is needed).
1491
+ * - 'alwaysChunk' (any chunk count) or 'mixed' multi-chunk: per-chunk provenance is recorded in
1492
+ * ContentItemChunk rows (ContentItemID, Sequence, Text, VectorRecordID) and
1493
+ * ContentItem.VectorRecordID is left null (the chunk table is the source of truth).
1494
+ *
1495
+ * `allChunks` and `records` are parallel arrays (buildVectorRecords maps 1:1), so
1496
+ * records[i].id is the vector-DB id for allChunks[i]. Best-effort: a persistence failure
1497
+ * for one item is logged but does not abort the batch — the vectors are already upserted.
1498
+ */
1499
+ async persistVectorReferences(batch, allChunks, records, contextUser) {
1500
+ // Group the persisted-chunk specs by content item, preserving order.
1501
+ const byItem = new Map();
1502
+ for (let idx = 0; idx < allChunks.length; idx++) {
1503
+ const c = allChunks[idx];
1504
+ const key = NormalizeUUID(c.item.ID);
1505
+ const list = byItem.get(key) ?? [];
1506
+ list.push({ chunkIndex: c.chunkIndex, text: c.text, vectorRecordID: String(records[idx].id), chunkID: c.chunkID });
1507
+ byItem.set(key, list);
1508
+ }
1509
+ for (const item of batch) {
1510
+ const chunks = byItem.get(NormalizeUUID(item.ID));
1511
+ if (!chunks || chunks.length === 0)
1512
+ continue;
1513
+ const config = this.resolveItemVectorStorageConfig(item);
1514
+ try {
1515
+ if (this.isItemLevelVector(config, chunks.length)) {
1516
+ // 'mixed' + single chunk — the record id lives on the content item itself.
1517
+ item.VectorRecordID = chunks[0].vectorRecordID;
1518
+ }
1519
+ else {
1520
+ // 'alwaysChunk', or multiple vectors — record per-chunk provenance in
1521
+ // ContentItemChunk rows; the item-level id is left null (chunk table is truth).
1522
+ item.VectorRecordID = null;
1523
+ await this.replaceContentItemChunks(item.ID, chunks, contextUser);
1524
+ }
1525
+ }
1526
+ catch (e) {
1527
+ const msg = e instanceof Error ? e.message : String(e);
1528
+ LogError(`persistVectorReferences: failed to persist chunk references for item ${item.ID}: ${msg}`);
1529
+ }
1530
+ }
1531
+ }
1532
+ /**
1533
+ * Supersede a content item's current (live) chunks with a freshly-embedded set: the live
1534
+ * chunks are SOFT-deleted (DeleteStatus='Pending', rows kept) and the new chunks appended,
1535
+ * atomically. A later PurgeDeletedChunks removes the superseded chunks' vectors from the
1536
+ * 3rd-party store and flips them to 'Deleted'. Either the swap commits or it rolls back;
1537
+ * on failure this throws and the caller (persistVectorReferences) logs it and moves on.
1538
+ *
1539
+ * Provider discipline (server, multi-user): the RunView read, the entity objects, and the
1540
+ * transaction ALL go through `this.ProviderToUse` — the connection allocated to this request.
1541
+ * A bare `new RunView()` would run on the process-global provider/connection, outside this
1542
+ * transaction and under the wrong user's context.
1543
+ */
1544
+ async replaceContentItemChunks(contentItemID, chunks, contextUser) {
1545
+ const rv = this.ProviderToUse;
1546
+ // Load only the LIVE chunks (not already soft-deleted) — those are the ones being
1547
+ // superseded by this re-vectorization. Chunks already marked for deletion are left alone.
1548
+ const existing = await rv.RunView({
1549
+ EntityName: 'MJ: Content Item Chunks',
1550
+ ExtraFilter: `ContentItemID='${contentItemID}' AND DeleteStatus IS NULL`,
1551
+ ResultType: 'entity_object'
1552
+ }, contextUser);
1553
+ if (!existing.Success) {
1554
+ throw new Error(`failed to load existing chunks for item ${contentItemID}: ${existing.ErrorMessage}`);
1555
+ }
1556
+ const newRows = await this.buildChunkRows(contentItemID, chunks, contextUser);
1557
+ await this.commitChunkReplacement(contentItemID, existing.Results, newRows);
1558
+ }
1559
+ /**
1560
+ * Build (in-memory only — no DB access) the replacement ContentItemChunk rows for an item.
1561
+ * These rows are created only after the chunk's vector has been successfully embedded and
1562
+ * upserted (see persistVectorReferences), so each row is stamped EmbeddingStatus='Complete'
1563
+ * with LastEmbeddedAt=now — mirroring how the parent ContentItem is stamped on a successful
1564
+ * embed. TaggingStatus is left at its 'Pending' default (chunks are not tagged individually;
1565
+ * tagging happens at the Content Item level) and DeleteStatus is left null (not slated for
1566
+ * deletion).
1567
+ */
1568
+ async buildChunkRows(contentItemID, chunks, contextUser) {
1569
+ const md = this.ProviderToUse;
1570
+ const now = new Date();
1571
+ const rows = [];
1572
+ for (const chunk of chunks) {
1573
+ const row = await md.GetEntityObject('MJ: Content Item Chunks', contextUser);
1574
+ row.NewRecord();
1575
+ // Pin the row PK to the id minted up front (chunkID) so the chunk's identity is stable
1576
+ // and known before it is written — it is the same value put in the vector metadata's
1577
+ // RecordID (Chunk-Identity Contract), so a search hit resolves to this row by id.
1578
+ // NewRecord() applies an explicit PK last, so this overrides the auto-generated uuid.
1579
+ row.ID = chunk.chunkID;
1580
+ // VectorRecordID is the id the vector was actually upserted under (equal to chunkID
1581
+ // under 'recordId', a hash under 'hash'). A re-chunk mints fresh ids for its new rows,
1582
+ // so a superseded (soft-deleted) chunk and its replacement never share one — which is
1583
+ // what makes PurgeDeletedChunks safe.
1584
+ row.ContentItemID = contentItemID;
1585
+ row.Sequence = chunk.chunkIndex;
1586
+ row.Text = chunk.text;
1587
+ row.VectorRecordID = chunk.vectorRecordID;
1588
+ row.EmbeddingStatus = 'Complete';
1589
+ row.LastEmbeddedAt = now;
1590
+ rows.push(row);
1591
+ }
1592
+ return rows;
1593
+ }
1594
+ /**
1595
+ * In one server-side transaction (SQL-only — no third-party calls): SOFT-delete the superseded
1596
+ * live chunks (set DeleteStatus='Pending', keeping the rows) and insert the new chunks. Either
1597
+ * the whole swap commits or it rolls back.
1598
+ *
1599
+ * The superseded chunks' vectors are removed from the vector database out-of-band by
1600
+ * PurgeDeletedChunks, which batches the remote deletes to each provider's limits — a
1601
+ * cross-system delete can't live inside this SQL transaction (it can't be rolled back if the
1602
+ * remote store already applied it). Keeping the rows (soft delete) also preserves history and
1603
+ * lets the vector purge be retried. There is no unique constraint on (ContentItemID, Sequence),
1604
+ * so a superseded chunk and its replacement may share a Sequence until the old one is purged.
1605
+ */
1606
+ async commitChunkReplacement(contentItemID, supersededRows, newRows) {
1607
+ const provider = this.ProviderToUse;
1608
+ await provider.BeginTransaction();
1609
+ let committed = false;
1610
+ try {
1611
+ // Soft-delete the superseded live chunks, then insert the new ones. Both phases fire
1612
+ // in parallel (Promise.all) — this is SQL-only inside the transaction (no third-party
1613
+ // call), and MJ's provider serializes transaction queries onto the single connection,
1614
+ // so parallel dispatch is safe.
1615
+ supersededRows.forEach(row => { row.DeleteStatus = 'Pending'; });
1616
+ const softDeleteResults = await Promise.all(supersededRows.map(row => row.Save()));
1617
+ const failedSoftDelete = supersededRows.find((_row, i) => !softDeleteResults[i]);
1618
+ if (failedSoftDelete) {
1619
+ throw new Error(`failed to soft-delete chunk ${failedSoftDelete.ID}: ${failedSoftDelete.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1620
+ }
1621
+ const saveResults = await Promise.all(newRows.map(row => row.Save()));
1622
+ const failedSave = newRows.find((_row, i) => !saveResults[i]);
1623
+ if (failedSave) {
1624
+ throw new Error(`failed to save chunk ${failedSave.Sequence}: ${failedSave.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1625
+ }
1626
+ await provider.CommitTransaction();
1627
+ committed = true;
1628
+ }
1629
+ catch (e) {
1630
+ // Guard the rollback so it can never mask the original error: a commit that throws has
1631
+ // already self-rolled-back inside the provider (nulling its transaction, so a second
1632
+ // rollback here throws "no active transaction"), and a rollback can fail on its own. The
1633
+ // provider clears its transaction state on every path, so this never leaves one open.
1634
+ if (!committed) {
1635
+ try {
1636
+ await provider.RollbackTransaction();
1637
+ }
1638
+ catch (rollbackErr) {
1639
+ const rbMsg = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
1640
+ LogError(`commitChunkReplacement: rollback failed for item ${contentItemID}: ${rbMsg}`);
1641
+ }
1642
+ }
1643
+ throw e;
1644
+ }
1645
+ }
1646
+ /**
1647
+ * Actually remove chunks that have been soft-deleted (DeleteStatus='Pending'): delete their
1648
+ * vectors from whatever 3rd-party vector store holds them, then flip the row to 'Deleted' with
1649
+ * LastDeletedAt (the SQL row is kept as a tombstone). Bounded per run (maxItems) and per remote
1650
+ * call (PURGE_VECTORDB_SUBBATCH) + rate-limited, so a large backlog or a single big re-chunk
1651
+ * can't hammer the DB or the vector provider. Meant to run out-of-band from vectorization
1652
+ * (on demand or scheduled).
1653
+ *
1654
+ * Ordering is delete-vector-first, then mark 'Deleted': if the run dies mid-way the chunk stays
1655
+ * 'Pending' and is retried next run (at worst a redundant remote delete, which is idempotent for
1656
+ * the vector stores we target).
1657
+ *
1658
+ * @returns counts of chunks purged (vector removed + marked Deleted), failed, and skipped
1659
+ * (no VectorRecordID — marked Deleted directly, nothing to remove remotely).
1660
+ */
1661
+ async PurgeDeletedChunks(contextUser, options) {
1662
+ await AIEngine.Instance.Config(false, contextUser);
1663
+ const maxItems = options?.maxItems && options.maxItems > 0 ? options.maxItems : DEFAULT_PURGE_BATCH_SIZE;
1664
+ const stats = { purged: 0, failed: 0, skipped: 0 };
1665
+ const chunks = await this.loadPendingChunks(contextUser, maxItems);
1666
+ if (chunks.length === 0)
1667
+ return stats;
1668
+ // Chunks with no vector id have nothing to remove remotely — mark Deleted directly.
1669
+ for (const c of chunks.filter(x => !x.VectorRecordID)) {
1670
+ if (await this.markChunkDeleted(c))
1671
+ stats.skipped++;
1672
+ else
1673
+ stats.failed++;
1674
+ }
1675
+ // Remove the rest from the vector DB (grouped by infrastructure), then mark Deleted.
1676
+ const toPurge = chunks.filter(c => !!c.VectorRecordID);
1677
+ if (toPurge.length > 0) {
1678
+ await this.purgeChunksByInfrastructure(toPurge, contextUser, stats);
1679
+ }
1680
+ LogStatus(`PurgeDeletedChunks: ${stats.purged} purged, ${stats.skipped} skipped (no vector), ${stats.failed} failed`);
1681
+ return stats;
1682
+ }
1683
+ /** Load soft-deleted chunks awaiting purge (bounded, oldest first). Returns [] on failure. */
1684
+ async loadPendingChunks(contextUser, maxItems) {
1685
+ const rv = this.ProviderToUse;
1686
+ const result = await rv.RunView({
1687
+ EntityName: 'MJ: Content Item Chunks',
1688
+ ExtraFilter: `DeleteStatus = 'Pending'`,
1689
+ OrderBy: '__mj_CreatedAt ASC',
1690
+ MaxRows: maxItems,
1691
+ ResultType: 'entity_object'
1692
+ }, contextUser);
1693
+ if (!result.Success) {
1694
+ LogError(`PurgeDeletedChunks: failed to load pending chunks: ${result.ErrorMessage}`);
1695
+ return [];
1696
+ }
1697
+ return result.Results;
1698
+ }
1699
+ /** Load the parent Content Items for a set of chunks (needed for infra resolution). null on failure. */
1700
+ async loadChunkParentItems(chunks, contextUser) {
1701
+ const rv = this.ProviderToUse;
1702
+ const itemIDs = [...new Set(chunks.map(c => NormalizeUUID(c.ContentItemID)))];
1703
+ const result = await rv.RunView({
1704
+ EntityName: 'MJ: Content Items',
1705
+ ExtraFilter: `ID IN (${itemIDs.map(id => `'${id}'`).join(',')})`,
1706
+ ResultType: 'entity_object'
1707
+ }, contextUser);
1708
+ if (!result.Success) {
1709
+ LogError(`loadChunkParentItems: failed to load parent items for chunks: ${result.ErrorMessage}`);
1710
+ return null;
1711
+ }
1712
+ return result.Results;
1713
+ }
1714
+ /** Group chunks by their parent item's vector infrastructure and purge each group's vectors. */
1715
+ async purgeChunksByInfrastructure(toPurge, contextUser, stats) {
1716
+ const items = await this.loadChunkParentItems(toPurge, contextUser);
1717
+ if (!items) {
1718
+ stats.failed += toPurge.length;
1719
+ return;
1720
+ }
1721
+ const { sourceMap, typeMap } = await this.loadContentSourceAndTypeMaps(items, contextUser);
1722
+ const itemGroups = this.groupItemsByInfrastructure(items, sourceMap, typeMap);
1723
+ for (const [groupKey, groupItems] of itemGroups) {
1724
+ const groupItemIDs = new Set(groupItems.map(i => NormalizeUUID(i.ID)));
1725
+ const groupChunks = toPurge.filter(c => groupItemIDs.has(NormalizeUUID(c.ContentItemID)));
1726
+ if (groupChunks.length === 0)
1727
+ continue;
1728
+ let infra;
1729
+ try {
1730
+ infra = await this.resolveGroupInfrastructure(groupKey, contextUser);
1731
+ }
1732
+ catch (e) {
1733
+ LogError(`PurgeDeletedChunks: infrastructure resolve failed for group ${groupKey}: ${e instanceof Error ? e.message : String(e)}`);
1734
+ stats.failed += groupChunks.length;
1735
+ continue;
1736
+ }
1737
+ await this.purgeChunkGroup(groupChunks, infra, stats);
1738
+ }
1739
+ }
1740
+ /** Delete one infra group's chunk vectors in bounded sub-batches, then mark the rows Deleted. */
1741
+ async purgeChunkGroup(groupChunks, infra, stats) {
1742
+ for (let i = 0; i < groupChunks.length; i += PURGE_VECTORDB_SUBBATCH) {
1743
+ const batch = groupChunks.slice(i, i + PURGE_VECTORDB_SUBBATCH);
1744
+ if (await this.deleteChunkVectors(batch, infra)) {
1745
+ for (const c of batch) {
1746
+ if (await this.markChunkDeleted(c))
1747
+ stats.purged++;
1748
+ else
1749
+ stats.failed++;
1750
+ }
1751
+ }
1752
+ else {
1753
+ stats.failed += batch.length; // left 'Pending' → retried on the next run
1754
+ }
1755
+ }
1756
+ }
1757
+ /** Remove a batch of chunk vectors from the vector DB (rate-limited). Returns whether it succeeded. */
1758
+ async deleteChunkVectors(batch, infra) {
1759
+ const records = batch.map(c => ({ id: c.VectorRecordID, values: [], metadata: {} }));
1760
+ await this.VectorDBRateLimiter.Acquire();
1761
+ try {
1762
+ const resp = await infra.vectorDB.DeleteRecords(records, infra.indexName);
1763
+ if (!resp.success)
1764
+ LogError(`PurgeDeletedChunks: DeleteRecords failed for index ${infra.indexName}: ${resp.message}`);
1765
+ return resp.success;
1766
+ }
1767
+ catch (e) {
1768
+ LogError(`PurgeDeletedChunks: DeleteRecords threw for index ${infra.indexName}: ${e instanceof Error ? e.message : String(e)}`);
1769
+ return false;
1770
+ }
1771
+ }
1772
+ /** Mark a soft-deleted chunk as fully Deleted (its vector already removed). Best-effort save. */
1773
+ async markChunkDeleted(chunk) {
1774
+ chunk.DeleteStatus = 'Deleted';
1775
+ chunk.LastDeletedAt = new Date();
1776
+ const saved = await chunk.Save();
1777
+ if (!saved) {
1778
+ LogError(`PurgeDeletedChunks: failed to mark chunk ${chunk.ID} Deleted: ${chunk.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1779
+ }
1780
+ return saved;
1781
+ }
1782
+ /**
1783
+ * (Re)embed persisted ContentItemChunk rows that are awaiting embedding
1784
+ * (EmbeddingStatus='Pending' AND DeleteStatus IS NULL): embed each chunk's stored `.Text`,
1785
+ * upsert the vector under the chunk's identity (see the Chunk-Identity Contract), then stamp
1786
+ * the row EmbeddingStatus='Complete' with its VectorRecordID + LastEmbeddedAt. This is the
1787
+ * migration path (backfill vectors for chunk rows created without them) and the recovery path
1788
+ * (a chunk whose embed previously failed stays 'Pending' and is retried).
1789
+ *
1790
+ * Bounded per run by `maxItems` and per embed/upsert call by CHUNK_EMBED_SUBBATCH + rate-limited,
1791
+ * so a large backlog drains over several runs rather than hammering the API or vector store.
1792
+ * Meant to run out-of-band from live vectorization (on demand or scheduled). Best-effort per
1793
+ * chunk — a failure leaves the row 'Pending' (retried next run) and never aborts the pass.
1794
+ *
1795
+ * @returns counts of chunks embedded, failed, and skipped (empty text / missing parent).
1796
+ */
1797
+ async EmbedPendingChunks(contextUser, options) {
1798
+ await AIEngine.Instance.Config(false, contextUser);
1799
+ const maxItems = options?.maxItems && options.maxItems > 0 ? options.maxItems : DEFAULT_CHUNK_EMBED_BATCH_SIZE;
1800
+ const stats = { embedded: 0, failed: 0, skipped: 0 };
1801
+ const chunks = await this.loadPendingEmbeddingChunks(contextUser, maxItems);
1802
+ if (chunks.length === 0)
1803
+ return stats;
1804
+ await this.embedChunksByInfrastructure(chunks, contextUser, stats);
1805
+ LogStatus(`EmbedPendingChunks: ${stats.embedded} embedded, ${stats.skipped} skipped (empty text/no parent), ${stats.failed} failed`);
1806
+ return stats;
1807
+ }
1808
+ /** Load chunks awaiting embedding (bounded, oldest first). Returns [] on failure. */
1809
+ async loadPendingEmbeddingChunks(contextUser, maxItems) {
1810
+ const rv = this.ProviderToUse;
1811
+ const result = await rv.RunView({
1812
+ EntityName: 'MJ: Content Item Chunks',
1813
+ ExtraFilter: `EmbeddingStatus = 'Pending' AND DeleteStatus IS NULL`,
1814
+ OrderBy: '__mj_CreatedAt ASC',
1815
+ MaxRows: maxItems,
1816
+ ResultType: 'entity_object'
1817
+ }, contextUser);
1818
+ if (!result.Success) {
1819
+ LogError(`EmbedPendingChunks: failed to load pending chunks: ${result.ErrorMessage}`);
1820
+ return [];
1821
+ }
1822
+ return result.Results;
1823
+ }
1824
+ /**
1825
+ * Group pending chunks by their parent item's vector infrastructure (embedding model + index)
1826
+ * and embed each group. Mirrors purgeChunksByInfrastructure so the two share the same
1827
+ * chunk → parent-item → infrastructure resolution.
1828
+ */
1829
+ async embedChunksByInfrastructure(chunks, contextUser, stats) {
1830
+ const items = await this.loadChunkParentItems(chunks, contextUser);
1831
+ if (!items) {
1832
+ stats.failed += chunks.length;
1833
+ return;
1834
+ }
1835
+ const itemById = new Map(items.map(i => [NormalizeUUID(i.ID), i]));
1836
+ const { sourceMap, typeMap } = await this.loadContentSourceAndTypeMaps(items, contextUser);
1837
+ const itemGroups = this.groupItemsByInfrastructure(items, sourceMap, typeMap);
1838
+ const tagMap = await this.loadTagsForItems(items, contextUser);
1839
+ for (const [groupKey, groupItems] of itemGroups) {
1840
+ const groupItemIDs = new Set(groupItems.map(i => NormalizeUUID(i.ID)));
1841
+ const groupChunks = chunks.filter(c => groupItemIDs.has(NormalizeUUID(c.ContentItemID)));
1842
+ if (groupChunks.length === 0)
1843
+ continue;
1844
+ let infra;
1845
+ try {
1846
+ infra = await this.resolveGroupInfrastructure(groupKey, contextUser);
1847
+ }
1848
+ catch (e) {
1849
+ LogError(`EmbedPendingChunks: infrastructure resolve failed for group ${groupKey}: ${e instanceof Error ? e.message : String(e)}`);
1850
+ stats.failed += groupChunks.length;
1851
+ continue;
1852
+ }
1853
+ await this.embedChunkGroup(groupChunks, itemById, infra, tagMap, contextUser, stats);
1854
+ }
1855
+ }
1856
+ /** Embed + upsert one infra group's chunks in bounded sub-batches, then stamp each row Complete. */
1857
+ async embedChunkGroup(groupChunks, itemById, infra, tagMap, contextUser, stats) {
1858
+ for (let i = 0; i < groupChunks.length; i += CHUNK_EMBED_SUBBATCH) {
1859
+ const batch = groupChunks.slice(i, i + CHUNK_EMBED_SUBBATCH);
1860
+ const embeddable = this.toEmbeddableChunks(batch, itemById, stats);
1861
+ if (embeddable.length === 0)
1862
+ continue;
1863
+ await this.embedAndPersistChunkBatch(embeddable, infra, tagMap, contextUser, stats);
1864
+ }
1865
+ }
1866
+ /**
1867
+ * Pair each persisted chunk with its parent item as an {@link EmbeddingChunk} (chunkID = the
1868
+ * chunk's existing PK, so the vector id + metadata identity match the live write path). Chunks
1869
+ * with empty text or a missing parent are counted as skipped and dropped.
1348
1870
  */
1349
- buildVectorRecords(allChunks, vectors, tagMap) {
1350
- return allChunks.map((chunk, idx) => ({
1351
- id: chunk.chunkIndex === 0
1352
- ? this.contentItemVectorId(chunk.item.ID)
1353
- : this.contentItemVectorId(chunk.item.ID) + `_chunk${chunk.chunkIndex}`,
1354
- values: vectors[idx],
1355
- metadata: this.buildVectorMetadata(chunk.item, tagMap.get(chunk.item.ID))
1356
- }));
1871
+ toEmbeddableChunks(batch, itemById, stats) {
1872
+ const embeddable = [];
1873
+ for (const row of batch) {
1874
+ const item = itemById.get(NormalizeUUID(row.ContentItemID));
1875
+ const text = row.Text ?? '';
1876
+ if (!item || text.trim().length === 0) {
1877
+ stats.skipped++;
1878
+ continue;
1879
+ }
1880
+ embeddable.push({ row, chunk: { item, chunkIndex: row.Sequence, text, chunkID: row.ID } });
1881
+ }
1882
+ return embeddable;
1883
+ }
1884
+ /** Embed one sub-batch's texts, upsert the vectors, and stamp each surviving chunk row Complete. */
1885
+ async embedAndPersistChunkBatch(embeddable, infra, tagMap, contextUser, stats) {
1886
+ const texts = embeddable.map(e => e.chunk.text);
1887
+ await this.EmbeddingRateLimiter.Acquire(texts.reduce((sum, t) => sum + Math.ceil(t.length / 4), 0));
1888
+ const runResult = await new AIModelRunner().RunEmbedding({
1889
+ Texts: texts,
1890
+ ModelID: infra.embeddingModelID,
1891
+ PromptID: this.resolveEmbeddingPromptID(),
1892
+ ContextUser: contextUser,
1893
+ Description: `Pending content-chunk embedding: ${embeddable.length} chunks`,
1894
+ Dimensions: infra.dimensions,
1895
+ });
1896
+ if (!runResult.Success || runResult.Vectors.length !== embeddable.length) {
1897
+ LogError(`EmbedPendingChunks: embedding returned ${runResult.Vectors.length} vectors for ${embeddable.length} chunks — ${runResult.ErrorMessage ?? 'unknown error'}`);
1898
+ stats.failed += embeddable.length;
1899
+ return;
1900
+ }
1901
+ const contentItemEntity = this.ProviderToUse.EntityByName('MJ: Content Items');
1902
+ const records = embeddable.map((e, idx) => {
1903
+ const config = this.resolveItemVectorStorageConfig(e.chunk.item);
1904
+ const record = {
1905
+ id: this.resolveChunkVectorID(e.chunk, config, false),
1906
+ values: runResult.Vectors[idx],
1907
+ metadata: this.buildVectorMetadata(e.chunk, false, tagMap.get(e.chunk.item.ID), config, contentItemEntity),
1908
+ };
1909
+ const directives = this.buildProviderDirectives(e.chunk.item, infra);
1910
+ if (directives) {
1911
+ record.providerTemporaryDirectives = directives;
1912
+ }
1913
+ return record;
1914
+ });
1915
+ if (!await this.upsertVectorRecords(records, infra)) {
1916
+ stats.failed += embeddable.length; // left 'Pending' → retried on the next run
1917
+ return;
1918
+ }
1919
+ for (let idx = 0; idx < embeddable.length; idx++) {
1920
+ if (await this.markChunkEmbedded(embeddable[idx].row, String(records[idx].id)))
1921
+ stats.embedded++;
1922
+ else
1923
+ stats.failed++;
1924
+ }
1925
+ }
1926
+ /** Stamp a chunk row as embedded: record its vector id, flip to Complete, timestamp. Best-effort. */
1927
+ async markChunkEmbedded(chunk, vectorRecordID) {
1928
+ chunk.VectorRecordID = vectorRecordID;
1929
+ chunk.EmbeddingStatus = 'Complete';
1930
+ chunk.LastEmbeddedAt = new Date();
1931
+ const saved = await chunk.Save();
1932
+ if (!saved) {
1933
+ LogError(`EmbedPendingChunks: failed to mark chunk ${chunk.ID} embedded: ${chunk.LatestResult?.CompleteMessage ?? 'unknown error'}`);
1934
+ }
1935
+ return saved;
1357
1936
  }
1358
1937
  /**
1359
1938
  * Upsert vector records to the vector database in sub-batches with rate limiting.
@@ -1365,7 +1944,9 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1365
1944
  for (let j = 0; j < records.length; j += UPSERT_CHUNK) {
1366
1945
  const chunk = records.slice(j, j + UPSERT_CHUNK);
1367
1946
  await this.VectorDBRateLimiter.Acquire();
1368
- upsertPromises.push(Promise.resolve(infra.vectorDB.CreateRecords(chunk, infra.indexName)));
1947
+ // Pass the index's ProviderConfig so drivers can apply routing (e.g. Pinecone reads
1948
+ // providerConfig.namespace, or per-record providerTemporaryDirectives set above).
1949
+ upsertPromises.push(Promise.resolve(infra.vectorDB.CreateRecords(chunk, infra.indexName, infra.providerConfig)));
1369
1950
  }
1370
1951
  const responses = await Promise.all(upsertPromises);
1371
1952
  let allSuccess = true;
@@ -1461,11 +2042,11 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1461
2042
  * Looks up the vector index by ID and the embedding model from AIEngine.
1462
2043
  */
1463
2044
  async buildVectorInfrastructure(embeddingModelID, vectorIndexID, _contextUser) {
1464
- const vectorIndex = this.khEngine.GetVectorIndexById(vectorIndexID);
2045
+ const vectorIndex = this.khEngine.GetVectorIndexByID(vectorIndexID);
1465
2046
  if (!vectorIndex) {
1466
2047
  throw new Error(`Vector index ${vectorIndexID} not found in KnowledgeHubMetadataEngine cache`);
1467
2048
  }
1468
- return this.createInfrastructureFromIndex(vectorIndex.Name, vectorIndex.VectorDatabaseID, embeddingModelID);
2049
+ return this.createInfrastructureFromIndex(vectorIndex, embeddingModelID);
1469
2050
  }
1470
2051
  /**
1471
2052
  * Fallback: resolve infrastructure from the first available VectorIndex (original behavior).
@@ -1476,25 +2057,54 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1476
2057
  throw new Error('No vector indexes found — create one in the Configuration tab first');
1477
2058
  }
1478
2059
  const vectorIndex = vectorIndexes[0];
1479
- return this.createInfrastructureFromIndex(vectorIndex.Name, vectorIndex.VectorDatabaseID, vectorIndex.EmbeddingModelID);
2060
+ return this.createInfrastructureFromIndex(vectorIndex, vectorIndex.EmbeddingModelID);
1480
2061
  }
1481
2062
  /**
1482
- * Shared helper: given vector index details and embedding model ID, resolve all
1483
- * driver instances needed for embedding + upsert. Uses AIEngine for Vector Databases.
2063
+ * Shared helper: given a VectorIndex and embedding model ID, resolve all driver instances
2064
+ * needed for embedding + upsert, plus the index's Dimensions / parsed ProviderConfig (so the
2065
+ * embed call and the upsert can honor reduced dimensions and provider routing). Uses AIEngine
2066
+ * for Vector Databases.
1484
2067
  */
1485
- async createInfrastructureFromIndex(indexName, vectorDatabaseID, embeddingModelID) {
1486
- const vectorDBEntity = AIEngine.Instance.VectorDatabases.find(db => UUIDsEqual(db.ID, vectorDatabaseID));
2068
+ async createInfrastructureFromIndex(vectorIndex, embeddingModelID) {
2069
+ const vectorDBEntity = AIEngine.Instance.VectorDatabases.find(db => UUIDsEqual(db.ID, vectorIndex.VectorDatabaseID));
1487
2070
  if (!vectorDBEntity || !vectorDBEntity.ClassKey) {
1488
- throw new Error(`Vector database ${vectorDatabaseID} not found in AIEngine cache`);
2071
+ throw new Error(`Vector database ${vectorIndex.VectorDatabaseID} not found in AIEngine cache`);
1489
2072
  }
1490
2073
  const vectorDBClassKey = vectorDBEntity.ClassKey;
1491
2074
  const aiModel = this.findEmbeddingModel(embeddingModelID);
1492
2075
  const driverClass = aiModel.DriverClass;
1493
2076
  const embeddingModelName = aiModel.APIName ?? aiModel.Name;
1494
- LogStatus(`VectorizeContentItems: USING embedding model "${aiModel.Name}" (${driverClass}), vector DB "${vectorDBClassKey}", index "${indexName}"`);
2077
+ LogStatus(`VectorizeContentItems: USING embedding model "${aiModel.Name}" (${driverClass}), vector DB "${vectorDBClassKey}", index "${vectorIndex.Name}"`);
1495
2078
  const embedding = this.createEmbeddingInstance(driverClass);
1496
2079
  const vectorDB = this.createVectorDBInstance(vectorDBClassKey);
1497
- return { embedding, vectorDB, indexName, embeddingModelName, embeddingModelID };
2080
+ return {
2081
+ embedding,
2082
+ vectorDB,
2083
+ indexName: vectorIndex.Name,
2084
+ embeddingModelName,
2085
+ embeddingModelID,
2086
+ dimensions: vectorIndex.Dimensions ?? undefined,
2087
+ providerConfig: this.parseProviderConfig(vectorIndex.ProviderConfig),
2088
+ };
2089
+ }
2090
+ /**
2091
+ * Parse a VectorIndex's ProviderConfig JSON blob into an object for provider-specific routing
2092
+ * (e.g. Pinecone's namespaceField). Mirrors the entity-vectorization pipeline's helper. Returns
2093
+ * undefined for null/empty/invalid JSON — a bad blob is logged and treated as "no config" so it
2094
+ * never blocks vectorization.
2095
+ */
2096
+ parseProviderConfig(raw) {
2097
+ if (!raw) {
2098
+ return undefined;
2099
+ }
2100
+ try {
2101
+ const parsed = JSON.parse(raw);
2102
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
2103
+ }
2104
+ catch {
2105
+ LogError(`Invalid JSON in VectorIndex.ProviderConfig, ignoring`);
2106
+ return undefined;
2107
+ }
1498
2108
  }
1499
2109
  /** Find an embedding model by ID in AIEngine, with helpful error reporting */
1500
2110
  findEmbeddingModel(embeddingModelID) {
@@ -1529,9 +2139,44 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1529
2139
  return instance;
1530
2140
  }
1531
2141
  /** SHA-1 deterministic vector ID for a content item */
1532
- contentItemVectorId(contentItemId) {
2142
+ contentItemVectorID(contentItemId) {
1533
2143
  return crypto.createHash('sha1').update(`content-item_${contentItemId}`).digest('hex');
1534
2144
  }
2145
+ /**
2146
+ * SHA-1 deterministic vector ID for a specific chunk of a content item, used only by the
2147
+ * 'hash' {@link VectorIDStrategy} for multi-chunk items. It is deterministic on
2148
+ * (contentItemId, chunkIndex), which is exactly what makes 'hash' unsafe with re-chunk +
2149
+ * purge — a re-chunk that produces the same index reuses the id (documented on the config).
2150
+ */
2151
+ contentItemChunkVectorID(contentItemId, chunkIndex) {
2152
+ return crypto.createHash('sha1').update(`content-item-chunk_${contentItemId}_${chunkIndex}`).digest('hex');
2153
+ }
2154
+ /**
2155
+ * Resolve the vector-storage behavior for a content item using the cascade:
2156
+ * ContentSource override -> ContentType default -> hardcoded default. Reads the strongly-typed
2157
+ * ConfigurationObject accessors emitted by CodeGen for each entity's Configuration JSONType.
2158
+ */
2159
+ resolveItemVectorStorageConfig(item) {
2160
+ const source = this.khEngine.GetContentSourceByID(item.ContentSourceID);
2161
+ const srcCfg = source?.ConfigurationObject;
2162
+ const contentType = this.khEngine.GetContentTypeByID(item.ContentTypeID);
2163
+ const typeCfg = contentType?.ConfigurationObject;
2164
+ return {
2165
+ vectorIDStrategy: srcCfg?.VectorIDStrategy ?? typeCfg?.VectorIDStrategy ?? DEFAULT_VECTOR_ID_STRATEGY,
2166
+ chunkTextStorage: srcCfg?.ChunkTextStorage ?? typeCfg?.ChunkTextStorage ?? DEFAULT_CHUNK_TEXT_STORAGE,
2167
+ metadata: srcCfg?.VectorMetadata ?? typeCfg?.VectorMetadata ?? undefined,
2168
+ };
2169
+ }
2170
+ /**
2171
+ * True when this item's single embedding vector is stored at the ContentItem level (on
2172
+ * ContentItem.VectorRecordID) rather than in a ContentItemChunk row. Only the 'mixed'
2173
+ * storage mode does this, and only when the item produced exactly one chunk; 'alwaysChunk'
2174
+ * always writes a chunk row (item-level id stays null). buildVectorRecords and
2175
+ * persistVectorReferences share this predicate so the vector id and its persistence agree.
2176
+ */
2177
+ isItemLevelVector(config, chunkCount) {
2178
+ return config.chunkTextStorage === 'mixed' && chunkCount === 1;
2179
+ }
1535
2180
  /** Build the text that gets embedded: Title + Description + full Text */
1536
2181
  /**
1537
2182
  * Max tokens per embedding chunk. text-embedding-3-small supports 8,191 tokens.
@@ -1542,7 +2187,7 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1542
2187
  * Build the text to embed for a content item, and chunk it if it exceeds
1543
2188
  * the embedding model's token limit. Returns one or more text chunks.
1544
2189
  */
1545
- buildEmbeddingChunks(item) {
2190
+ async buildEmbeddingChunks(item) {
1546
2191
  const parts = [];
1547
2192
  if (item.Name)
1548
2193
  parts.push(item.Name);
@@ -1556,45 +2201,188 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1556
2201
  if (full.length <= charLimit) {
1557
2202
  return [full];
1558
2203
  }
1559
- // Chunk using TextChunker for token-aware splitting
1560
2204
  LogStatus(`[Autotag] Chunking embedding text for "${item.Name}" (${full.length} chars, ~${Math.ceil(full.length / 4)} tokens)`);
1561
- try {
1562
- const chunkParams = {
1563
- Text: full,
1564
- MaxChunkTokens: AutotagBaseEngine_1.MAX_EMBEDDING_TOKENS,
1565
- OverlapTokens: 100,
1566
- };
1567
- const chunks = TextChunker.ChunkText(chunkParams);
1568
- LogStatus(`[Autotag] Split into ${chunks.length} chunks for embedding`);
1569
- return chunks.map(c => c.Text);
2205
+ const segments = await this.segmentTextForChunking(full, {
2206
+ MaxSegmentTokens: AutotagBaseEngine_1.MAX_EMBEDDING_TOKENS,
2207
+ OverlapTokens: 100,
2208
+ TextStrategy: 'sentence',
2209
+ });
2210
+ if (segments) {
2211
+ LogStatus(`[Autotag] Split into ${segments.length} chunks for embedding`);
2212
+ return segments;
1570
2213
  }
1571
- catch {
1572
- // Fallback: simple character-based splitting
1573
- const result = [];
1574
- for (let i = 0; i < full.length; i += charLimit) {
1575
- result.push(full.substring(i, i + charLimit));
1576
- }
1577
- return result;
2214
+ // Fallback: simple character-based splitting
2215
+ const result = [];
2216
+ for (let i = 0; i < full.length; i += charLimit) {
2217
+ result.push(full.substring(i, i + charLimit));
1578
2218
  }
2219
+ return result;
1579
2220
  }
1580
- /** Build metadata stored alongside the vector — truncate large text fields */
1581
- buildVectorMetadata(item, tags) {
1582
- const META_TEXT_LIMIT = 1000;
1583
- const meta = {
1584
- RecordID: item.ID,
1585
- Entity: 'MJ: Content Items',
1586
- ContentSourceID: item.ContentSourceID,
1587
- ContentSourceTypeID: item.ContentSourceTypeID,
1588
- };
2221
+ /**
2222
+ * Build the metadata object stored alongside the vector. Mirrors the entity-vectorization
2223
+ * pipeline's decomposed shape ({@link addContentSystemMetadata}/{@link addCuratedMetadata}/
2224
+ * {@link addStrategyDisplayFields}/{@link addEntityIconMetadata}/{@link addUpdatedAtMetadata}/
2225
+ * {@link addTagsMetadata}) so each concern can be overridden independently.
2226
+ *
2227
+ * `VectorMetadata.FieldStrategy` selects the field set:
2228
+ * - unset ⇒ the curated content default (source ids + Title / Description / URL), preserving
2229
+ * historical behavior.
2230
+ * - 'all' / 'include' / 'exclude' / 'explicit' ⇒ ContentItem fields resolved by
2231
+ * {@link getContentDisplayFields}, with per-field StoreAs coercion + truncation.
2232
+ *
2233
+ * The identity keys are chunk-aware (see the Chunk-Identity Contract); under 'explicit' only
2234
+ * `Entity` is kept so content search results stay labeled (record id is recovered from the
2235
+ * vector id under the default 'recordId' strategy).
2236
+ */
2237
+ buildVectorMetadata(chunk, isItemLevel, tags, config, contentItemEntity) {
2238
+ const item = chunk.item;
2239
+ const metaCfg = config.metadata;
2240
+ const strategy = metaCfg?.FieldStrategy;
2241
+ const explicit = strategy === 'explicit';
2242
+ const meta = {};
2243
+ this.addContentSystemMetadata(meta, chunk, isItemLevel, explicit);
2244
+ if (!strategy) {
2245
+ this.addCuratedMetadata(meta, item);
2246
+ }
2247
+ else {
2248
+ const all = item.GetAll();
2249
+ this.addEntityIconMetadata(meta, contentItemEntity, metaCfg, explicit);
2250
+ this.addUpdatedAtMetadata(meta, all, metaCfg, explicit);
2251
+ // Display fields LAST so an explicitly-configured field (e.g. __mj_UpdatedAt with a
2252
+ // StoreAs override) wins over the toggle-driven default value — matches the entity side.
2253
+ this.addStrategyDisplayFields(meta, all, contentItemEntity, metaCfg);
2254
+ }
2255
+ this.addTagsMetadata(meta, tags, metaCfg, explicit);
2256
+ // Optional embedded-text copy (surfaces as the search snippet). Off by default — external
2257
+ // hydrators read the authoritative text from the row. Honored under every strategy.
2258
+ if (metaCfg?.IncludeText && chunk.text) {
2259
+ meta['Text'] = chunk.text.substring(0, DEFAULT_METADATA_TRUNCATION);
2260
+ }
2261
+ return meta;
2262
+ }
2263
+ /**
2264
+ * Add the identity/system keys. `Entity` is always present (chunk-aware). Under 'explicit' the
2265
+ * rest are omitted (minimal metadata); otherwise `RecordID` — plus `ContentItemID` / `Sequence`
2266
+ * for chunk vectors — are included so an external hydrator can fetch the row(s).
2267
+ */
2268
+ addContentSystemMetadata(meta, chunk, isItemLevel, explicit) {
2269
+ meta['Entity'] = isItemLevel ? 'MJ: Content Items' : 'MJ: Content Item Chunks';
2270
+ if (explicit)
2271
+ return;
2272
+ if (isItemLevel) {
2273
+ meta['RecordID'] = chunk.item.ID;
2274
+ }
2275
+ else {
2276
+ meta['RecordID'] = chunk.chunkID;
2277
+ meta['ContentItemID'] = chunk.item.ID;
2278
+ meta['Sequence'] = chunk.chunkIndex;
2279
+ }
2280
+ }
2281
+ /** The curated default content metadata set (historical behavior when no FieldStrategy is set). */
2282
+ addCuratedMetadata(meta, item) {
2283
+ meta['ContentSourceID'] = item.ContentSourceID;
2284
+ meta['ContentSourceTypeID'] = item.ContentSourceTypeID;
1589
2285
  if (item.Name)
1590
- meta['Title'] = item.Name.substring(0, META_TEXT_LIMIT);
2286
+ meta['Title'] = item.Name.substring(0, DEFAULT_METADATA_TRUNCATION);
1591
2287
  if (item.Description)
1592
- meta['Description'] = item.Description.substring(0, META_TEXT_LIMIT);
2288
+ meta['Description'] = item.Description.substring(0, DEFAULT_METADATA_TRUNCATION);
1593
2289
  if (item.URL)
1594
2290
  meta['URL'] = item.URL;
1595
- if (tags && tags.length > 0)
2291
+ }
2292
+ /** Add the strategy-selected ContentItem fields, with per-field StoreAs coercion + truncation. */
2293
+ addStrategyDisplayFields(meta, all, contentItemEntity, metaCfg) {
2294
+ for (const field of this.getContentDisplayFields(contentItemEntity, metaCfg)) {
2295
+ const value = all[field.Name];
2296
+ if (value == null)
2297
+ continue;
2298
+ this.setCoercedFieldValue(meta, field, value, metaCfg);
2299
+ }
2300
+ }
2301
+ /**
2302
+ * Resolve which ContentItem fields go into metadata for the configured strategy. Mirrors the
2303
+ * entity pipeline: 'include'/'explicit' take only fields explicitly marked Included (candidate
2304
+ * set is the full field list; only unstorable binary types are refused); 'all'/'exclude' take
2305
+ * the conservative eligible set (no PKs, uniqueidentifiers, binary, or __mj_* fields) minus any
2306
+ * marked Included:false.
2307
+ */
2308
+ getContentDisplayFields(entityInfo, metaCfg) {
2309
+ if (!entityInfo)
2310
+ return [];
2311
+ const strategy = metaCfg?.FieldStrategy;
2312
+ const overrides = metaCfg?.Fields ?? {};
2313
+ if (strategy === 'include' || strategy === 'explicit') {
2314
+ return entityInfo.Fields.filter(f => {
2315
+ if (overrides[f.Name]?.Included !== true)
2316
+ return false;
2317
+ if (UNSTORABLE_METADATA_TYPES.has(f.Type.toLowerCase())) {
2318
+ LogError(`Field "${f.Name}" is a binary type (${f.Type}) and cannot be stored in vector metadata — ignoring its explicit inclusion`);
2319
+ return false;
2320
+ }
2321
+ return true;
2322
+ });
2323
+ }
2324
+ const skip = new Set(['uniqueidentifier', ...UNSTORABLE_METADATA_TYPES]);
2325
+ return entityInfo.Fields.filter(f => !f.IsPrimaryKey &&
2326
+ !f.Name.startsWith('__mj_') &&
2327
+ !skip.has(f.Type.toLowerCase()) &&
2328
+ overrides[f.Name]?.Included !== false);
2329
+ }
2330
+ /** Store one field's value with its configured/typed coercion (epoch/number/boolean/UUID/string). */
2331
+ setCoercedFieldValue(meta, field, value, metaCfg) {
2332
+ const storeAs = metaCfg?.Fields?.[field.Name]?.StoreAs;
2333
+ const fieldType = field.Type?.toLowerCase() ?? '';
2334
+ if (storeAs === 'epochSeconds' || storeAs === 'epochMilliseconds') {
2335
+ const ms = new Date(String(value)).getTime();
2336
+ if (!Number.isNaN(ms))
2337
+ meta[field.Name] = storeAs === 'epochSeconds' ? Math.floor(ms / 1000) : ms;
2338
+ }
2339
+ else if (storeAs === 'number' || (storeAs == null && NUMERIC_SQL_TYPES.has(fieldType))) {
2340
+ const n = Number(value);
2341
+ if (!Number.isNaN(n))
2342
+ meta[field.Name] = n;
2343
+ }
2344
+ else if (storeAs === 'boolean') {
2345
+ meta[field.Name] = Boolean(value);
2346
+ }
2347
+ else if (fieldType === 'uniqueidentifier' && typeof value === 'string' && IsValidUUID(value)) {
2348
+ meta[field.Name] = NormalizeUUID(value);
2349
+ }
2350
+ else {
2351
+ meta[field.Name] = String(value).substring(0, this.resolveMetadataTruncationLimit(field, metaCfg));
2352
+ }
2353
+ }
2354
+ /** Per-field truncation: explicit override → field MaxLength (if small) → global default. */
2355
+ resolveMetadataTruncationLimit(field, metaCfg) {
2356
+ const fieldCfg = metaCfg?.Fields?.[field.Name];
2357
+ if (fieldCfg?.TruncationLimit != null && fieldCfg.TruncationLimit > 0)
2358
+ return fieldCfg.TruncationLimit;
2359
+ if (field.MaxLength && field.MaxLength > 0 && field.MaxLength <= 5000)
2360
+ return field.MaxLength;
2361
+ return metaCfg?.DefaultTruncationLimit ?? DEFAULT_METADATA_TRUNCATION;
2362
+ }
2363
+ /** Add the content entity's icon: default on under a set strategy, opt-in under 'explicit'. */
2364
+ addEntityIconMetadata(meta, entityInfo, metaCfg, explicit) {
2365
+ const include = explicit ? metaCfg?.IncludeEntityIcon === true : metaCfg?.IncludeEntityIcon !== false;
2366
+ if (entityInfo?.Icon && include)
2367
+ meta['EntityIcon'] = entityInfo.Icon;
2368
+ }
2369
+ /** Add __mj_UpdatedAt for recency: default on under a set strategy, opt-in under 'explicit'. */
2370
+ addUpdatedAtMetadata(meta, all, metaCfg, explicit) {
2371
+ const include = explicit ? metaCfg?.IncludeUpdatedAt === true : metaCfg?.IncludeUpdatedAt !== false;
2372
+ if (all['__mj_UpdatedAt'] && include)
2373
+ meta['__mj_UpdatedAt'] = String(all['__mj_UpdatedAt']);
2374
+ }
2375
+ /**
2376
+ * Add the item's Tags array. Tags aren't a ContentItem field (they're derived), so they're a
2377
+ * toggle like icon/updatedAt: on by default (and under the curated default), opt-in under
2378
+ * 'explicit'.
2379
+ */
2380
+ addTagsMetadata(meta, tags, metaCfg, explicit) {
2381
+ if (!tags || tags.length === 0)
2382
+ return;
2383
+ const include = explicit ? metaCfg?.IncludeTags === true : metaCfg?.IncludeTags !== false;
2384
+ if (include)
1596
2385
  meta['Tags'] = tags;
1597
- return meta;
1598
2386
  }
1599
2387
  /** Load all tags for the given items in a single RunView call */
1600
2388
  async loadTagsForItems(items, contextUser) {
@@ -1776,6 +2564,8 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1776
2564
  Texts: [truncated],
1777
2565
  PromptID: embeddingPromptID ?? undefined,
1778
2566
  ContextUser: contextUser,
2567
+ // Must match the index's dimensions so the query vector is comparable to stored vectors.
2568
+ Dimensions: infra.dimensions,
1779
2569
  });
1780
2570
  if (!runResult?.Vectors || runResult.Vectors.length === 0) {
1781
2571
  LogStatus(`[Dedup] Embedding failed for item ${contentItem.ID}, skipping vector dedup`);