diff --git a/src/paid-keyword-optimizer/guidance-opportunity-mapper.js b/src/paid-keyword-optimizer/guidance-opportunity-mapper.js index 91030c1aaf..1df368006d 100644 --- a/src/paid-keyword-optimizer/guidance-opportunity-mapper.js +++ b/src/paid-keyword-optimizer/guidance-opportunity-mapper.js @@ -88,13 +88,17 @@ export function mapToKeywordOptimizerOpportunity(siteId, audit, message) { portfolioMetrics = {}, } = guidanceBody; /** - * Page-level context fields emitted by mystique (additive, both optional). + * Page-level context fields emitted by mystique (additive, all optional). * * Distinct from per-cluster * `gapAnalysis.{keywordToPageGap,adToPageGap}.relevantExtractedTopics`, * which are LLM-filtered subsets of `pageTopics` filtered for cluster- * specific intent relevance. `pageTopics` here is the full page-level set. * + * `pageRecommendation` is the strategist's reconciled, play-aware page-level + * recommendation (inferredPlay, primary + rankedActions with lever/alternatives); + * `null` when mystique skips/fails the strategist pass or emits an older payload. + * * Use `??` (nullish coalescing) instead of ES destructure defaults so that * a future regression emitting `pageTopics: null` (instead of omitting the * key) still results in an empty array on the consumer side. ES destructure @@ -102,6 +106,7 @@ export function mapToKeywordOptimizerOpportunity(siteId, audit, message) { */ const resolvedPageHeading = guidanceBody.resolvedPageHeading ?? null; const pageTopics = guidanceBody.pageTopics ?? []; + const pageRecommendation = guidanceBody.pageRecommendation ?? null; const { langfuseTraceId, langfuseTraceUrl } = guidanceBody?.observability || {}; const hasConflictingHeadlineRecommendations = clusterResults.filter( @@ -149,6 +154,7 @@ export function mapToKeywordOptimizerOpportunity(siteId, audit, message) { totalMisalignedSpend, resolvedPageHeading, pageTopics, + pageRecommendation, }, status: 'NEW', tags: [ diff --git a/test/audits/paid-keyword-optimizer/opportunity-mapper.test.js b/test/audits/paid-keyword-optimizer/opportunity-mapper.test.js index 50ea9f7fa0..73967c91d1 100644 --- a/test/audits/paid-keyword-optimizer/opportunity-mapper.test.js +++ b/test/audits/paid-keyword-optimizer/opportunity-mapper.test.js @@ -670,6 +670,44 @@ describe('Paid Keyword Optimizer opportunity mapper (cluster format)', () => { expect(result.data.pageTopics).to.be.an('array').that.is.empty; }); + it('passes pageRecommendation through to opportunity.data', () => { + const pageRecommendation = { + inferredPlay: 'Branded navigational defense', + playConfidence: 'high', + primary: { + lever: 'on_page', + actionType: 'modify_heading', + title: 'Broaden the H1 for branded visitors', + detail: 'Surface the core IAM promise alongside AI.', + affectedClusters: ['okta'], + alternatives: [], + }, + rankedActions: [], + reasoning: 'Reconciled across all branded clusters.', + }; + const audit = createMockAudit(); + const message = createClusterMessage({ extraBody: { pageRecommendation } }); + + const result = mapToKeywordOptimizerOpportunity(TEST_SITE_ID, audit, message); + + expect(result.data.pageRecommendation).to.deep.equal(pageRecommendation); + }); + + it('defaults pageRecommendation to null when absent (old mystique) or explicitly null', () => { + const audit = createMockAudit(); + // Absent: no extraBody at all. + const absent = mapToKeywordOptimizerOpportunity(TEST_SITE_ID, audit, createClusterMessage()); + expect(absent.data.pageRecommendation).to.be.null; + + // Explicitly null (strategist skipped/failed): collapses to null, not undefined. + const explicitNull = mapToKeywordOptimizerOpportunity( + TEST_SITE_ID, + audit, + createClusterMessage({ extraBody: { pageRecommendation: null } }), + ); + expect(explicitNull.data.pageRecommendation).to.be.null; + }); + it('collapses explicit null pageTopics to [] (future regression guard)', () => { const audit = createMockAudit(); const message = createClusterMessage({