Skip to content

Commit dd4d9a3

Browse files
dzehnderclaude
andcommitted
feat(llmo): register recurring DRS prompt-suggestion schedules at onboarding
Adds three onboarding triggers next to triggerBrandalfOnboardingJob that register recurring DRS schedules (with an immediate first run) for the prompt-suggestion pipelines, invoked from the V2 onboarding block after the Brandalf trigger: - triggerSemrushPromptJob -> prompt_generation_semrush (twice-monthly) - triggerCitationAttemptJob -> prompt_generation_agentic_traffic (twice-monthly) - triggerSyntheticPersonasJob -> prompt_generation_synthetic_personas (quarterly) Each is guarded by drsClient.isConfigured() and uses a single createSchedule({ ..., triggerImmediately: true }) call. Split error semantics: the immediate first run is best-effort (self-heals on the next scheduled run), but a createSchedule (schedule-registration) failure is logged at ERROR with site_id + provider_id + status and surfaced to ops -- never silently swallowed, since a swallowed failure means the site never gets a recurring schedule. Onboarding still succeeds when a schedule registration fails. Depends on a new additive createSchedule(...) method in @adobe/spacecat-shared-drs-client (bumped to 1.14.0); must merge/release after that shared PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5c1ab31 commit dd4d9a3

3 files changed

Lines changed: 322 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
"@adobe/spacecat-shared-cloudflare-client": "1.2.1",
9090
"@adobe/spacecat-shared-content-client": "1.8.24",
9191
"@adobe/spacecat-shared-data-access": "4.9.0",
92-
"@adobe/spacecat-shared-drs-client": "1.13.0",
92+
"@adobe/spacecat-shared-drs-client": "1.14.0",
9393
"@adobe/spacecat-shared-gpt-client": "1.6.23",
9494
"@adobe/spacecat-shared-http-utils": "1.34.0",
9595
"@adobe/spacecat-shared-ims-client": "1.12.7",

src/controllers/llmo/llmo-onboarding.js

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,115 @@ export async function triggerBrandalfOnboardingJob({
175175
return drsJob;
176176
}
177177

178+
// Cadence labels accepted by DRS `createSchedule` for the recurring
179+
// "prompt suggestion" pipelines. DRS derives the concrete cron expression
180+
// server-side from the label (frequency:'cron') — we never send raw cron, so a
181+
// misconfigured/leaked caller cannot schedule a fleet-wide Fargate storm.
182+
// 'twice-monthly' → 1st & 15th (honest label; not a true 14-day interval)
183+
// 'quarterly' → 1st of Jan/Apr/Jul/Oct
184+
// See local/drs-prompt-suggestions-schedules-onboarding-plan.md ("Cadence expression").
185+
const DRS_CADENCE_TWICE_MONTHLY = 'twice-monthly';
186+
const DRS_CADENCE_QUARTERLY = 'quarterly';
187+
188+
/**
189+
* Registers a recurring DRS schedule for one prompt-suggestion provider, with an
190+
* immediate first run. Shared body for the three onboarding triggers below.
191+
*
192+
* Split error semantics (see the V2 caller): the immediate first run is
193+
* best-effort — if it produces nothing (e.g. brand/base-prompt data not present
194+
* yet) the next scheduled run self-heals. A `createSchedule` (schedule
195+
* REGISTRATION) failure is NOT best-effort: the site would never get a recurring
196+
* schedule and nothing self-heals, so it propagates to the caller which logs it
197+
* at ERROR. This helper therefore does not swallow the failure itself.
198+
*
199+
* @param {object} params
200+
* @param {object} params.drsClient - Configured DRS client.
201+
* @param {string} params.providerId - DRS provider id to schedule.
202+
* @param {string} params.cadence - One of DRS_CADENCE_* labels.
203+
* @param {string} params.siteId - SpaceCat site UUID.
204+
* @param {string} params.orgId - SpaceCat organization UUID.
205+
* @param {string} params.imsOrgId - IMS org id (isolation key; DRS re-derives server-side).
206+
* @param {object} params.log - Logger.
207+
* @param {Function} [params.say] - Optional Slack say callback.
208+
* @returns {Promise<object|null>} The createSchedule result, or null when DRS is not configured.
209+
*/
210+
async function registerPromptSuggestionSchedule({
211+
drsClient, providerId, cadence, siteId, orgId, imsOrgId, log, say = () => {},
212+
}) {
213+
if (!drsClient.isConfigured()) {
214+
log.debug(`DRS client not configured, skipping ${providerId} schedule for site ${siteId}`);
215+
return null;
216+
}
217+
218+
// Default enable_brand_presence off (plan Phase 0.4): these prompt-suggestion
219+
// pipelines must not push unexpected load into the brand-presence pipeline / SNS
220+
// allowlist unless a site is explicitly BP-enabled.
221+
const result = await drsClient.createSchedule({
222+
siteId,
223+
providerId,
224+
cadence,
225+
orgId,
226+
imsOrgId,
227+
enableBrandPresence: false,
228+
triggerImmediately: true,
229+
});
230+
231+
log.info(`Registered DRS ${providerId} schedule ${result?.scheduleId ?? 'unknown'} `
232+
+ `(cadence=${cadence}) for site ${siteId}${result?.alreadyExisted ? ' (already existed)' : ''}`);
233+
say(`:calendar_spiral: Registered DRS ${providerId} schedule for site ${siteId}`);
234+
return result;
235+
}
236+
237+
/**
238+
* Registers the recurring SEMrush prompt-generation schedule (twice-monthly) for
239+
* a newly onboarded site, triggering an immediate first run.
240+
* @param {object} params - See {@link registerPromptSuggestionSchedule}.
241+
* @returns {Promise<object|null>}
242+
*/
243+
export async function triggerSemrushPromptJob(params) {
244+
return registerPromptSuggestionSchedule({
245+
...params,
246+
providerId: 'prompt_generation_semrush',
247+
cadence: DRS_CADENCE_TWICE_MONTHLY,
248+
});
249+
}
250+
251+
/**
252+
* Registers the recurring "citation attempts" schedule (twice-monthly) for a
253+
* newly onboarded site, triggering an immediate first run.
254+
*
255+
* NOTE: "Citation Attempt" is the renamed *output* of the agentic-traffic
256+
* pipeline — there is no standalone citation-attempt provider — so the provider
257+
* id below (`prompt_generation_agentic_traffic`) will NOT grep from the word
258+
* "citation". DRS's per-provider Fargate whitelist (`AT_FARGATE_WHITELIST`) is
259+
* the real enablement gate; leave agentic-traffic un-whitelisted in an env until
260+
* its Postgres-format migration is confirmed healthy (plan Phase 0.2) so the
261+
* best-effort first run cannot automate a known-failing pipeline.
262+
* @param {object} params - See {@link registerPromptSuggestionSchedule}.
263+
* @returns {Promise<object|null>}
264+
*/
265+
export async function triggerCitationAttemptJob(params) {
266+
return registerPromptSuggestionSchedule({
267+
...params,
268+
providerId: 'prompt_generation_agentic_traffic',
269+
cadence: DRS_CADENCE_TWICE_MONTHLY,
270+
});
271+
}
272+
273+
/**
274+
* Registers the recurring synthetic-personas schedule (quarterly) for a newly
275+
* onboarded site, triggering an immediate first run.
276+
* @param {object} params - See {@link registerPromptSuggestionSchedule}.
277+
* @returns {Promise<object|null>}
278+
*/
279+
export async function triggerSyntheticPersonasJob(params) {
280+
return registerPromptSuggestionSchedule({
281+
...params,
282+
providerId: 'prompt_generation_synthetic_personas',
283+
cadence: DRS_CADENCE_QUARTERLY,
284+
});
285+
}
286+
178287
// submitOnboardingPromptGenerationJob removed — prompt generation is now
179288
// triggered by DRS after Brandalf completes (LLMO-4258, option b).
180289

@@ -1422,6 +1531,45 @@ export async function activateBrandAndGeneratePrompts({
14221531
log.error(`Failed to start DRS Brandalf flow: ${drsError.message}`);
14231532
say(':warning: Failed to start DRS Brandalf flow (will need manual trigger)');
14241533
}
1534+
1535+
// Register the recurring "prompt suggestion" schedules (SEMrush, citation
1536+
// attempts, synthetic personas), each with an immediate first run. Done AFTER
1537+
// the Brandalf trigger above (which chains base prompt generation on
1538+
// completion, LLMO-4258) — not fired in parallel before brand data exists, so
1539+
// the first run has base prompts to work from (plan Phase 0.3). The immediate
1540+
// run is best-effort; the durable outcome is the recurring schedule row.
1541+
const promptSuggestionSchedules = [
1542+
{ name: 'SEMrush prompts', providerId: 'prompt_generation_semrush', trigger: triggerSemrushPromptJob },
1543+
{ name: 'citation attempts', providerId: 'prompt_generation_agentic_traffic', trigger: triggerCitationAttemptJob },
1544+
{ name: 'synthetic personas', providerId: 'prompt_generation_synthetic_personas', trigger: triggerSyntheticPersonasJob },
1545+
];
1546+
const scheduleDrsClient = DrsClient.createFrom(context);
1547+
await Promise.allSettled(
1548+
promptSuggestionSchedules.map(async ({ name, providerId, trigger }) => {
1549+
try {
1550+
await trigger({
1551+
drsClient: scheduleDrsClient,
1552+
siteId: site.getId(),
1553+
orgId: organization.getId(),
1554+
imsOrgId,
1555+
log,
1556+
say,
1557+
});
1558+
} catch (scheduleError) {
1559+
// Schedule-REGISTRATION failure (distinct from the best-effort immediate
1560+
// run): the site would never get a recurring schedule and nothing
1561+
// self-heals, so surface it loudly with full context — never swallow it.
1562+
// Onboarding still succeeds, mirroring the brand-activation side-effect
1563+
// handling in brands.js (activateBrand). `status` is the upstream HTTP
1564+
// status when the DRS client attaches one.
1565+
const status = scheduleError.status ?? 'unknown';
1566+
log.error(`Failed to register DRS ${name} schedule provider_id=${providerId} `
1567+
+ `site_id=${site.getId()} status=${status}: ${scheduleError.message}`);
1568+
say(`:warning: Failed to register DRS ${name} schedule for site ${site.getId()} `
1569+
+ '(will need manual trigger)');
1570+
}
1571+
}),
1572+
);
14251573
} else {
14261574
// V1 has no Brandalf trigger, so DRS will not submit prompt generation
14271575
// automatically. Submit it directly here so v1 onboardings still get

test/controllers/llmo/llmo-onboarding.test.js

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,14 @@ describe('LLMO Onboarding Functions', () => {
198198
isConfigured = true,
199199
submitJob = sandbox.stub().resolves({ job_id: 'test-brandalf-job-123' }),
200200
submitPromptGenerationJob = sandbox.stub().resolves({ job_id: 'test-drs-job-123' }),
201+
createSchedule = sandbox.stub().resolves({ scheduleId: 'test-schedule-123', alreadyExisted: false }),
201202
} = options;
202203

203204
const instance = {
204205
isConfigured: sandbox.stub().returns(isConfigured),
205206
submitJob,
206207
submitPromptGenerationJob,
208+
createSchedule,
207209
};
208210

209211
return {
@@ -5756,4 +5758,175 @@ describe('LLMO Onboarding Functions', () => {
57565758
expect(() => validateConfiguration(config)).to.throw(/detectedCdn/);
57575759
});
57585760
});
5761+
5762+
describe('prompt-suggestion schedule triggers (Phase 3)', () => {
5763+
let onboardingModule;
5764+
let sandbox;
5765+
5766+
before(async () => {
5767+
onboardingModule = await esmock('../../../src/controllers/llmo/llmo-onboarding.js', {});
5768+
});
5769+
5770+
beforeEach(() => {
5771+
sandbox = sinon.createSandbox();
5772+
});
5773+
5774+
afterEach(() => {
5775+
sandbox.restore();
5776+
});
5777+
5778+
const buildDrsClient = (createScheduleImpl) => ({
5779+
isConfigured: sandbox.stub().returns(true),
5780+
createSchedule: createScheduleImpl
5781+
|| sandbox.stub().resolves({ scheduleId: 'sched-1', alreadyExisted: false }),
5782+
});
5783+
5784+
const buildParams = (drsClient) => ({
5785+
drsClient,
5786+
siteId: 'site-123',
5787+
orgId: 'org-123',
5788+
imsOrgId: 'ABC123@AdobeOrg',
5789+
log: {
5790+
info: sandbox.stub(), debug: sandbox.stub(), warn: sandbox.stub(), error: sandbox.stub(),
5791+
},
5792+
say: sandbox.stub(),
5793+
});
5794+
5795+
const HELPERS = [
5796+
['triggerSemrushPromptJob', 'prompt_generation_semrush', 'twice-monthly'],
5797+
['triggerCitationAttemptJob', 'prompt_generation_agentic_traffic', 'twice-monthly'],
5798+
['triggerSyntheticPersonasJob', 'prompt_generation_synthetic_personas', 'quarterly'],
5799+
];
5800+
5801+
HELPERS.forEach(([fnName, providerId, cadence]) => {
5802+
it(`${fnName} registers a ${cadence} schedule for ${providerId} with an immediate first run`, async () => {
5803+
const drsClient = buildDrsClient();
5804+
const result = await onboardingModule[fnName](buildParams(drsClient));
5805+
5806+
expect(drsClient.createSchedule).to.have.been.calledOnce;
5807+
expect(drsClient.createSchedule.firstCall.args[0]).to.deep.include({
5808+
siteId: 'site-123',
5809+
orgId: 'org-123',
5810+
imsOrgId: 'ABC123@AdobeOrg',
5811+
providerId,
5812+
cadence,
5813+
enableBrandPresence: false,
5814+
triggerImmediately: true,
5815+
});
5816+
expect(result).to.deep.equal({ scheduleId: 'sched-1', alreadyExisted: false });
5817+
});
5818+
5819+
it(`${fnName} skips (no createSchedule) when the DRS client is not configured`, async () => {
5820+
const drsClient = buildDrsClient();
5821+
drsClient.isConfigured.returns(false);
5822+
const params = buildParams(drsClient);
5823+
5824+
const result = await onboardingModule[fnName](params);
5825+
5826+
expect(drsClient.createSchedule).to.not.have.been.called;
5827+
expect(result).to.equal(null);
5828+
expect(params.log.debug).to.have.been.calledWithMatch(providerId);
5829+
});
5830+
5831+
it(`${fnName} propagates a schedule-registration failure to the caller (not swallowed)`, async () => {
5832+
const err = new Error('DRS POST /schedules failed: 500');
5833+
const drsClient = buildDrsClient(sandbox.stub().rejects(err));
5834+
5835+
await expect(onboardingModule[fnName](buildParams(drsClient)))
5836+
.to.be.rejectedWith('DRS POST /schedules failed: 500');
5837+
});
5838+
});
5839+
});
5840+
5841+
describe('activateBrandAndGeneratePrompts — V2 prompt-suggestion schedules', () => {
5842+
let sandbox;
5843+
5844+
beforeEach(() => {
5845+
sandbox = sinon.createSandbox();
5846+
});
5847+
5848+
afterEach(() => {
5849+
sandbox.restore();
5850+
});
5851+
5852+
const buildV2Context = () => {
5853+
const brandsMaybeSingle = sandbox.stub().resolves({ data: null, error: null });
5854+
const eqName = sandbox.stub().returns({ maybeSingle: brandsMaybeSingle });
5855+
const eqOrg = sandbox.stub().returns({ eq: eqName });
5856+
const select = sandbox.stub().returns({ eq: eqOrg });
5857+
const postgrestClient = { from: sandbox.stub().returns({ select }) };
5858+
const log = {
5859+
info: sandbox.stub(), debug: sandbox.stub(), warn: sandbox.stub(), error: sandbox.stub(),
5860+
};
5861+
return {
5862+
log,
5863+
dataAccess: { services: { postgrestClient } },
5864+
};
5865+
};
5866+
5867+
const buildV2Params = (context) => ({
5868+
onboardingMode: 'v2',
5869+
organization: { getId: sandbox.stub().returns('org-123') },
5870+
site: { getId: sandbox.stub().returns('site-123') },
5871+
siteConfig: { getFetchConfig: sandbox.stub().returns({}) },
5872+
brandName: 'Test Brand',
5873+
imsOrgId: 'ABC123@AdobeOrg',
5874+
baseURL: 'https://example.com',
5875+
context,
5876+
say: sandbox.stub(),
5877+
});
5878+
5879+
it('registers all three prompt-suggestion schedules after the Brandalf trigger', async () => {
5880+
const mockDrsClient = createMockDrsClient(sandbox);
5881+
const { activateBrandAndGeneratePrompts } = await esmock(
5882+
'../../../src/controllers/llmo/llmo-onboarding.js',
5883+
createCommonEsmockDependencies({
5884+
mockDrsClient,
5885+
mockUpsertBrand: sandbox.stub().resolves({ id: 'brand-123', name: 'Test Brand' }),
5886+
}),
5887+
);
5888+
5889+
const context = buildV2Context();
5890+
await activateBrandAndGeneratePrompts(buildV2Params(context));
5891+
5892+
const instance = mockDrsClient.createFrom();
5893+
expect(instance.submitJob).to.have.been.calledOnce; // Brandalf fired first
5894+
expect(instance.createSchedule).to.have.been.calledThrice;
5895+
const providerIds = instance.createSchedule.getCalls().map((c) => c.args[0].providerId);
5896+
expect(providerIds).to.have.members([
5897+
'prompt_generation_semrush',
5898+
'prompt_generation_agentic_traffic',
5899+
'prompt_generation_synthetic_personas',
5900+
]);
5901+
});
5902+
5903+
it('logs a schedule-registration failure at ERROR with context but still completes onboarding', async () => {
5904+
const err = new Error('DRS POST /schedules failed');
5905+
err.status = 500;
5906+
const mockDrsClient = createMockDrsClient(sandbox, {
5907+
createSchedule: sandbox.stub().rejects(err),
5908+
});
5909+
const { activateBrandAndGeneratePrompts } = await esmock(
5910+
'../../../src/controllers/llmo/llmo-onboarding.js',
5911+
createCommonEsmockDependencies({
5912+
mockDrsClient,
5913+
mockUpsertBrand: sandbox.stub().resolves({ id: 'brand-123', name: 'Test Brand' }),
5914+
}),
5915+
);
5916+
5917+
const context = buildV2Context();
5918+
// Must resolve (onboarding succeeds) despite every schedule registration failing.
5919+
await activateBrandAndGeneratePrompts(buildV2Params(context));
5920+
5921+
const errorLogs = context.log.error.getCalls().map((c) => c.args[0]);
5922+
const semrushLog = errorLogs.find((m) => m.includes('prompt_generation_semrush'));
5923+
expect(semrushLog, 'expected an ERROR log for the failed semrush schedule').to.be.a('string');
5924+
expect(semrushLog).to.include('site_id=site-123');
5925+
expect(semrushLog).to.include('status=500');
5926+
// All three providers are surfaced, none swallowed.
5927+
expect(context.log.error).to.have.been.calledThrice;
5928+
// Brandalf still fired — the schedule failures did not abort onboarding.
5929+
expect(mockDrsClient.createFrom().submitJob).to.have.been.calledOnce;
5930+
});
5931+
});
57595932
});

0 commit comments

Comments
 (0)