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

Commit 422eee1

Browse files
author
Tanase Butcaru
committed
feat(data-access): validate semantic search statuses against Opportunity.STATUSES (LLMO-7445)
1 parent 8ac9fa8 commit 422eee1

6 files changed

Lines changed: 55 additions & 68 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 this library's writer stores and reader accepts only their values; 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 empty, multi-dimensional, or NULL-element lists and lists over 100 elements (a length bound, not a registry mirror; `NULL` entity types = all; the reader sends `NULL` when `entityTypes` is omitted or empty). 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+
**Status filter.** The reader also takes optional `statuses`, checked against `Opportunity.STATUSES` and deduped like the type lists. The RPC applies it before the per-query limit, so filtered-out opportunities never take result slots. Which statuses count as visible is the caller's choice. The data-service casts the list to its status enum, so a value it doesn't know is an error, not an empty result. Sending `statuses` needs a data-service with `p_statuses` (mysticat-data-service PR 1132).
53+
5254
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.
5355

5456
## Consequences

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ await touchQueryEmbeddings(postgrestClient, { textHashes, ...scope });
171171

172172
// reader, batched: one best-first list of { entityId, entityType, score } per query vector.
173173
// entityTypes is optional (omitted or [] searches all entity types). statuses is optional too
174-
// (omitted or [] searches all); it is applied before k, so hidden opportunities can't take the slots.
174+
// (omitted or [] searches all; values from Opportunity.STATUSES); it is applied before k, so
175+
// filtered-out opportunities never take result slots. Needs a data-service with p_statuses.
175176
const matches = await lookupOpportunitiesByVectors(postgrestClient, {
176177
siteId, sourceTypes: [TOPIC], entityTypes: [CITED_ANALYSIS], statuses: ['NEW', 'IN_PROGRESS'],
177178
vectors, k: 10, minScore: 0.5, ...scope,

‎packages/spacecat-shared-data-access/src/service/index.js‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ const fetch = createFetchCompat(postgrestFetch);
5050
export * from '../errors/index.js';
5151
export * from '../models/index.js';
5252
export * from '../util/index.js';
53+
// Not in util/index.js: semantic-index imports the Opportunity model, which imports util/index.js.
54+
export {
55+
SEMANTIC_INDEX_TABLES,
56+
QUERY_EMBEDDING_TABLE,
57+
SEMANTIC_SEARCH_RPC,
58+
COPY_VECTORS_RPC,
59+
SEMANTIC_CHUNK_SIZE,
60+
QUERY_HASH_CHUNK_SIZE,
61+
MAX_SOURCE_TEXT_LENGTH,
62+
OPPORTUNITY_SEMANTIC_SOURCE_TYPES,
63+
OPPORTUNITY_SEMANTIC_ENTITY_TYPES,
64+
SEMANTIC_MATCHING_CONFIG,
65+
normalizeText,
66+
hashText,
67+
cleanTopicText,
68+
serializeVector,
69+
parseVector,
70+
syncOpportunitySemantic,
71+
copyEntityVectors,
72+
lookupOpportunitiesByVectors,
73+
getQueryEmbeddings,
74+
upsertQueryEmbeddings,
75+
touchQueryEmbeddings,
76+
} from '../util/semantic-index.utils.js';
5377

5478
const createPostgrestService = (config, client = undefined) => {
5579
if (client) {

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

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,30 +36,6 @@ export {
3636
lookupEntityIdsByUrl,
3737
} from './url-index.utils.js';
3838

39-
export {
40-
SEMANTIC_INDEX_TABLES,
41-
QUERY_EMBEDDING_TABLE,
42-
SEMANTIC_SEARCH_RPC,
43-
COPY_VECTORS_RPC,
44-
SEMANTIC_CHUNK_SIZE,
45-
QUERY_HASH_CHUNK_SIZE,
46-
MAX_SOURCE_TEXT_LENGTH,
47-
OPPORTUNITY_SEMANTIC_SOURCE_TYPES,
48-
OPPORTUNITY_SEMANTIC_ENTITY_TYPES,
49-
SEMANTIC_MATCHING_CONFIG,
50-
normalizeText,
51-
hashText,
52-
cleanTopicText,
53-
serializeVector,
54-
parseVector,
55-
syncOpportunitySemantic,
56-
copyEntityVectors,
57-
lookupOpportunitiesByVectors,
58-
getQueryEmbeddings,
59-
upsertQueryEmbeddings,
60-
touchQueryEmbeddings,
61-
} from './semantic-index.utils.js';
62-
6339
/**
6440
* Datastore types that collections can use to declare their storage backend.
6541
* @readonly

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

Lines changed: 11 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createHash } from 'crypto';
1515
import { OPPORTUNITY_TYPES } from '@adobe/spacecat-shared-utils';
1616

1717
import { DataAccessError, ValidationError } from '../errors/index.js';
18+
import Opportunity from '../models/opportunity/opportunity.model.js';
1819
import { DEFAULT_PAGE_SIZE, rpcError } from './postgrest.utils.js';
1920

2021
/**
@@ -163,31 +164,6 @@ function toAllowedList(values, name, registry, { required }) {
163164
return [...new Set(values)];
164165
}
165166

166-
/**
167-
* Omitted or empty means no status filter. Not checked against `Opportunity.STATUSES` (importing
168-
* the model here is a dependency cycle); the data-service matches the enum, so a typo matches
169-
* nothing.
170-
*/
171-
function toStatusList(statuses) {
172-
if (statuses === undefined || statuses === null
173-
|| (Array.isArray(statuses) && statuses.length === 0)) {
174-
return null;
175-
}
176-
if (!Array.isArray(statuses)) {
177-
throw new ValidationError('statuses must be an array');
178-
}
179-
const rejected = [];
180-
for (const status of statuses) {
181-
if (typeof status !== 'string' || status.trim() === '') {
182-
rejected.push(status);
183-
}
184-
}
185-
if (rejected.length > 0) {
186-
throw new ValidationError(`statuses must only contain non-empty strings (got ${describeValue(rejected)})`);
187-
}
188-
return [...new Set(statuses)];
189-
}
190-
191167
/** Only an empty/omitted `sources` clears; a non-empty input reduced to nothing throws. */
192168
function assertClearable(sources, entityId, sourceType) {
193169
const explicitClear = sources === undefined || sources === null
@@ -495,7 +471,7 @@ export async function lookupOpportunitiesByVectors(postgrestClient, {
495471
assertId(siteId, 'siteId');
496472
const sourceTypeList = toAllowedList(sourceTypes, 'sourceTypes', OPPORTUNITY_SEMANTIC_SOURCE_TYPES, { required: true });
497473
const entityTypeList = toAllowedList(entityTypes, 'entityTypes', OPPORTUNITY_SEMANTIC_ENTITY_TYPES, { required: false });
498-
const statusList = toStatusList(statuses);
474+
const statusList = toAllowedList(statuses, 'statuses', Opportunity.STATUSES, { required: false });
499475
assertModel(model);
500476
assertDims(dims);
501477
if (!Array.isArray(vectors)) {
@@ -509,6 +485,12 @@ export async function lookupOpportunitiesByVectors(postgrestClient, {
509485
}
510486
const serialized = vectors.map((vector) => serializeDimsVector(vector, dims));
511487

488+
const details = {
489+
siteId,
490+
sourceTypes: sourceTypeList,
491+
entityTypes: entityTypeList,
492+
...(statusList ? { statuses: statusList } : {}),
493+
};
512494
const results = vectors.map(() => []);
513495
const groupSize = Math.min(SEMANTIC_CHUNK_SIZE, Math.floor(DEFAULT_PAGE_SIZE / k));
514496
for (let offset = 0; offset < serialized.length; offset += groupSize) {
@@ -523,29 +505,19 @@ export async function lookupOpportunitiesByVectors(postgrestClient, {
523505
p_dims: dims,
524506
p_limit: k,
525507
p_min_score: minScore,
526-
// Only when set: a data-service without p_statuses rejects the unknown argument.
508+
// Rollout shim: always send p_statuses (null if unset) once data-service #1132 is out.
527509
...(statusList ? { p_statuses: statusList } : {}),
528510
});
529511
if (error) {
530-
throw rpcError(
531-
`Failed semantic search for site ${siteId}`,
532-
SEMANTIC_SEARCH_RPC,
533-
{
534-
siteId,
535-
sourceTypes: sourceTypeList,
536-
entityTypes: entityTypeList,
537-
...(statusList ? { statuses: statusList } : {}),
538-
},
539-
error,
540-
);
512+
throw rpcError(`Failed semantic search for site ${siteId}`, SEMANTIC_SEARCH_RPC, details, error);
541513
}
542514
for (const row of data ?? []) {
543515
// Checked against this group, not all results, so a bad index can't land in another group.
544516
const { query_index: queryIndex } = row;
545517
if (!Number.isInteger(queryIndex) || queryIndex < 0 || queryIndex >= group.length) {
546518
throw new DataAccessError(
547519
`Unexpected query_index ${queryIndex} from ${SEMANTIC_SEARCH_RPC}`,
548-
{ siteId, sourceTypes: sourceTypeList, entityTypes: entityTypeList },
520+
details,
549521
);
550522
}
551523
results[offset + queryIndex].push({

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -685,17 +685,19 @@ describe('semantic-index.utils', () => {
685685
expect(client.calls.rpc[2].params).to.not.have.property('p_statuses');
686686
});
687687

688-
it('rejects statuses that are not a list of non-empty strings', async () => {
689-
const call = (statuses) => lookupOpportunitiesByVectors(makeClient(), {
688+
it('rejects statuses outside Opportunity.STATUSES without calling the RPC', async () => {
689+
const client = makeClient();
690+
const call = (statuses) => lookupOpportunitiesByVectors(client, {
690691
...base, statuses, vectors: [[0.1, 0.2]],
691692
});
692693
await expect(call('NEW')).to.be.rejectedWith(ValidationError, 'statuses must be an array');
693-
await expect(call(['NEW', ' '])).to.be.rejectedWith(
694+
await expect(call(['NEW', 'new', ' NEW'])).to.be.rejectedWith(
694695
ValidationError,
695-
'statuses must only contain non-empty strings (got [" "])',
696+
'statuses must only contain: NEW, IN_PROGRESS, IGNORED, RESOLVED (got ["new"," NEW"])',
696697
);
697698
await expect(call(['NEW', 1])).to.be.rejectedWith(ValidationError, '(got [1])');
698699
await expect(call(new Array(1))).to.be.rejectedWith(ValidationError, 'statuses must only contain');
700+
expect(client.calls.rpc).to.be.empty;
699701
});
700702

701703
it('renders rejected values safely: unserializable falls back to String, long ones are truncated', async () => {
@@ -790,6 +792,11 @@ describe('semantic-index.utils', () => {
790792
expect(err.details).to.deep.equal({
791793
siteId: SITE_ID, sourceTypes: [SOURCE_TYPE], entityTypes: null,
792794
});
795+
const withStatuses = await lookupOpportunitiesByVectors(client, {
796+
...base, statuses: ['NEW'], vectors: [[0.1, 0.2]],
797+
}).catch((e) => e);
798+
expect(withStatuses.message).to.equal(`Failed semantic search for site ${SITE_ID}: ${SEMANTIC_SEARCH_RPC} ${PGRST202_HINT}`);
799+
expect(withStatuses.details.statuses).to.deep.equal(['NEW']);
793800
});
794801

795802
it('rejects a query_index outside the current group (never spills into another group)', async () => {
@@ -802,6 +809,11 @@ describe('semantic-index.utils', () => {
802809
await expect(lookupOpportunitiesByVectors(client, { ...base, vectors: vecs(25) }))
803810
.to.be.rejectedWith(DataAccessError, `Unexpected query_index ${bad}`);
804811
}
812+
const client = makeClient({ rpcResult: { data: [row(5)], error: null } });
813+
const err = await lookupOpportunitiesByVectors(client, {
814+
...base, statuses: ['NEW'], vectors: [[0.1, 0.2]],
815+
}).catch((e) => e);
816+
expect(err.details.statuses).to.deep.equal(['NEW']);
805817
});
806818
});
807819

0 commit comments

Comments
 (0)