Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/controllers/serenity.js
Original file line number Diff line number Diff line change
Expand Up @@ -536,10 +536,13 @@ function SerenityController(context, log, env) {
// length: `deferPublish` is the exact flag CSV-chunking already sets
// (see handleCreatePrompts' docstring) precisely because a UI multi-add
// never sets it, so a three-prompt UI add never pays queue+worker+publish
// latency. Flat-mode brands only for now — see this PR's report for why
// subworkspace-mode CSV import stays on the synchronous path.
// latency. Covers both flat and subworkspace-mode brands — the two modes
// only ever differed here in JIT resource-allocation guarding, and that
// machinery (createHeadroomGuard et al.) was removed org-wide once Semrush
// confirmed it no longer enforces AI project/prompt limits (SITES-49206,
// #2995), so there is nothing left to special-case.
const body = ctx.data || {};
if (auth.mode !== 'subworkspace' && validateDeferPublish(body)) {
if (validateDeferPublish(body)) {
const prompts = Array.isArray(body.prompts) ? body.prompts : [];
if (prompts.length === 0) {
return createResponse(
Expand All @@ -559,6 +562,12 @@ function SerenityController(context, log, env) {
mode: 'create',
brandId: auth.brandUuid,
semrushWorkspaceId: auth.workspaceId,
// subworkspace mode resolves slice→project via a live listing
// (buildSliceProjectMap) rather than the BrandSemrushProject DB
// mapping flat mode uses — the worker needs to know which lookup to
// use. Kept distinct from `mode` above, which means create-vs-reclassify.
subworkspace: auth.mode === 'subworkspace',
parentWorkspaceId: auth.parentWorkspaceId ?? '',
prompts,
// Authorship (LLMO-6289): capture the caller id at enqueue time — from
// the auth profile, never the forwarded upstream bearer — so the async
Expand Down
28 changes: 20 additions & 8 deletions src/support/serenity/handlers/classify-prompts-job.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from './prompts.js';
import { ORIGIN_VALUE } from '../prompt-tags.js';
import { resolveIntentValueInjection } from '../tag-tree.js';
import { buildSliceProjectMap, sliceKey } from '../subworkspace-projects.js';

/** @typedef {import('../rest-transport.js').SerenityTransport} SerenityTransport */

Expand Down Expand Up @@ -139,7 +140,7 @@ async function requeuePending(context, job, semrushWorkspaceId, items) {
* @param {SerenityTransport} transport - Serenity transport built from the exchanged
* access token.
* @param {object} metadata - the job's metadata (`brandId`, `semrushWorkspaceId`,
* `prompts`).
* `subworkspace`, `prompts`).
* @returns {Promise<object>} the job result.
*/
async function createAndClassify(context, job, transport, metadata) {
Expand All @@ -149,13 +150,25 @@ async function createAndClassify(context, job, transport, metadata) {
// Authorship (LLMO-6289): the caller id captured at enqueue time in the create
// controller, carried through the async job so classified-on-create prompts are
// stamped with the human/service that submitted them, not the job runner.
const { brandId, semrushWorkspaceId, callerId = 'unknown' } = metadata;
const {
brandId, semrushWorkspaceId, subworkspace = false, callerId = 'unknown',
} = metadata;
const inputs = Array.isArray(metadata.prompts) ? metadata.prompts : [];

const projects = await dataAccess.BrandSemrushProject.allByBrandId(brandId);
// Twin of the sync handlers' slice→project resolution (prompts.js vs
// prompts-subworkspace.js): subworkspace mode has no BrandSemrushProject DB
// mapping to read, so it resolves every slice from one live upstream listing.
const projectsBySlice = new Map();
for (const p of projects || []) {
projectsBySlice.set(`${p.getGeoTargetId()}:${p.getLanguageCode()}`, p);
if (subworkspace) {
for (const [key, p] of await buildSliceProjectMap(transport, semrushWorkspaceId, log)) {
projectsBySlice.set(key, p.id);
}
} else {
const projects = await dataAccess.BrandSemrushProject.allByBrandId(brandId);
for (const p of projects || []) {
const key = sliceKey(p.getGeoTargetId(), p.getLanguageCode());
projectsBySlice.set(key, p.getSemrushProjectId());
}
}

const classifyPromptType = await buildPromptTypeClassifier(dataAccess, brandId);
Expand All @@ -180,16 +193,15 @@ async function createAndClassify(context, job, transport, metadata) {
if (!input) {
return { skipped: { text: String(raw?.text || ''), reason: /** @type {string} */ (reason) } };
}
const project = projectsBySlice.get(`${input.geoTargetId}:${input.languageCode}`);
if (!project) {
const projectId = projectsBySlice.get(sliceKey(input.geoTargetId, input.languageCode));
if (!projectId) {
return {
skipped: {
text: input.text,
reason: `No market for slice (${input.geoTargetId}, ${input.languageCode})`,
},
};
}
const projectId = project.getSemrushProjectId();
try {
let typed = await injectComputedTags(projectId, input);
typed = await injectComputedIntent(projectId, typed);
Expand Down
33 changes: 26 additions & 7 deletions test/controllers/serenity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2794,7 +2794,13 @@ describe('SerenityController', () => {
expect(enqueueArgs.metadata).to.deep.equal({
// callerId captured at enqueue time (LLMO-6289) — no auth profile on the
// test context, so it resolves to the `unknown` sentinel.
mode: 'create', brandId: BRAND, semrushWorkspaceId: WORKSPACE, prompts, callerId: 'unknown',
mode: 'create',
brandId: BRAND,
semrushWorkspaceId: WORKSPACE,
subworkspace: false,
parentWorkspaceId: WORKSPACE,
prompts,
callerId: 'unknown',
});
// The synchronous path never runs.
expect(handlers.handleCreatePrompts).to.not.have.been.called;
Expand All @@ -2812,19 +2818,32 @@ describe('SerenityController', () => {
expect(handlers.handleCreatePrompts).to.have.been.calledOnce;
});

it('stays synchronous for subworkspace-mode brands even when deferPublish is true', async () => {
it('enqueues a serenity-classify-prompts job for subworkspace-mode brands too', async () => {
resolveBrandWorkspaceStub.resolves({
mode: 'subworkspace', workspaceId: SUBWS, parentWorkspaceId: WORKSPACE,
});
handlers.handleCreatePromptsSubworkspace.resolves({ created: 1, failed: [] });
const prompts = [{ text: 'x', geoTargetId: 2840, languageCode: 'en' }];
const controller = SerenityController({ env: {} }, fakeLog(), {});
const response = await controller.createPrompts(fakeContext({
data: { deferPublish: true, prompts: [{ text: 'x', geoTargetId: 2840, languageCode: 'en' }] },
data: { deferPublish: true, prompts },
}));

expect(response.status).to.equal(200);
expect(createAndEnqueueJobStub).to.not.have.been.called;
expect(handlers.handleCreatePromptsSubworkspace).to.have.been.calledOnce;
expect(response.status).to.equal(202);
const body = await readBody(response);
expect(body).to.deep.equal({ jobId: 'job-abc', status: 'IN_PROGRESS' });
expect(createAndEnqueueJobStub).to.have.been.calledOnce;
const [, enqueueArgs] = createAndEnqueueJobStub.firstCall.args;
expect(enqueueArgs.metadata).to.deep.equal({
mode: 'create',
brandId: BRAND,
semrushWorkspaceId: SUBWS,
subworkspace: true,
parentWorkspaceId: WORKSPACE,
prompts,
callerId: 'unknown',
});
// The synchronous subworkspace path never runs.
expect(handlers.handleCreatePromptsSubworkspace).to.not.have.been.called;
});

it('400s without enqueueing when deferPublish is true but prompts is empty', async () => {
Expand Down
34 changes: 34 additions & 0 deletions test/support/serenity/handlers/classify-prompts-job.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe('handlers/classify-prompts-job.js (serenity-docs#33)', () => {
createPromptsWithMetadata: sinon.stub().resolves({ items: [{ id: 'created-prompt' }] }),
publishProject: sinon.stub().resolves(),
updatePromptTagsByIds: sinon.stub().resolves(),
listProjects: sinon.stub().resolves({ items: [] }),
};
});

Expand Down Expand Up @@ -175,6 +176,39 @@ describe('handlers/classify-prompts-job.js (serenity-docs#33)', () => {
expect(createAndEnqueueJobStub).to.not.have.been.called;
});

it('subworkspace mode: resolves the slice from a live project listing, not the DB mapping', async () => {
const intentByTextMap = new Map([['great product', 'Task']]);
const createAndEnqueueJobStub = sinon.stub();
transport.listProjects = sinon.stub().resolves({
items: [{
id: 'proj-live-1',
settings: { ai: { location: { id: 2840 }, language: { name: 'en' } } },
}],
});
const { classifyPromptsHandler } = await load({
intentByTextMap, createAndEnqueueJobStub, transport,
});

const dataAccess = dataAccessFor([]);
const context = { env: {}, log: fakeLog(), dataAccess };
const job = makeJob({
brandId: 'brand-1',
semrushWorkspaceId: WORKSPACE,
subworkspace: true,
prompts: [{
text: 'great product', geoTargetId: 2840, languageCode: 'en', tagIds: [TAG_IDS.categoryRunningShoes],
}],
});

const result = await classifyPromptsHandler(context, job, 'token');

expect(result.created).to.have.lengthOf(1);
expect(result.created[0].tagIds).to.include(TAG_IDS.intentTask);
// The DB mapping is never consulted in subworkspace mode.
expect(dataAccess.BrandSemrushProject.allByBrandId).to.not.have.been.called;
expect(transport.listProjects).to.have.been.calledOnceWith(WORKSPACE);
});

it('skips a prompt whose slice has no matching project', async () => {
const intentByTextMap = new Map([['x', 'Task']]);
const createAndEnqueueJobStub = sinon.stub();
Expand Down
Loading