diff --git a/packages/spacecat-shared-data-access/src/models/audit/audit.model.js b/packages/spacecat-shared-data-access/src/models/audit/audit.model.js index 780a9bc14..b9c33c2bc 100644 --- a/packages/spacecat-shared-data-access/src/models/audit/audit.model.js +++ b/packages/spacecat-shared-data-access/src/models/audit/audit.model.js @@ -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', }; /** @@ -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]: { @@ -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; + }, + }, }; /** @@ -214,7 +286,7 @@ class Audit extends BaseModel { if (( auditType === Audit.AUDIT_CONFIG.TYPES.LHS_MOBILE - || auditType === Audit.AUDIT_CONFIG.TYPES.LHS_DESKTOP + || auditType === Audit.AUDIT_CONFIG.TYPES.LHS_DESKTOP ) && !isObject(auditResult.scores)) { throw new ValidationError(`Missing scores property for audit type '${auditType}'`); diff --git a/packages/spacecat-shared-data-access/test/unit/models/audit/audit.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/audit/audit.model.test.js index ddca01b83..b45127fda 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/audit/audit.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/audit/audit.model.test.js @@ -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' }; @@ -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: {}, diff --git a/packages/spacecat-shared-utils/src/schemas.js b/packages/spacecat-shared-utils/src/schemas.js index 1ffaeacf5..4baf0d4fe 100644 --- a/packages/spacecat-shared-utils/src/schemas.js +++ b/packages/spacecat-shared-utils/src/schemas.js @@ -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({ @@ -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>} 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 @@ -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 diff --git a/packages/spacecat-shared-utils/test/schemas.test.js b/packages/spacecat-shared-utils/test/schemas.test.js index 1540135df..30adf6992 100644 --- a/packages/spacecat-shared-utils/test/schemas.test.js +++ b/packages/spacecat-shared-utils/test/schemas.test.js @@ -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)', () => { @@ -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', () => {