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
8 changes: 7 additions & 1 deletion src/paid-keyword-optimizer/guidance-opportunity-mapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,25 @@ 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
* defaults only fire for `undefined`, not `null`.
*/
const resolvedPageHeading = guidanceBody.resolvedPageHeading ?? null;
const pageTopics = guidanceBody.pageTopics ?? [];
const pageRecommendation = guidanceBody.pageRecommendation ?? null;
const { langfuseTraceId, langfuseTraceUrl } = guidanceBody?.observability || {};

const hasConflictingHeadlineRecommendations = clusterResults.filter(
Expand Down Expand Up @@ -149,6 +154,7 @@ export function mapToKeywordOptimizerOpportunity(siteId, audit, message) {
totalMisalignedSpend,
resolvedPageHeading,
pageTopics,
pageRecommendation,
},
status: 'NEW',
tags: [
Expand Down
38 changes: 38 additions & 0 deletions test/audits/paid-keyword-optimizer/opportunity-mapper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading