Skip to content
This repository was archived by the owner on Oct 14, 2026. It is now read-only.

Commit 9b672af

Browse files
author
Tanase Butcaru
committed
fix(data-access): reject sparse type lists, name rejected values, clear unregistered entity types (LLMO-7445)
1 parent 2d4ef8f commit 9b672af

4 files changed

Lines changed: 56 additions & 18 deletions

File tree

‎docs/adr/0002-embedding-client-and-semantic-index-utils.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Following ADR-0001's test (*true for every consumer → shared; specific to one
4040

4141
**Consumers own:**
4242
- Caching. The durable query-embedding cache (`semantic_query_embedding`) and its key policy (`hash(normalizedText)+model+dims`) live in the data-access helpers/consumer, not the client — staleness/keying is a consumer concern.
43-
- Model/dimension values come from `SEMANTIC_MATCHING_CONFIG` (`embeddingModel`, `embeddingDims`) in `data-access`, which owns the stored index; consumers import it and pass it as `model` / `dims`. It is a code constant, not env config: the label is persisted on every row and in the cache key, so writer and reader must never disagree, and changing it is a re-embed. `embeddingDims` must match the `vector(N)` columns. The helpers still accept any `model` / `dims`, so a same-dims model change can run the online re-embed below.
43+
- Passing the model/dimension on every write and search. They pass the shared `SEMANTIC_MATCHING_CONFIG` (`embeddingModel`, `embeddingDims`), which `data-access` defines because it owns the stored index, and do not hardcode the values. It is a code constant, not env config: the label is persisted on every row and in the cache key, so writer and reader agree as long as they run the same data-access version, and changing it is a re-embed plus upgrading both. `embeddingDims` must match the `vector(N)` columns. The helpers still accept any `model` / `dims`, so a same-dims model change can run the online re-embed below.
4444
- Translating a client failure into an HTTP response (api-service returns 503; the audit-worker write path degrades best-effort).
4545

4646
### 4. Semantic-index helpers live in `data-access`, mirroring the URL index
@@ -49,6 +49,8 @@ Following ADR-0001's test (*true for every consumer → shared; specific to one
4949

5050
**Type registries.** `OPPORTUNITY_SEMANTIC_SOURCE_TYPES` (kinds of embedded text: `topic`, later `claim`) and `OPPORTUNITY_SEMANTIC_ENTITY_TYPES` (opportunity types, from `OPPORTUNITY_TYPES`) are exported from this layer and are the only values the writer stores and the reader accepts; anything else is a `ValidationError`. The reader takes `sourceTypes` (required, non-empty) and optional `entityTypes` (omitted or empty = all), deduped, so a list can never exceed its registry. The DB does not duplicate the registries: the RPC only rejects an empty source-type list and NULL elements. A team that wants its opportunities searchable adds its source kind and/or opportunity type to the registries, indexes with them, and callers opt in by passing them.
5151

52+
Adding an entity type: (1) if the opportunity type is new, add it to `OPPORTUNITY_TYPES` and release `spacecat-shared-utils`; (2) add it to `OPPORTUNITY_SEMANTIC_ENTITY_TYPES`, bump data-access's utils pin, and release data-access; (3) bump data-access in the writer (audit-worker) and the reader (api-service). The writer rejects the type until step 3, so index only after it. A new source kind is the same minus step 1.
53+
5254
## Consequences
5355

5456
- `gpt-client` gains an embeddings surface and its own `AZURE_EMBEDDING_*` config (documented in its README); the intended cost, not a drawback.

‎packages/spacecat-shared-data-access/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ const matches = await lookupOpportunitiesByVectors(postgrestClient, {
180180
181181
`OPPORTUNITY_SEMANTIC_SOURCE_TYPES` (kinds of embedded text) and `OPPORTUNITY_SEMANTIC_ENTITY_TYPES` (opportunity types) are the registries for `source_type` and `entity_type`: the writer and reader reject any value outside them. To index a new kind or opportunity type, add it to the registry first.
182182
183-
`SEMANTIC_MATCHING_CONFIG` (`embeddingModel`, `embeddingDims`) is the embedding generation the writer and reader both use; pass it as `model` / `dims` rather than hardcoding the values. It is a code constant, not env config, so the two sides can never disagree.
183+
`SEMANTIC_MATCHING_CONFIG` (`embeddingModel`, `embeddingDims`) is the embedding generation the writer and reader both use; pass it as `model` / `dims` rather than hardcoding the values. It is a code constant, not env config, so writer and reader agree as long as both run the same data-access version; changing it means a re-embed plus upgrading both consumers.
184184
185185
The batched helpers group their calls internally (search in groups of up to 20 vectors, cache reads/touches in groups of 50 hashes), so callers pass whole lists rather than looping per item.
186186

‎packages/spacecat-shared-data-access/src/util/semantic-index.utils.js‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ function assertModel(value) {
9999
function assertAllowed(value, name, registry) {
100100
const allowed = Object.values(registry);
101101
if (!allowed.includes(value)) {
102-
throw new ValidationError(`${name} must be one of: ${allowed.join(', ')}`);
102+
throw new ValidationError(`${name} must be one of: ${allowed.join(', ')} (got ${JSON.stringify(value)})`);
103103
}
104104
}
105105

@@ -118,8 +118,15 @@ function toAllowedList(values, name, registry, { required }) {
118118
throw new ValidationError(`${name} must be an array`);
119119
}
120120
const allowed = Object.values(registry);
121-
if (!values.every((value) => allowed.includes(value))) {
122-
throw new ValidationError(`${name} must only contain: ${allowed.join(', ')}`);
121+
122+
const rejected = [];
123+
for (const value of values) {
124+
if (!allowed.includes(value)) {
125+
rejected.push(value);
126+
}
127+
}
128+
if (rejected.length > 0) {
129+
throw new ValidationError(`${name} must only contain: ${allowed.join(', ')} (got ${JSON.stringify(rejected)})`);
123130
}
124131
return [...new Set(values)];
125132
}
@@ -339,7 +346,6 @@ export async function syncOpportunitySemantic(postgrestClient, {
339346
assertClient(postgrestClient);
340347
assertId(siteId, 'siteId');
341348
assertId(entityId, 'entityId');
342-
assertAllowed(entityType, 'entityType', OPPORTUNITY_SEMANTIC_ENTITY_TYPES);
343349
assertAllowed(sourceType, 'sourceType', OPPORTUNITY_SEMANTIC_SOURCE_TYPES);
344350

345351
const table = 'opportunity_semantic_embedding';
@@ -353,6 +359,8 @@ export async function syncOpportunitySemantic(postgrestClient, {
353359
return 0;
354360
}
355361

362+
// Only stored rows carry entityType; clearing must still work for a type since removed.
363+
assertAllowed(entityType, 'entityType', OPPORTUNITY_SEMANTIC_ENTITY_TYPES);
356364
await upsertRows(postgrestClient, table, rows, 'entity_id,source_type,source_hash', entityId);
357365

358366
const keep = new Set(rows.map((r) => r.source_hash));
@@ -456,15 +464,19 @@ export async function lookupOpportunitiesByVectors(postgrestClient, {
456464
p_min_score: minScore,
457465
});
458466
if (error) {
459-
throw new DataAccessError(`Failed semantic search for site ${siteId}`, { siteId, sourceTypes: sourceTypeList }, error);
467+
throw new DataAccessError(
468+
`Failed semantic search for site ${siteId}`,
469+
{ siteId, sourceTypes: sourceTypeList, entityTypes: entityTypeList },
470+
error,
471+
);
460472
}
461473
for (const row of data ?? []) {
462474
// Checked against this group, not all results, so a bad index can't land in another group.
463475
const { query_index: queryIndex } = row;
464476
if (!Number.isInteger(queryIndex) || queryIndex < 0 || queryIndex >= group.length) {
465477
throw new DataAccessError(
466478
`Unexpected query_index ${queryIndex} from ${SEMANTIC_SEARCH_RPC}`,
467-
{ siteId, sourceTypes: sourceTypeList },
479+
{ siteId, sourceTypes: sourceTypeList, entityTypes: entityTypeList },
468480
);
469481
}
470482
results[offset + queryIndex].push({

‎packages/spacecat-shared-data-access/test/unit/util/semantic-index.utils.test.js‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -219,21 +219,31 @@ describe('semantic-index.utils', () => {
219219
siteId: SITE_ID, entityType: ENTITY_TYPE, sourceType: SOURCE_TYPE,
220220
})).to.be.rejectedWith(ValidationError, 'entityId is required');
221221
await expect(syncOpportunitySemantic(client, {
222-
siteId: SITE_ID, entityId: ENTITY_ID, sourceType: SOURCE_TYPE,
223-
})).to.be.rejectedWith(ValidationError, 'entityType must be one of: cited-analysis, reddit-analysis, youtube-analysis');
222+
siteId: SITE_ID, entityId: ENTITY_ID, sourceType: SOURCE_TYPE, sources: [src('x')],
223+
})).to.be.rejectedWith(ValidationError, 'entityType must be one of: cited-analysis, reddit-analysis, youtube-analysis (got undefined)');
224224
await expect(syncOpportunitySemantic(client, {
225-
siteId: SITE_ID, entityId: ENTITY_ID, entityType: 'broken-backlinks', sourceType: SOURCE_TYPE,
226-
})).to.be.rejectedWith(ValidationError, 'entityType must be one of');
225+
siteId: SITE_ID, entityId: ENTITY_ID, entityType: 'wikipedia-analysis', sourceType: SOURCE_TYPE, sources: [src('x')],
226+
})).to.be.rejectedWith(ValidationError, '(got "wikipedia-analysis")');
227227
await expect(syncOpportunitySemantic(client, {
228228
siteId: SITE_ID, entityId: ENTITY_ID, entityType: ENTITY_TYPE,
229-
})).to.be.rejectedWith(ValidationError, 'sourceType must be one of: topic');
229+
})).to.be.rejectedWith(ValidationError, 'sourceType must be one of: topic (got undefined)');
230230
await expect(syncOpportunitySemantic(client, {
231231
siteId: SITE_ID, entityId: ENTITY_ID, entityType: ENTITY_TYPE, sourceType: 'claim',
232-
})).to.be.rejectedWith(ValidationError, 'sourceType must be one of');
232+
})).to.be.rejectedWith(ValidationError, '(got "claim")');
233233
expect(client.calls.upsert).to.have.length(0);
234234
expect(client.calls.delete).to.have.length(0);
235235
});
236236

237+
it('clears an entity whose entityType is no longer registered', async () => {
238+
const client = makeClient();
239+
const count = await syncOpportunitySemantic(client, {
240+
siteId: SITE_ID, entityId: ENTITY_ID, entityType: 'wikipedia-analysis', sourceType: SOURCE_TYPE, sources: [],
241+
});
242+
expect(count).to.equal(0);
243+
expect(client.calls.delete).to.have.length(1);
244+
expect(client.calls.upsert).to.have.length(0);
245+
});
246+
237247
it('clears the (entity, sourceType) slice when sources is empty', async () => {
238248
const client = makeClient();
239249
const count = await syncOpportunitySemantic(client, {
@@ -527,7 +537,11 @@ describe('semantic-index.utils', () => {
527537
await expect(call({ sourceTypes: ['topic', 'claim'] })).to.be.rejectedWith(ValidationError, 'sourceTypes must only contain: topic');
528538
await expect(call({ sourceTypes: ['*'] })).to.be.rejectedWith(ValidationError, 'sourceTypes must only contain');
529539
await expect(call({ entityTypes: 'cited-analysis' })).to.be.rejectedWith(ValidationError, 'entityTypes must be an array');
530-
await expect(call({ entityTypes: ['cited-analysis', null] })).to.be.rejectedWith(ValidationError, 'entityTypes must only contain: cited-analysis, reddit-analysis, youtube-analysis');
540+
await expect(call({ entityTypes: ['cited-analysis', null] })).to.be.rejectedWith(ValidationError, 'entityTypes must only contain: cited-analysis, reddit-analysis, youtube-analysis (got [null])');
541+
await expect(call({ entityTypes: ['wikipedia-analysis'] })).to.be.rejectedWith(ValidationError, '(got ["wikipedia-analysis"])');
542+
// eslint-disable-next-line no-sparse-arrays
543+
await expect(call({ sourceTypes: [, 'topic'] })).to.be.rejectedWith(ValidationError, 'sourceTypes must only contain: topic (got [null])');
544+
await expect(call({ entityTypes: new Array(1) })).to.be.rejectedWith(ValidationError, 'entityTypes must only contain');
531545
await expect(call({ model: undefined })).to.be.rejectedWith(ValidationError, 'model is required');
532546
await expect(call({ dims: undefined })).to.be.rejectedWith(ValidationError, 'dims must be a positive integer');
533547
await expect(call({ vectors: 'x' })).to.be.rejectedWith(ValidationError, 'vectors must be an array');
@@ -597,9 +611,13 @@ describe('semantic-index.utils', () => {
597611
await lookupOpportunitiesByVectors(client, {
598612
...base, entityTypes: [], vectors: [[0.1, 0.2]],
599613
});
614+
await lookupOpportunitiesByVectors(client, {
615+
...base, entityTypes: null, vectors: [[0.1, 0.2]],
616+
});
600617
expect(client.calls.rpc[0].params.p_source_types).to.deep.equal([SOURCE_TYPE]);
601618
expect(client.calls.rpc[0].params.p_entity_types).to.deep.equal(['reddit-analysis', 'cited-analysis']);
602619
expect(client.calls.rpc[1].params.p_entity_types).to.equal(null);
620+
expect(client.calls.rpc[2].params.p_entity_types).to.equal(null);
603621
});
604622

605623
it('exposes frozen source/entity type registries', () => {
@@ -655,10 +673,16 @@ describe('semantic-index.utils', () => {
655673
expect(client.calls.rpc[0].params).to.include({ p_limit: 5, p_min_score: 0.3 });
656674
});
657675

658-
it('wraps an RPC error', async () => {
676+
it('wraps an RPC error with the searched type lists', async () => {
659677
const client = makeClient({ rpcResult: { data: null, error: { message: 'boom' } } });
660-
await expect(lookupOpportunitiesByVectors(client, { ...base, vectors: [[0.1, 0.2]] }))
661-
.to.be.rejectedWith(DataAccessError, 'Failed semantic search');
678+
const err = await lookupOpportunitiesByVectors(client, {
679+
...base, entityTypes: ['reddit-analysis'], vectors: [[0.1, 0.2]],
680+
}).catch((e) => e);
681+
expect(err).to.be.instanceOf(DataAccessError);
682+
expect(err.message).to.include('Failed semantic search');
683+
expect(err.details).to.deep.equal({
684+
siteId: SITE_ID, sourceTypes: [SOURCE_TYPE], entityTypes: ['reddit-analysis'],
685+
});
662686
});
663687

664688
it('rejects a query_index outside the current group (never spills into another group)', async () => {

0 commit comments

Comments
 (0)