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

Commit 8ac9fa8

Browse files
author
Tanase Butcaru
committed
feat(data-access): filter lookupOpportunitiesByVectors by opportunity status (LLMO-7445)
1 parent fc75b50 commit 8ac9fa8

3 files changed

Lines changed: 74 additions & 4 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,11 @@ await upsertQueryEmbeddings(postgrestClient, { entries: [{ text, vector }], ...s
170170
await touchQueryEmbeddings(postgrestClient, { textHashes, ...scope });
171171

172172
// reader, batched: one best-first list of { entityId, entityType, score } per query vector.
173-
// entityTypes is optional (omitted or [] searches all entity types).
173+
// 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.
174175
const matches = await lookupOpportunitiesByVectors(postgrestClient, {
175-
siteId, sourceTypes: [TOPIC], entityTypes: [CITED_ANALYSIS], vectors, k: 10, minScore: 0.5, ...scope,
176+
siteId, sourceTypes: [TOPIC], entityTypes: [CITED_ANALYSIS], statuses: ['NEW', 'IN_PROGRESS'],
177+
vectors, k: 10, minScore: 0.5, ...scope,
176178
});
177179
```
178180

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,31 @@ function toAllowedList(values, name, registry, { required }) {
163163
return [...new Set(values)];
164164
}
165165

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+
166191
/** Only an empty/omitted `sources` clears; a non-empty input reduced to nothing throws. */
167192
function assertClearable(sources, entityId, sourceType) {
168193
const explicitClear = sources === undefined || sources === null
@@ -453,6 +478,8 @@ export async function copyEntityVectors(postgrestClient, {
453478
* `OPPORTUNITY_SEMANTIC_SOURCE_TYPES` (required, non-empty)
454479
* @param {string[]} [params.entityTypes] - opportunity types to narrow to, from
455480
* `OPPORTUNITY_SEMANTIC_ENTITY_TYPES`; omitted or empty searches all
481+
* @param {string[]} [params.statuses] - opportunity statuses (`Opportunity.STATUSES` values) to
482+
* narrow to, applied before `k`; omitted or empty searches all
456483
* @param {number[][]} params.vectors - the query embeddings
457484
* @param {string} params.model - the model the queries were embedded with
458485
* @param {number} params.dims - the query embedding dimension
@@ -462,12 +489,13 @@ export async function copyEntityVectors(postgrestClient, {
462489
* best-first list per input vector, in input order
463490
*/
464491
export async function lookupOpportunitiesByVectors(postgrestClient, {
465-
siteId, sourceTypes, entityTypes, vectors, model, dims, k = 10, minScore = 0,
492+
siteId, sourceTypes, entityTypes, statuses, vectors, model, dims, k = 10, minScore = 0,
466493
} = {}) {
467494
assertClient(postgrestClient);
468495
assertId(siteId, 'siteId');
469496
const sourceTypeList = toAllowedList(sourceTypes, 'sourceTypes', OPPORTUNITY_SEMANTIC_SOURCE_TYPES, { required: true });
470497
const entityTypeList = toAllowedList(entityTypes, 'entityTypes', OPPORTUNITY_SEMANTIC_ENTITY_TYPES, { required: false });
498+
const statusList = toStatusList(statuses);
471499
assertModel(model);
472500
assertDims(dims);
473501
if (!Array.isArray(vectors)) {
@@ -495,12 +523,19 @@ export async function lookupOpportunitiesByVectors(postgrestClient, {
495523
p_dims: dims,
496524
p_limit: k,
497525
p_min_score: minScore,
526+
// Only when set: a data-service without p_statuses rejects the unknown argument.
527+
...(statusList ? { p_statuses: statusList } : {}),
498528
});
499529
if (error) {
500530
throw rpcError(
501531
`Failed semantic search for site ${siteId}`,
502532
SEMANTIC_SEARCH_RPC,
503-
{ siteId, sourceTypes: sourceTypeList, entityTypes: entityTypeList },
533+
{
534+
siteId,
535+
sourceTypes: sourceTypeList,
536+
entityTypes: entityTypeList,
537+
...(statusList ? { statuses: statusList } : {}),
538+
},
504539
error,
505540
);
506541
}

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,33 @@ describe('semantic-index.utils', () => {
671671
expect(client.calls.rpc[2].params.p_entity_types).to.equal(null);
672672
});
673673

674+
it('sends deduped statuses as p_statuses, and omits p_statuses when none are given', async () => {
675+
const client = makeClient({ rpcResult: { data: [], error: null } });
676+
await lookupOpportunitiesByVectors(client, {
677+
...base, statuses: ['NEW', 'RESOLVED', 'NEW'], vectors: [[0.1, 0.2]],
678+
});
679+
await lookupOpportunitiesByVectors(client, { ...base, statuses: [], vectors: [[0.1, 0.2]] });
680+
await lookupOpportunitiesByVectors(client, {
681+
...base, statuses: null, vectors: [[0.1, 0.2]],
682+
});
683+
expect(client.calls.rpc[0].params.p_statuses).to.deep.equal(['NEW', 'RESOLVED']);
684+
expect(client.calls.rpc[1].params).to.not.have.property('p_statuses');
685+
expect(client.calls.rpc[2].params).to.not.have.property('p_statuses');
686+
});
687+
688+
it('rejects statuses that are not a list of non-empty strings', async () => {
689+
const call = (statuses) => lookupOpportunitiesByVectors(makeClient(), {
690+
...base, statuses, vectors: [[0.1, 0.2]],
691+
});
692+
await expect(call('NEW')).to.be.rejectedWith(ValidationError, 'statuses must be an array');
693+
await expect(call(['NEW', ' '])).to.be.rejectedWith(
694+
ValidationError,
695+
'statuses must only contain non-empty strings (got [" "])',
696+
);
697+
await expect(call(['NEW', 1])).to.be.rejectedWith(ValidationError, '(got [1])');
698+
await expect(call(new Array(1))).to.be.rejectedWith(ValidationError, 'statuses must only contain');
699+
});
700+
674701
it('renders rejected values safely: unserializable falls back to String, long ones are truncated', async () => {
675702
const call = (over) => lookupOpportunitiesByVectors(makeClient(), {
676703
...base, vectors: [[0.1, 0.2]], ...over,
@@ -743,6 +770,12 @@ describe('semantic-index.utils', () => {
743770
expect(err.details).to.deep.equal({
744771
siteId: SITE_ID, sourceTypes: [SOURCE_TYPE], entityTypes: ['reddit-analysis'],
745772
});
773+
const withStatuses = await lookupOpportunitiesByVectors(client, {
774+
...base, statuses: ['NEW'], vectors: [[0.1, 0.2]],
775+
}).catch((e) => e);
776+
expect(withStatuses.details).to.deep.equal({
777+
siteId: SITE_ID, sourceTypes: [SOURCE_TYPE], entityTypes: null, statuses: ['NEW'],
778+
});
746779
});
747780

748781
it('adds a deploy-ordering hint when PostgREST cannot find the RPC signature', async () => {

0 commit comments

Comments
 (0)