@memberjunction/content-autotagging 6.1.0-edge.2 → 6.1.0-edge.4

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.
@@ -16,7 +16,7 @@ import { ProcessRunParams, ContentItemProcessParams } from './process.types.js';
16
16
  import { ClassificationContextResolver } from './ClassificationContextResolver.js';
17
17
  import { FieldPathResolver } from './FieldPathResolver.js';
18
18
  import { toZonedTime } from 'date-fns-tz';
19
- import axios from 'axios';
19
+ import { HttpGet } from '@memberjunction/network-utils';
20
20
  import * as cheerio from 'cheerio';
21
21
  import crypto from 'crypto';
22
22
  import { BaseEmbeddings, GetAIAPIKey } from '@memberjunction/ai';
@@ -97,6 +97,8 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
97
97
  * CancellationRequested machinery.
98
98
  */
99
99
  this.OnAfterBatch = null;
100
+ /** Declarations already reported as unresolvable, so the warning is once per run, not per vector. */
101
+ this.unresolvableDeclarations = new Set();
100
102
  }
101
103
  static { AutotagBaseEngine_1 = this; }
102
104
  static get Instance() {
@@ -1116,8 +1118,8 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
1116
1118
  return `${contentTypeName} in ${fileTypeName} format obtained from a ${sourceTypeName} source`;
1117
1119
  }
1118
1120
  async getChecksumFromURL(url) {
1119
- const response = await axios.get(url);
1120
- const content = String(response.data);
1121
+ const response = await HttpGet(url, { ResponseType: 'text' });
1122
+ const content = String(response.Data);
1121
1123
  return crypto.createHash('sha256').update(content).digest('hex');
1122
1124
  }
1123
1125
  async getChecksumFromText(text) {
@@ -2266,24 +2268,49 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
2266
2268
  }
2267
2269
  /** Create a BaseEmbeddings instance for a given driver class */
2268
2270
  createEmbeddingInstance(driverClass) {
2271
+ // No pre-flight key check, deliberately — the same call the EntityDocument pipeline already
2272
+ // makes (`entityVectorSync.ts`), for the reason documented there: an empty key is legitimate
2273
+ // for local-only drivers (LocalEmbedding runs ONNX in-process and does `super(apiKey || 'local')`),
2274
+ // and for a cloud driver that genuinely needs one the constructor or the first inference call
2275
+ // raises a real provider-level auth error, which is more actionable than a guard here.
2276
+ // Gating up front made local embedding models unusable from this pipeline without inventing a
2277
+ // meaningless AI_VENDOR_API_KEY__LocalEmbedding.
2269
2278
  const apiKey = GetAIAPIKey(driverClass);
2270
- if (!apiKey) {
2271
- throw new Error(`No API key found for embedding driver ${driverClass} — set AI_VENDOR_API_KEY__${driverClass} in .env`);
2272
- }
2273
- const instance = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEmbeddings, driverClass, apiKey);
2279
+ const instance = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEmbeddings, driverClass, apiKey || '');
2274
2280
  if (!instance)
2275
2281
  throw new Error(`Failed to create embedding instance for ${driverClass}`);
2276
2282
  return instance;
2277
2283
  }
2278
- /** Create a VectorDBBase instance for a given class key */
2284
+ /**
2285
+ * Create a VectorDBBase instance for a given class key, wired for colocated storage where the
2286
+ * provider supports it.
2287
+ *
2288
+ * The ordering is load-bearing. A **colocated** provider (SQLServerVectorDatabase, pgvector) keeps
2289
+ * vectors in the application's own database: it has no credentials to present, and it needs the
2290
+ * active data-provider connection handed to it before use or it throws "requires a host connection".
2291
+ * Neither fact is knowable until the instance exists — so instantiate first, wire, and only then
2292
+ * enforce the key, for providers that actually need one.
2293
+ *
2294
+ * Demanding a key up front made every colocated store unusable from this pipeline: callers had to
2295
+ * invent a meaningless `AI_VENDOR_API_KEY__SQLServerVectorDatabase`, and even then the missing host
2296
+ * connection failed later and confusingly — `CreateIndex` logged and continued, then vectorization
2297
+ * died on a vector-database cache miss, which reads like bad metadata rather than a missing wire-up.
2298
+ *
2299
+ * This mirrors what the EntityDocument pipeline already does (`entityVectorSync.ts`), so the two
2300
+ * vectorization paths now agree about colocated providers.
2301
+ */
2279
2302
  createVectorDBInstance(classKey) {
2280
2303
  const apiKey = GetAIAPIKey(classKey);
2281
- if (!apiKey) {
2282
- throw new Error(`No API key found for vector DB ${classKey}set AI_VENDOR_API_KEY__${classKey} in .env`);
2283
- }
2284
- const instance = MJGlobal.Instance.ClassFactory.CreateInstance(VectorDBBase, classKey, apiKey);
2304
+ // The sentinel is required, not cosmetic: `VectorDBBase`'s constructor rejects an empty key
2305
+ // outright, and a colocated provider does not override itso passing '' would throw
2306
+ // "API key cannot be empty" for precisely the keyless case this method exists to support.
2307
+ const instance = MJGlobal.Instance.ClassFactory.CreateInstance(VectorDBBase, classKey, apiKey || 'colocated');
2285
2308
  if (!instance)
2286
2309
  throw new Error(`Failed to create vector DB instance for ${classKey}`);
2310
+ instance.TryWireColocatedHost(this.ProviderToUse);
2311
+ if (!instance.SupportsColocatedQuery && instance.RequiresAPIKey && !apiKey) {
2312
+ throw new Error(`No API key found for vector DB ${classKey} — set AI_VENDOR_API_KEY__${classKey} in .env`);
2313
+ }
2287
2314
  return instance;
2288
2315
  }
2289
2316
  /** SHA-1 deterministic vector ID for a content item */
@@ -2313,6 +2340,13 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
2313
2340
  vectorIDStrategy: srcCfg?.VectorIDStrategy ?? typeCfg?.VectorIDStrategy ?? DEFAULT_VECTOR_ID_STRATEGY,
2314
2341
  chunkTextStorage: srcCfg?.ChunkTextStorage ?? typeCfg?.ChunkTextStorage ?? DEFAULT_CHUNK_TEXT_STORAGE,
2315
2342
  metadata: srcCfg?.VectorMetadata ?? typeCfg?.VectorMetadata ?? undefined,
2343
+ // Source only — deliberately NOT part of the ContentType cascade the other knobs use. The
2344
+ // others describe HOW to store a vector, which a content type can sensibly default for every
2345
+ // source that adopts it. This one asserts WHAT the vectors are, and it decides which entity's
2346
+ // permissions search evaluates, so a type-level default would make that assertion on behalf of
2347
+ // sources the type's author never saw — including ones storing their vectors at a different
2348
+ // level, where the declaration would be wrong.
2349
+ vectorEntityName: srcCfg?.VectorEntityName,
2316
2350
  };
2317
2351
  }
2318
2352
  /**
@@ -2380,7 +2414,9 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
2380
2414
  *
2381
2415
  * The identity keys are chunk-aware (see the Chunk-Identity Contract); under 'explicit' only
2382
2416
  * `Entity` is kept so content search results stay labeled (record id is recovered from the
2383
- * vector id under the default 'recordId' strategy).
2417
+ * vector id under the default 'recordId' strategy) — unless the source declares its vector entity,
2418
+ * in which case `Entity` gives way to `ContentSourceID` and search resolves the name from the
2419
+ * declaration. See {@link canOmitEntityMetadataKey} for the conditions and why each one exists.
2384
2420
  */
2385
2421
  buildVectorMetadata(chunk, isItemLevel, tags, config, contentItemEntity) {
2386
2422
  const item = chunk.item;
@@ -2388,7 +2424,7 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
2388
2424
  const strategy = metaCfg?.FieldStrategy;
2389
2425
  const explicit = strategy === 'explicit';
2390
2426
  const meta = {};
2391
- this.addContentSystemMetadata(meta, chunk, isItemLevel, explicit);
2427
+ this.addContentSystemMetadata(meta, chunk, isItemLevel, explicit, config);
2392
2428
  if (!strategy) {
2393
2429
  this.addCuratedMetadata(meta, item);
2394
2430
  }
@@ -2406,14 +2442,26 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
2406
2442
  if (metaCfg?.IncludeText && chunk.text) {
2407
2443
  meta['Text'] = chunk.text.substring(0, DEFAULT_METADATA_TRUNCATION);
2408
2444
  }
2445
+ this.enforceAttributionKey(meta, chunk, explicit, config);
2409
2446
  return meta;
2410
2447
  }
2411
2448
  /**
2412
- * Add the identity/system keys. `Entity` is always present (chunk-aware). Under 'explicit' the
2413
- * rest are omitted (minimal metadata); otherwise `RecordID` plus `ContentItemID` / `Sequence`
2414
- * for chunk vectors are included so an external hydrator can fetch the row(s).
2415
- */
2416
- addContentSystemMetadata(meta, chunk, isItemLevel, explicit) {
2449
+ * Add the identity/system keys. `Entity` is present unless the source declares its vector entity
2450
+ * and {@link canOmitEntityMetadataKey} allows dropping it, in which case `ContentSourceID` takes
2451
+ * its place as the key search attributes through. Under 'explicit' the rest are omitted (minimal
2452
+ * metadata); otherwise `RecordID` — plus `ContentItemID` / `Sequence` for chunk vectors — are
2453
+ * included so an external hydrator can fetch the row(s).
2454
+ */
2455
+ addContentSystemMetadata(meta, chunk, isItemLevel, explicit, config) {
2456
+ if (this.canOmitEntityMetadataKey(config, explicit)) {
2457
+ // The invariant that makes omission safe, and the whole reason this is not just a deletion:
2458
+ // a vector with no `Entity` key must still carry the key attribution resolves THROUGH, or the
2459
+ // match reaches search with nothing to resolve and is discarded without being shown. Written
2460
+ // unconditionally — a source configuring `ContentSourceID` in its own field list is applied
2461
+ // later (display fields go last) and so still wins, StoreAs coercion included.
2462
+ meta['ContentSourceID'] = chunk.item.ContentSourceID;
2463
+ return;
2464
+ }
2417
2465
  meta['Entity'] = isItemLevel ? 'MJ: Content Items' : 'MJ: Content Item Chunks';
2418
2466
  if (explicit)
2419
2467
  return;
@@ -2426,6 +2474,148 @@ let AutotagBaseEngine = class AutotagBaseEngine extends BaseEngine {
2426
2474
  meta['Sequence'] = chunk.chunkIndex;
2427
2475
  }
2428
2476
  }
2477
+ /**
2478
+ * Last line of defence for the omission invariant: when `Entity` was dropped, the vector MUST leave
2479
+ * here carrying a `ContentSourceID` search can actually resolve — a non-empty **string**.
2480
+ *
2481
+ * The promoted key is written before the strategy's own display fields, deliberately, so a source
2482
+ * that configures `ContentSourceID` with its own rules wins. That is right for every coercion but
2483
+ * one: `StoreAs: 'boolean'` assigns `Boolean(value)` unconditionally
2484
+ * ({@link setCoercedFieldValue} — the numeric and epoch branches are guarded, that one is not), so
2485
+ * the configured rule would replace the id with `true`. The reader requires a string
2486
+ * (`typeof contentSourceID === 'string'`), so the match would arrive attributable-by-design and
2487
+ * unattributable-in-fact — the silent drop this feature exists to prevent, reintroduced by a field
2488
+ * rule that looks unrelated.
2489
+ *
2490
+ * So configured rules win up to the point where they would break attribution, and no further.
2491
+ */
2492
+ enforceAttributionKey(meta, chunk, explicit, config) {
2493
+ if (!this.canOmitEntityMetadataKey(config, explicit)) {
2494
+ return;
2495
+ }
2496
+ const current = meta['ContentSourceID'];
2497
+ if (typeof current === 'string' && current.trim().length > 0) {
2498
+ return;
2499
+ }
2500
+ LogStatus(`[Autotag] Content source ${chunk.item.ContentSourceID} configures ContentSourceID in a way ` +
2501
+ `that leaves no resolvable value (${JSON.stringify(current)}), and its vectors omit the Entity ` +
2502
+ `key — restoring the id so search can still attribute them. Remove the StoreAs override on ` +
2503
+ `ContentSourceID, or set VectorEntityName aside and let Entity be written instead.`);
2504
+ meta['ContentSourceID'] = chunk.item.ContentSourceID;
2505
+ }
2506
+ /**
2507
+ * Whether this source's vectors may omit the `Entity` metadata key and let search resolve the entity
2508
+ * from the source's declaration instead of carrying it on every vector.
2509
+ *
2510
+ * Each condition closes a way attribution could otherwise fail, and attribution failure is not a
2511
+ * cosmetic loss: a match search cannot name is discarded by the permission filter, not returned
2512
+ * unlabelled.
2513
+ *
2514
+ * - **`explicit` only.** The other strategies document a populated metadata set; dropping a key their
2515
+ * consumers are told is always there would be a behavior change for them.
2516
+ * - **A declaration must exist**, and must resolve to the chunk entity or a subtype of it — see
2517
+ * {@link declarationWillResolve}. Without one nothing downstream can answer what the vector is.
2518
+ * - **`'alwaysChunk'` only.** One declaration names one entity, and `'mixed'` emits ContentItem-level
2519
+ * vectors for single-chunk items and ContentItemChunk-level vectors for the rest
2520
+ * ({@link isItemLevelVector}) — two entities out of one source, which a single declaration cannot
2521
+ * describe. Written as an allowlist so a storage mode added later keeps writing the key until
2522
+ * someone decides otherwise.
2523
+ * - **`'recordId'` only.** Under `explicit` the `RecordID` key is dropped too, leaving the vector's own
2524
+ * id as the only pointer back to a row — which IS the chunk id under `'recordId'`, and a SHA-1 digest
2525
+ * under `'hash'` ({@link resolveChunkVectorID}). Omitting `Entity` under `'hash'` would attribute the
2526
+ * match successfully and then hand search an id that resolves against no row: the same silent
2527
+ * disappearance, one step further along.
2528
+ *
2529
+ * The level IS checked here (it was not, originally, and that was wrong — the reader validates
2530
+ * family membership, which both content-item entities satisfy, so a chunk-level source declaring the
2531
+ * item entity passed both sides and pointed search at a table holding none of its ids).
2532
+ */
2533
+ canOmitEntityMetadataKey(config, explicit) {
2534
+ // Trimmed, because the reader trims before deciding whether a declaration exists
2535
+ // (`declaredVectorEntityName`). A whitespace-only value is truthy here and empty there, so the
2536
+ // untrimmed test would omit the key against a declaration the reader then refuses.
2537
+ const declared = config.vectorEntityName?.trim();
2538
+ if (!explicit
2539
+ || !declared
2540
+ || config.chunkTextStorage !== 'alwaysChunk'
2541
+ || config.vectorIDStrategy !== 'recordId') {
2542
+ return false;
2543
+ }
2544
+ return this.declarationWillResolve(declared);
2545
+ }
2546
+ /**
2547
+ * Whether a declared entity name resolves in metadata — checked HERE, before the `Entity` key is
2548
+ * dropped, and not left to the reader.
2549
+ *
2550
+ * This asymmetry is the difference between a recoverable mistake and an unrecoverable one. The
2551
+ * reader refuses a name it cannot resolve and falls through to `'Unknown'`, at which point the
2552
+ * results are silently discarded — but by then the vectors were written without `Entity`, so
2553
+ * correcting the name does not bring them back. Only a full re-embed does. The likeliest way in is
2554
+ * the most ordinary: a core entity written without its `MJ: ` prefix.
2555
+ *
2556
+ * Keeping the key when the name does not resolve costs one redundant metadata field and loses
2557
+ * nothing. So this deliberately fails SAFE rather than fail-closed.
2558
+ *
2559
+ * It checks resolution only, not whether the entity is in the content-item family. That refusal is
2560
+ * the reader's security decision to make (an arbitrary entity name in a writable blob must not
2561
+ * choose which permissions apply), and it is not something the write side should be able to
2562
+ * pre-approve.
2563
+ */
2564
+ declarationWillResolve(declared) {
2565
+ const entity = this.ProviderToUse.EntityByName(declared);
2566
+ if (!entity) {
2567
+ this.refuseDeclarationOnce(declared, `does not resolve in metadata (core entities carry the \`MJ: \` prefix)`);
2568
+ return false;
2569
+ }
2570
+ // Level, not just family. Omission requires `alwaysChunk`, so every vector this gate governs is
2571
+ // chunk-level and its id is a ContentItemChunk primary key. A declaration naming the ITEM entity
2572
+ // — or a subtype of it, which is what the config docs steer you toward when row-level security
2573
+ // lives on an extension — would therefore point search at a table containing none of these ids.
2574
+ // The read side validates family membership only, so this is the only place the mismatch can be
2575
+ // caught before the key is gone for good.
2576
+ if (!this.isChunkLevelEntity(entity)) {
2577
+ this.refuseDeclarationOnce(declared, `is not "${AutotagBaseEngine_1.CHUNK_ENTITY_NAME}" or a subtype of it, but this source stores ` +
2578
+ `chunk-level vectors (ChunkTextStorage 'alwaysChunk'), so their ids are chunk keys`);
2579
+ return false;
2580
+ }
2581
+ return true;
2582
+ }
2583
+ /** The entity whose rows chunk-level vectors actually are. */
2584
+ static { this.CHUNK_ENTITY_NAME = 'MJ: Content Item Chunks'; }
2585
+ /**
2586
+ * Whether an entity IS-A {@link CHUNK_ENTITY_NAME} — itself, or a subtype somewhere up its IS-A
2587
+ * chain, so a consumer may still declare an extension carrying its own row-level security.
2588
+ *
2589
+ * Walks `ParentID` through `this.ProviderToUse` rather than `EntityInfo.ParentChain`, which resolves
2590
+ * each step against the process-global `Metadata.Provider` by design — mixing the two would resolve
2591
+ * the declaration on one metadata set and walk its ancestry on another.
2592
+ */
2593
+ isChunkLevelEntity(entity) {
2594
+ const visited = new Set();
2595
+ let current = entity;
2596
+ while (current) {
2597
+ if (current.Name === AutotagBaseEngine_1.CHUNK_ENTITY_NAME) {
2598
+ return true;
2599
+ }
2600
+ if (!current.ParentID || visited.has(current.ID)) {
2601
+ return false; // root reached, or a cycle in the metadata
2602
+ }
2603
+ visited.add(current.ID);
2604
+ current = this.ProviderToUse.EntityByID(current.ParentID) ?? undefined;
2605
+ }
2606
+ return false;
2607
+ }
2608
+ /** Report a refused declaration once per run — this is evaluated per vector, so per-call would spam. */
2609
+ refuseDeclarationOnce(declared, because) {
2610
+ if (this.unresolvableDeclarations.has(declared)) {
2611
+ return;
2612
+ }
2613
+ this.unresolvableDeclarations.add(declared);
2614
+ LogStatus(`[Autotag] Vector entity declaration "${declared}" ${because}. Keeping the \`Entity\` key ` +
2615
+ `rather than omitting it — search would otherwise have no way to attribute these vectors, ` +
2616
+ `and because the key would never have been written, correcting the configuration later would ` +
2617
+ `not recover them without a re-embed.`);
2618
+ }
2429
2619
  /** The curated default content metadata set (historical behavior when no FieldStrategy is set). */
2430
2620
  addCuratedMetadata(meta, item) {
2431
2621
  meta['ContentSourceID'] = item.ContentSourceID;