Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,20 @@ class Audit extends BaseModel {
/**
* The destinations for the audit steps. Used with AuditBuilder to determine the destination
* an audit step should trigger.
* @type {{CONTENT_SCRAPER: string, IMPORT_WORKER: string}}
* @type {{
* CONTENT_SCRAPER: string,
* IMPORT_WORKER: string,
* SCRAPE_CLIENT: string,
* MYSTIQUE: string,
* GEO_BRAND_PRESENCE_CATEGORIZATION: string,
* GEO_BRAND_PRESENCE_DETECTION: string,
* }}
*/
static AUDIT_STEP_DESTINATIONS = {
CONTENT_SCRAPER: 'content-scraper',
IMPORT_WORKER: 'import-worker',
SCRAPE_CLIENT: 'scrape-client',
MYSTIQUE: 'mystique',
};

/**
Expand All @@ -108,8 +116,13 @@ class Audit extends BaseModel {
* formatPayload: function
* },
* [Audit.AUDIT_STEP_DESTINATIONS.SCRAPE_CLIENT]: {
* formatPayload: function
* }}}
* formatPayload: function
* },
* [Audit.AUDIT_STEP_DESTINATIONS.MYSTIQUE]: {
* getQueueUrl: function,
* formatPayload: function
* }
* }}
*/
static AUDIT_STEP_DESTINATION_CONFIGS = {
[Audit.AUDIT_STEP_DESTINATIONS.IMPORT_WORKER]: {
Expand Down Expand Up @@ -190,11 +203,70 @@ class Audit extends BaseModel {
maxScrapeAge: isNumber(stepResult.maxScrapeAge) ? stepResult.maxScrapeAge : 24,
auditData: {
siteId: stepResult.siteId,
completionQueueUrl: stepResult.completionQueueUrl || context.env?.AUDIT_JOBS_QUEUE_URL,
completionQueueUrl:
stepResult.completionQueueUrl || context.env?.AUDIT_JOBS_QUEUE_URL,
auditContext,
},
}),
},
[Audit.AUDIT_STEP_DESTINATIONS.MYSTIQUE]: {
getQueueUrl: (context) => context.env?.QUEUE_SPACECAT_TO_MYSTIQUE,
/**
* Formats the payload for the Mystique queue.
* @param {object} stepResult - The result of the audit step.
* @param {string} stepResult.type - The message type for Mystique
* (e.g., 'categorize:geo-brand-presence').
* @param {string} stepResult.siteId - The site ID.
* @param {string} stepResult.url - The base URL or data URL.
* @param {string} stepResult.auditId - The audit ID.
* @param {string} stepResult.deliveryType - The site delivery type.
* @param {object} stepResult.calendarWeek - The calendar week context { week, year }.
* @param {string} [stepResult.configVersion] - The LLMO config version (optional).
* @param {string} [stepResult.date] - The date for daily cadence (optional).
* @param {object} stepResult.data - Additional data payload for Mystique.
* @param {object} auditContext - The audit context.
* @param {object} auditContext.next - The next audit step to run.
* @param {string} auditContext.auditId - The audit ID.
* @param {string} auditContext.auditType - The audit type.
* @param {string} auditContext.fullAuditRef - The full audit reference.
*
* @returns {object} - The formatted payload.
*/
formatPayload: (stepResult, auditContext) => {
const payload = {
type: stepResult.type,
siteId: stepResult.siteId,
url: stepResult.url,
auditId: stepResult.auditId,
deliveryType: stepResult.deliveryType,
time: new Date().toISOString(),
week: stepResult.calendarWeek.week,
year: stepResult.calendarWeek.year,
data: stepResult.data || {},
auditContext,
};

// Add optional fields
if (stepResult.configVersion) {
payload.data.configVersion = stepResult.configVersion;
payload.data.config_version = stepResult.configVersion;
}

if (stepResult.date) {
payload.data.date = stepResult.date;
}

if (stepResult.source) {
payload.source = stepResult.source;
}

if (stepResult.initiator) {
payload.initiator = stepResult.initiator;
}

return payload;
},
},
};

/**
Expand All @@ -214,7 +286,7 @@ class Audit extends BaseModel {

if ((
auditType === Audit.AUDIT_CONFIG.TYPES.LHS_MOBILE
|| auditType === Audit.AUDIT_CONFIG.TYPES.LHS_DESKTOP
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is your editor making these whitespace changes automatically?
Please either disable that until we settle on a common formatter, or just don't commit them.

|| auditType === Audit.AUDIT_CONFIG.TYPES.LHS_DESKTOP
)
&& !isObject(auditResult.scores)) {
throw new ValidationError(`Missing scores property for audit type '${auditType}'`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ describe('AuditModel', () => {
};
const context = {
env: {
AUDIT_JOBS_QUEUE_URL: 'audit-jobs-queue-url',
AUDIT_JOBS_QUEUE_URL: 'https://sqs.us-east-1.amazonaws.com/123456789012/audit-jobs-queue',
},
};
const auditContext = { some: 'context' };
Expand All @@ -304,7 +304,7 @@ describe('AuditModel', () => {
urls: [{ url: 'someUrl' }],
jobId: 'someSiteId',
processingType: 'someProcessingType',
completionQueueUrl: 'audit-jobs-queue-url',
completionQueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/audit-jobs-queue',
skipMessage: false,
allowCache: true,
options: {},
Expand Down
22 changes: 19 additions & 3 deletions packages/spacecat-shared-utils/src/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const llmoConfig = z.object({
entities: z.record(z.uuid(), entity),
categories: z.record(z.uuid(), category),
topics: z.record(z.uuid(), topic),
aiTopics: z.record(z.uuid(), topic).optional(),
brands: z.object({
aliases: z.array(
z.object({
Expand Down Expand Up @@ -148,6 +149,21 @@ export const llmoConfig = z.object({
});

// Validate topic prompts regions against their category
validateTopicPromptRegions(categories, ctx, topics, 'topics');

// Validate aiTopics prompts regions against their category
if (value.aiTopics) {
validateTopicPromptRegions(categories, ctx, value.aiTopics, 'aiTopics');
}
});

/**
* @param {LLMOConfig['categories']} categories
* @param {z.RefinementCtx} ctx
* @param {Record<string, z.infer<typeof topic>>} topics
* @param {string} topicsKey - The key name in the path (e.g., 'topics' or 'aiTopics')
*/
function validateTopicPromptRegions(categories, ctx, topics, topicsKey) {
Object.entries(topics).forEach(([topicId, topicEntity]) => {
if (topicEntity.prompts && topicEntity.category) {
// If category is a UUID, validate against the referenced category entity
Expand All @@ -158,14 +174,14 @@ export const llmoConfig = z.object({
ctx,
topicEntity.category,
promptItem.regions,
['topics', topicId, 'prompts', promptIndex, 'regions'],
'topic prompt',
[topicsKey, topicId, 'prompts', promptIndex, 'regions'],
`${topicsKey} prompt`,
);
});
}
}
});
});
}

/**
* @param {LLMOConfig['categories']} categories
Expand Down
94 changes: 93 additions & 1 deletion packages/spacecat-shared-utils/test/schemas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ describe('schemas', () => {
if (result.success) {
throw new Error('Expected validation to fail');
}
expect(result.error.issues[0].message).equals('topic prompt regions [mx] are not allowed. Category only supports regions: [us, ca]');
expect(result.error.issues[0].message).equals('topics prompt regions [mx] are not allowed. Category only supports regions: [us, ca]');
});

it('validates when topic category is a string name (no region validation)', () => {
Expand Down Expand Up @@ -516,6 +516,98 @@ describe('schemas', () => {
expect(result.success).false;
});
});

describe('aiTopics prompts', () => {
it('validates when aiTopics prompt regions are subset of category regions', () => {
const aiTopicId = '999e9999-e99b-49d9-a999-999999999999';
const config = {
...configWithRegions,
aiTopics: {
[aiTopicId]: {
name: 'AI Test Topic',
prompts: [
{
prompt: 'AI Test prompt 1',
regions: ['us'],
origin: 'ai',
source: 'flow',
},
{
prompt: 'AI Test prompt 2',
regions: ['ca', 'us'],
origin: 'ai',
source: 'flow',
},
],
category: categoryWithRegionsId,
},
},
};

const result = llmoConfig.safeParse(config);
expect(result.success).true;
});

it('fails when aiTopics prompt has regions not in category', () => {
const aiTopicId = 'aaaa0000-ea0b-40d0-a000-000000000000';
const config = {
...configWithRegions,
aiTopics: {
[aiTopicId]: {
name: 'AI Test Topic',
prompts: [
{
prompt: 'AI Test prompt',
regions: ['us', 'mx'], // mx not in category regions
origin: 'ai',
source: 'flow',
},
],
category: categoryWithRegionsId,
},
},
};

const result = llmoConfig.safeParse(config);
expect(result.success).false;
if (result.success) {
throw new Error('Expected validation to fail');
}
expect(result.error.issues[0].message).equals('aiTopics prompt regions [mx] are not allowed. Category only supports regions: [us, ca]');
});

it('validates when aiTopics category is a string name (no region validation)', () => {
const aiTopicId = 'bbbb1111-eb1b-41d1-a111-111111111111';
const config = {
...configWithRegions,
aiTopics: {
[aiTopicId]: {
name: 'AI Test Topic',
prompts: [
{
prompt: 'AI Test prompt',
regions: ['mx'], // Any regions allowed when category is string
origin: 'ai',
source: 'flow',
},
],
category: 'AI Test Category Name', // String name, not UUID
},
},
};

const result = llmoConfig.safeParse(config);
expect(result.success).true;
});

it('validates configuration without aiTopics (optional field)', () => {
const result = llmoConfig.safeParse(configWithRegions);
expect(result.success).true;
if (result.success) {
expect(result.data.aiTopics).to.be.undefined;
}
});
});
});

describe('deleted', () => {
Expand Down
Loading