@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.
- package/LICENSE +180 -4
- package/README.md +1 -1
- package/dist/Engine/generic/AutotagBaseEngine.d.ts +112 -5
- package/dist/Engine/generic/AutotagBaseEngine.d.ts.map +1 -1
- package/dist/Engine/generic/AutotagBaseEngine.js +209 -19
- package/dist/Engine/generic/AutotagBaseEngine.js.map +1 -1
- package/dist/RSSFeed/generic/AutotagRSSFeed.d.ts.map +1 -1
- package/dist/RSSFeed/generic/AutotagRSSFeed.js +10 -8
- package/dist/RSSFeed/generic/AutotagRSSFeed.js.map +1 -1
- package/dist/Websites/generic/AutotagWebsite.d.ts +2 -2
- package/dist/Websites/generic/AutotagWebsite.js +9 -9
- package/dist/Websites/generic/AutotagWebsite.js.map +1 -1
- package/package.json +19 -19
|
@@ -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
|
|
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
|
|
1120
|
-
const content = String(response.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
2282
|
-
|
|
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 it — so 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
|
|
2413
|
-
*
|
|
2414
|
-
*
|
|
2415
|
-
|
|
2416
|
-
|
|
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;
|