From f5dfdf3a71187d25cdc305b3f5ca5b30e25077d4 Mon Sep 17 00:00:00 2001 From: hjen_adobe Date: Mon, 20 Jul 2026 16:37:39 +0200 Subject: [PATCH 1/4] feat(serenity): opt-in per-prompt userIntent on brand-presence prompts Adds an opt-in `userIntent` query param to the Serenity brand-presence prompts endpoint. When set, each returned row gains its own classified intent (`userIntent`), derived by one `intent__`-filtered PROMPTS element call per Semrush intent (parallel, joined by prompt text). Non-fatal: a classification-call failure degrades to blank `userIntent`, never failing the request. Without the flag, behavior and upstream call count are unchanged. Consumed by the Topic Intent Alignment tile (project-elmo-ui#2478) to build per-topic own-intent distributions from the full, uncapped prompt set. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/elements.js | 19 +++++ src/support/elements/definitions/index.js | 7 +- src/support/elements/definitions/prompts.js | 23 ++++++ src/support/elements/elements-service.js | 62 ++++++++++++++-- test/controllers/elements.test.js | 31 +++++++- .../support/elements/elements-service.test.js | 72 +++++++++++++++++++ 6 files changed, 205 insertions(+), 9 deletions(-) diff --git a/src/controllers/elements.js b/src/controllers/elements.js index e481693377..d5eeb10527 100644 --- a/src/controllers/elements.js +++ b/src/controllers/elements.js @@ -179,6 +179,24 @@ export function parseShowTrends(q) { return false; } +/** + * True when the `userIntent`/`user_intent` query param opts into per-prompt + * intent enrichment on the brand-presence prompts endpoint. Same boolean + * parsing as {@link parseShowTrends} (the HTTP path only ever yields strings; + * the boolean/number branch is unit-test-only). + */ +export function parseUserIntent(q) { + const v = q?.userIntent ?? q?.user_intent; + if (v === true || v === 1) { + return true; + } + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === 'true' || s === '1'; + } + return false; +} + /** * Extracts and validates the IMS bearer token from the inbound Authorization header. * Throws 401 if missing or if the caller authenticated via a non-IMS mechanism. @@ -477,6 +495,7 @@ export default function ElementsController(context, log, env) { platform: query.platform, tags: splitCsv(query.tag), projectIds: splitCsv(query.projectId || query.project_id), + enrichUserIntent: parseUserIntent(query), }); return ok(result); } catch (e) { diff --git a/src/support/elements/definitions/index.js b/src/support/elements/definitions/index.js index bab124f48f..f98c8efa1b 100644 --- a/src/support/elements/definitions/index.js +++ b/src/support/elements/definitions/index.js @@ -21,7 +21,12 @@ export { transformOtherTagsForFilterDimensions, } from './topics.js'; export { buildWeeksPayload, transformWeeksResponse } from './weeks.js'; -export { buildPromptsPayload, transformPromptsResponse } from './prompts.js'; +export { + buildPromptsPayload, + transformPromptsResponse, + SEMRUSH_INTENT_TAG_VALUES, + INTENT_ENRICH_CONCURRENCY, +} from './prompts.js'; export { buildCitedDomainsPayload, transformCitedDomainsResponse } from './cited-domains.js'; export { buildSentimentOverviewPayload, diff --git a/src/support/elements/definitions/prompts.js b/src/support/elements/definitions/prompts.js index edf5a18733..730715bb45 100644 --- a/src/support/elements/definitions/prompts.js +++ b/src/support/elements/definitions/prompts.js @@ -12,6 +12,25 @@ import { resolveElementModel } from '../constants.js'; +/** + * The Semrush 5-value intent taxonomy, as the exact (capitalized) tag VALUES + * stored under the `intent` dimension — verified live against a real workspace + * (Lovesac): `tags contains intent__Informational` returns that subset. These + * match the values the write-path classifier (serenity-docs#32 / api PR #2785) + * assigns. Used to enrich each prompt row with its own intent (see `getPrompts`): + * one `intent__`-filtered PROMPTS call per value, joined back by prompt. + */ +export const SEMRUSH_INTENT_TAG_VALUES = [ + 'Informational', + 'Commercial', + 'Transactional', + 'Task', + 'Navigational', +]; + +/** Max parallel intent-filtered PROMPTS calls when enriching `userIntent`. */ +export const INTENT_ENRICH_CONCURRENCY = 5; + /** * Builds the payload for the Prompts element ({@link ELEMENT_IDS.PROMPTS}). * @@ -89,6 +108,10 @@ export function buildPromptsPayload({ * @property {number} volume - Estimated number of times per month a user asked the LLM * a question about this topic. A per-topic estimate, so prompts sharing a topic carry * the same volume. + * @property {string} [userIntent] - The prompt's OWN intent (a lowercased Semrush intent + * key, e.g. `commercial`), independent of the topic's `primary_intent`. Present only + * when the caller opts into enrichment; `''` when the prompt has no intent tag. Added by + * the service (`getPrompts`), NOT by `transformPromptsResponse`. */ /** diff --git a/src/support/elements/elements-service.js b/src/support/elements/elements-service.js index 4f1fe3907d..a1edd866cc 100644 --- a/src/support/elements/elements-service.js +++ b/src/support/elements/elements-service.js @@ -28,6 +28,8 @@ import { transformWeeksResponse, buildPromptsPayload, transformPromptsResponse, + SEMRUSH_INTENT_TAG_VALUES, + INTENT_ENRICH_CONCURRENCY, buildCitedDomainsPayload, transformCitedDomainsResponse, buildSentimentOverviewPayload, @@ -135,17 +137,63 @@ export function createElementsService(transport) { /** * Fetches the prompts matching the given filters, plus their count. * + * When `params.enrichUserIntent` is set, each returned row also carries its + * OWN intent (`userIntent`) — the intent isn't a column on the PROMPTS + * element, so it's derived with one `intent__`-filtered call per + * Semrush intent (run in parallel), joined back to the base rows by prompt + * text. Enrichment is NON-FATAL: if the intent calls fail, rows are returned + * with `userIntent: ''` rather than failing the whole request. The base call + * itself still propagates on failure. Without the flag, behavior + upstream + * call count are unchanged. + * * @param {string} workspaceId - Semrush workspace UUID. - * @param {object} params - Filter parameters (model/platform, topics, projectIds). + * @param {object} params - Filter parameters (model/platform, tags, projectIds, + * and `enrichUserIntent`). * @returns {Promise<{count: number, prompts: object[]}>} `{ count, prompts }`. */ async getPrompts(workspaceId, params) { - const raw = await transport.fetchElement( - workspaceId, - ELEMENT_IDS.PROMPTS, - buildPromptsPayload(params), - ); - return transformPromptsResponse(raw); + const basePromise = transport + .fetchElement(workspaceId, ELEMENT_IDS.PROMPTS, buildPromptsPayload(params)) + .then(transformPromptsResponse); + + if (!params?.enrichUserIntent) { + return basePromise; + } + + // Fire the base call + one intent-filtered call per intent value in + // parallel (~one extra round-trip of latency). The intent fan-out is + // non-fatal — degrade to blank `userIntent` on any failure. + const intentPromise = mapWithConcurrency( + SEMRUSH_INTENT_TAG_VALUES, + INTENT_ENRICH_CONCURRENCY, + async (value) => { + const raw = await transport.fetchElement( + workspaceId, + ELEMENT_IDS.PROMPTS, + buildPromptsPayload({ ...params, tags: [...(params.tags ?? []), `intent__${value}`] }), + ); + return { key: value.toLowerCase(), rows: transformPromptsResponse(raw).prompts }; + }, + ).catch(() => []); + + const [base, intentResults] = await Promise.all([basePromise, intentPromise]); + + // prompt text → own intent. A prompt carries exactly one intent tag, so + // it appears in at most one filtered result. + const intentByPrompt = new Map(); + for (const { key, rows } of intentResults) { + for (const row of rows) { + if (row?.prompt != null) { + intentByPrompt.set(row.prompt, key); + } + } + } + + const prompts = base.prompts.map((p) => ({ + ...p, + userIntent: intentByPrompt.get(p.prompt) ?? '', + })); + return { count: prompts.length, prompts }; }, /** diff --git a/test/controllers/elements.test.js b/test/controllers/elements.test.js index 3ae28d25d3..f5e2cb1659 100644 --- a/test/controllers/elements.test.js +++ b/test/controllers/elements.test.js @@ -16,7 +16,7 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import esmock from 'esmock'; import { ErrorWithStatusCode } from '../../src/support/utils.js'; -import { parseShowTrends } from '../../src/controllers/elements.js'; +import { parseShowTrends, parseUserIntent } from '../../src/controllers/elements.js'; use(chaiAsPromised); use(sinonChai); @@ -635,6 +635,7 @@ describe('ElementsController', () => { platform: undefined, tags: ['type__branded', 'category__Brand'], projectIds: ['proj-a', 'proj-b'], + enrichUserIntent: false, }); }); @@ -1187,4 +1188,32 @@ describe('ElementsController', () => { expect(parseShowTrends(undefined)).to.equal(false); }); }); + + // ─── parseUserIntent ────────────────────────────────────────────────────── + // Opt-in flag for per-prompt intent enrichment on the brand-presence prompts + // endpoint. Same boolean-parse semantics as parseShowTrends. + + describe('parseUserIntent', () => { + it('returns true for the boolean true and the number 1', () => { + expect(parseUserIntent({ userIntent: true })).to.equal(true); + expect(parseUserIntent({ userIntent: 1 })).to.equal(true); + }); + + it('returns true for the string "true"/"1" (any case/whitespace)', () => { + expect(parseUserIntent({ userIntent: 'TRUE' })).to.equal(true); + expect(parseUserIntent({ userIntent: ' 1 ' })).to.equal(true); + }); + + it('falls back to user_intent when userIntent is absent', () => { + expect(parseUserIntent({ user_intent: 'true' })).to.equal(true); + }); + + it('returns false for "false", unrelated strings, 0, and absent keys', () => { + expect(parseUserIntent({ userIntent: 'false' })).to.equal(false); + expect(parseUserIntent({ userIntent: 'yes' })).to.equal(false); + expect(parseUserIntent({ userIntent: 0 })).to.equal(false); + expect(parseUserIntent({})).to.equal(false); + expect(parseUserIntent(undefined)).to.equal(false); + }); + }); }); diff --git a/test/support/elements/elements-service.test.js b/test/support/elements/elements-service.test.js index 6f531ead2b..cd5b64c628 100644 --- a/test/support/elements/elements-service.test.js +++ b/test/support/elements/elements-service.test.js @@ -16,6 +16,7 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { createElementsService } from '../../../src/support/elements/elements-service.js'; import { ELEMENT_IDS } from '../../../src/support/elements/element-ids.js'; +import { SEMRUSH_INTENT_TAG_VALUES } from '../../../src/support/elements/definitions/prompts.js'; use(chaiAsPromised); use(sinonChai); @@ -222,6 +223,77 @@ describe('createElementsService', () => { }); }); + describe('getPrompts — userIntent enrichment', () => { + const rawWith = (rows) => ({ type: 'table', blocks: { data: rows } }); + const RAW_BASE = rawWith([ + { + primary_intent: 'informational', prompt: 'p-info', prompt_topic: 'T1', volume: 10, + }, + { + primary_intent: 'informational', prompt: 'p-comm', prompt_topic: 'T1', volume: 10, + }, + ]); + // Matches a PROMPTS payload carrying a specific `intent__X` tag clause. + const withTag = (val) => sinon.match((payload) => Boolean( + payload?.filters?.advanced?.filters?.some((f) => f.col === 'tags' && f.val === val), + )); + // Matches the base call (no `intent__` tag clause). + const noIntentTag = sinon.match((payload) => !payload?.filters?.advanced?.filters + ?.some((f) => f.col === 'tags' && String(f.val).startsWith('intent__'))); + + beforeEach(() => { + transport.fetchElement.withArgs('ws-1', ELEMENT_IDS.PROMPTS, noIntentTag).resolves(RAW_BASE); + // All intent-filtered calls return empty except Commercial, which claims p-comm. + SEMRUSH_INTENT_TAG_VALUES + .filter((v) => v !== 'Commercial') + .forEach((v) => transport.fetchElement + .withArgs('ws-1', ELEMENT_IDS.PROMPTS, withTag(`intent__${v}`)).resolves(rawWith([]))); + transport.fetchElement + .withArgs('ws-1', ELEMENT_IDS.PROMPTS, withTag('intent__Commercial')) + .resolves(rawWith([{ + primary_intent: 'informational', prompt: 'p-comm', prompt_topic: 'T1', volume: 10, + }])); + }); + + it('stamps each row with its own intent (base + one call per intent value)', async () => { + const result = await service.getPrompts('ws-1', { enrichUserIntent: true }); + const byPrompt = Object.fromEntries(result.prompts.map((p) => [p.prompt, p.userIntent])); + expect(byPrompt['p-comm']).to.equal('commercial'); + expect(byPrompt['p-info']).to.equal(''); + const promptCalls = transport.fetchElement.getCalls() + .filter((c) => c.args[1] === ELEMENT_IDS.PROMPTS); + expect(promptCalls).to.have.length(1 + SEMRUSH_INTENT_TAG_VALUES.length); + }); + + it('does not enrich or make extra calls without the flag', async () => { + const result = await service.getPrompts('ws-1', {}); + const promptCalls = transport.fetchElement.getCalls() + .filter((c) => c.args[1] === ELEMENT_IDS.PROMPTS); + expect(promptCalls).to.have.length(1); + expect(result.prompts[0]).to.not.have.property('userIntent'); + }); + + it('ANDs intent__X with any pre-existing tag filter', async () => { + await service.getPrompts('ws-1', { enrichUserIntent: true, tags: ['type__branded'] }); + const commercialCall = transport.fetchElement.getCalls() + .find((c) => c.args[1] === ELEMENT_IDS.PROMPTS + && c.args[2]?.filters?.advanced?.filters + ?.some((f) => f.col === 'tags' && f.val === 'intent__Commercial')); + const tagVals = commercialCall.args[2].filters.advanced.filters + .filter((f) => f.col === 'tags').map((f) => f.val); + expect(tagVals).to.include.members(['type__branded', 'intent__Commercial']); + }); + + it('is non-fatal: a failing intent call degrades to blank userIntent', async () => { + transport.fetchElement + .withArgs('ws-1', ELEMENT_IDS.PROMPTS, withTag('intent__Commercial')) + .rejects(new Error('intent call failed')); + const result = await service.getPrompts('ws-1', { enrichUserIntent: true }); + expect(result.count).to.equal(2); + result.prompts.forEach((p) => expect(p.userIntent).to.equal('')); + }); + }); + describe('getBrandPresenceStats', () => { const simpleNumeric = (value) => ({ blocks: { firstSectionMainValue: [{ firstSectionMainValue: value }] }, From b6a354fbf563884fda3cd7a13b00662f9846f9a7 Mon Sep 17 00:00:00 2001 From: hjen_adobe Date: Mon, 20 Jul 2026 16:50:05 +0200 Subject: [PATCH 2/4] refactor(serenity): reuse canonical INTENT_VALUE for userIntent enrichment Drop the hardcoded intent-value array in the elements enrichment; iterate the canonical `INTENT_VALUE` from serenity/prompt-tags.js instead (the same `intent` closed-dimension vocabulary the write-path classifier derives its taxonomy from), so the enrichment's filter values can't drift from what the classifier writes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/support/elements/definitions/index.js | 1 - src/support/elements/definitions/prompts.js | 16 ---------------- src/support/elements/elements-service.js | 4 ++-- test/support/elements/elements-service.test.js | 8 +++++--- 4 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/support/elements/definitions/index.js b/src/support/elements/definitions/index.js index f98c8efa1b..0133d795b7 100644 --- a/src/support/elements/definitions/index.js +++ b/src/support/elements/definitions/index.js @@ -24,7 +24,6 @@ export { buildWeeksPayload, transformWeeksResponse } from './weeks.js'; export { buildPromptsPayload, transformPromptsResponse, - SEMRUSH_INTENT_TAG_VALUES, INTENT_ENRICH_CONCURRENCY, } from './prompts.js'; export { buildCitedDomainsPayload, transformCitedDomainsResponse } from './cited-domains.js'; diff --git a/src/support/elements/definitions/prompts.js b/src/support/elements/definitions/prompts.js index 730715bb45..48313ce9d3 100644 --- a/src/support/elements/definitions/prompts.js +++ b/src/support/elements/definitions/prompts.js @@ -12,22 +12,6 @@ import { resolveElementModel } from '../constants.js'; -/** - * The Semrush 5-value intent taxonomy, as the exact (capitalized) tag VALUES - * stored under the `intent` dimension — verified live against a real workspace - * (Lovesac): `tags contains intent__Informational` returns that subset. These - * match the values the write-path classifier (serenity-docs#32 / api PR #2785) - * assigns. Used to enrich each prompt row with its own intent (see `getPrompts`): - * one `intent__`-filtered PROMPTS call per value, joined back by prompt. - */ -export const SEMRUSH_INTENT_TAG_VALUES = [ - 'Informational', - 'Commercial', - 'Transactional', - 'Task', - 'Navigational', -]; - /** Max parallel intent-filtered PROMPTS calls when enriching `userIntent`. */ export const INTENT_ENRICH_CONCURRENCY = 5; diff --git a/src/support/elements/elements-service.js b/src/support/elements/elements-service.js index a1edd866cc..41f7f9834a 100644 --- a/src/support/elements/elements-service.js +++ b/src/support/elements/elements-service.js @@ -13,6 +13,7 @@ import { ELEMENT_IDS } from './element-ids.js'; import { mapWithConcurrency } from './concurrency.js'; import { splitDateRangeIntoWeeksBackward } from './week-utils.js'; +import { INTENT_VALUE } from '../serenity/prompt-tags.js'; import { buildBrandsPayload, transformBrandsToFilterDimensions, @@ -28,7 +29,6 @@ import { transformWeeksResponse, buildPromptsPayload, transformPromptsResponse, - SEMRUSH_INTENT_TAG_VALUES, INTENT_ENRICH_CONCURRENCY, buildCitedDomainsPayload, transformCitedDomainsResponse, @@ -164,7 +164,7 @@ export function createElementsService(transport) { // parallel (~one extra round-trip of latency). The intent fan-out is // non-fatal — degrade to blank `userIntent` on any failure. const intentPromise = mapWithConcurrency( - SEMRUSH_INTENT_TAG_VALUES, + Object.values(INTENT_VALUE), INTENT_ENRICH_CONCURRENCY, async (value) => { const raw = await transport.fetchElement( diff --git a/test/support/elements/elements-service.test.js b/test/support/elements/elements-service.test.js index cd5b64c628..a0aca08d44 100644 --- a/test/support/elements/elements-service.test.js +++ b/test/support/elements/elements-service.test.js @@ -16,7 +16,9 @@ import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { createElementsService } from '../../../src/support/elements/elements-service.js'; import { ELEMENT_IDS } from '../../../src/support/elements/element-ids.js'; -import { SEMRUSH_INTENT_TAG_VALUES } from '../../../src/support/elements/definitions/prompts.js'; +import { INTENT_VALUE } from '../../../src/support/serenity/prompt-tags.js'; + +const INTENT_VALUES = Object.values(INTENT_VALUE); use(chaiAsPromised); use(sinonChai); @@ -244,7 +246,7 @@ describe('createElementsService', () => { beforeEach(() => { transport.fetchElement.withArgs('ws-1', ELEMENT_IDS.PROMPTS, noIntentTag).resolves(RAW_BASE); // All intent-filtered calls return empty except Commercial, which claims p-comm. - SEMRUSH_INTENT_TAG_VALUES + INTENT_VALUES .filter((v) => v !== 'Commercial') .forEach((v) => transport.fetchElement .withArgs('ws-1', ELEMENT_IDS.PROMPTS, withTag(`intent__${v}`)).resolves(rawWith([]))); @@ -262,7 +264,7 @@ describe('createElementsService', () => { expect(byPrompt['p-info']).to.equal(''); const promptCalls = transport.fetchElement.getCalls() .filter((c) => c.args[1] === ELEMENT_IDS.PROMPTS); - expect(promptCalls).to.have.length(1 + SEMRUSH_INTENT_TAG_VALUES.length); + expect(promptCalls).to.have.length(1 + INTENT_VALUES.length); }); it('does not enrich or make extra calls without the flag', async () => { From 278007d405cd84d4c23296dd9eb7c6502e985286 Mon Sep 17 00:00:00 2001 From: hjen_adobe Date: Mon, 20 Jul 2026 17:09:37 +0200 Subject: [PATCH 3/4] fix(serenity): per-intent degradation + preserve base count in userIntent enrichment Addresses MysticatBot blocking review on #2858: - Move the intent-fan-out error handling inside the per-value callback so one failing intent-filtered call drops only that intent's rows, not the whole enrichment (was non-fatal per batch, now non-fatal per intent value). - Return the base response's `count` on the enriched path instead of the local array length, preserving the endpoint's count contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/support/elements/elements-service.js | 29 ++++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/support/elements/elements-service.js b/src/support/elements/elements-service.js index 41f7f9834a..b6702c20e3 100644 --- a/src/support/elements/elements-service.js +++ b/src/support/elements/elements-service.js @@ -161,20 +161,27 @@ export function createElementsService(transport) { } // Fire the base call + one intent-filtered call per intent value in - // parallel (~one extra round-trip of latency). The intent fan-out is - // non-fatal — degrade to blank `userIntent` on any failure. + // parallel (~one extra round-trip of latency). Enrichment is non-fatal + // PER intent value: each call catches its own failure and contributes an + // empty result, so one failing intent drops only that intent's rows — not + // the whole enrichment. const intentPromise = mapWithConcurrency( Object.values(INTENT_VALUE), INTENT_ENRICH_CONCURRENCY, async (value) => { - const raw = await transport.fetchElement( - workspaceId, - ELEMENT_IDS.PROMPTS, - buildPromptsPayload({ ...params, tags: [...(params.tags ?? []), `intent__${value}`] }), - ); - return { key: value.toLowerCase(), rows: transformPromptsResponse(raw).prompts }; + const key = value.toLowerCase(); + try { + const raw = await transport.fetchElement( + workspaceId, + ELEMENT_IDS.PROMPTS, + buildPromptsPayload({ ...params, tags: [...(params.tags ?? []), `intent__${value}`] }), + ); + return { key, rows: transformPromptsResponse(raw).prompts }; + } catch { + return { key, rows: [] }; + } }, - ).catch(() => []); + ); const [base, intentResults] = await Promise.all([basePromise, intentPromise]); @@ -189,11 +196,13 @@ export function createElementsService(transport) { } } + // Preserve the base response's `count` (may be a server-reported total) + // rather than substituting the local row count on the enriched path. const prompts = base.prompts.map((p) => ({ ...p, userIntent: intentByPrompt.get(p.prompt) ?? '', })); - return { count: prompts.length, prompts }; + return { count: base.count, prompts }; }, /** From fa5bba8bed6f19fc71d1c532b0177f94aa5b0b0a Mon Sep 17 00:00:00 2001 From: hjen_adobe Date: Mon, 20 Jul 2026 18:21:43 +0200 Subject: [PATCH 4/4] fix(serenity): harden userIntent join + observability (review follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the human multi-persona review on #2858: - Join now keys on the (prompt, prompt_topic) tuple, not bare prompt text, and is gated to single-slice calls (exactly one projectId): the PROMPTS element row carries no stable id / geo / language, so a cross-market join can't be disambiguated — skip enrichment rather than risk mislabeling. - Thread a logger into the Elements service; the per-intent catch now log.warns the swallowed error instead of degrading silently. - Correct the misleading `count` comment (no server total exists; base.count currently equals the row count). - Destructure `enrichUserIntent` out before spreading into buildPromptsPayload; dedupe the "non-fatal" JSDoc. - Tests: single-slice guard, (prompt, prompt_topic) tuple disambiguation, and a ?userIntent=true controller-wiring assertion. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/controllers/elements.js | 2 +- src/support/elements/elements-service.js | 65 +++++++++++-------- test/controllers/elements.test.js | 8 +++ .../support/elements/elements-service.test.js | 50 ++++++++++---- 4 files changed, 85 insertions(+), 40 deletions(-) diff --git a/src/controllers/elements.js b/src/controllers/elements.js index d5eeb10527..b1d64eb252 100644 --- a/src/controllers/elements.js +++ b/src/controllers/elements.js @@ -391,7 +391,7 @@ export default function ElementsController(context, log, env) { async function buildService(ctx) { const imsToken = await resolveElementsImsToken(ctx); - return createElementsService(createElementsTransport({ env, imsToken })); + return createElementsService(createElementsTransport({ env, imsToken }), log); } /** diff --git a/src/support/elements/elements-service.js b/src/support/elements/elements-service.js index b6702c20e3..a4e616ce63 100644 --- a/src/support/elements/elements-service.js +++ b/src/support/elements/elements-service.js @@ -62,8 +62,9 @@ const STATS_TRENDS_WEEK_CONCURRENCY = 4; * payload builders and response transformers. * * @param {object} transport - Elements transport created by createElementsTransport(). + * @param {object} [log] - Optional logger (`{ warn }`) for non-fatal degradation paths. */ -export function createElementsService(transport) { +export function createElementsService(transport, log) { return { /** * Fetches filter dimensions for the URL Inspector dashboard. @@ -137,14 +138,23 @@ export function createElementsService(transport) { /** * Fetches the prompts matching the given filters, plus their count. * - * When `params.enrichUserIntent` is set, each returned row also carries its - * OWN intent (`userIntent`) — the intent isn't a column on the PROMPTS - * element, so it's derived with one `intent__`-filtered call per - * Semrush intent (run in parallel), joined back to the base rows by prompt - * text. Enrichment is NON-FATAL: if the intent calls fail, rows are returned - * with `userIntent: ''` rather than failing the whole request. The base call - * itself still propagates on failure. Without the flag, behavior + upstream - * call count are unchanged. + * When `params.enrichUserIntent` is set (and the query is scoped to exactly + * one `projectId` — see below), each returned row also carries its OWN intent + * (`userIntent`). The intent isn't a column on the PROMPTS element, so it's + * derived with one `intent__`-filtered call per Semrush intent, run in + * parallel and joined back to the base rows. + * + * Enrichment is non-fatal per intent value: each call catches its own failure + * and contributes nothing, so one failing intent drops only that intent's rows. + * The base call still propagates on failure. Without the flag, response shape + * and upstream call count are unchanged. + * + * SINGLE-SLICE ONLY: the join key is `(prompt, prompt_topic)` — the strongest + * identifier the element row exposes (it carries no `semrushPromptId`, geo, or + * language). That tuple is unique only WITHIN one project (= one geo+language + * slice), so enrichment is skipped unless exactly one `projectId` is requested; + * across markets the same text could map to different intents and no row field + * could disambiguate it. (A stable per-row id from upstream would lift this.) * * @param {string} workspaceId - Semrush workspace UUID. * @param {object} params - Filter parameters (model/platform, tags, projectIds, @@ -152,19 +162,22 @@ export function createElementsService(transport) { * @returns {Promise<{count: number, prompts: object[]}>} `{ count, prompts }`. */ async getPrompts(workspaceId, params) { + const { enrichUserIntent, ...promptParams } = params ?? {}; const basePromise = transport - .fetchElement(workspaceId, ELEMENT_IDS.PROMPTS, buildPromptsPayload(params)) + .fetchElement(workspaceId, ELEMENT_IDS.PROMPTS, buildPromptsPayload(promptParams)) .then(transformPromptsResponse); - if (!params?.enrichUserIntent) { + // Enrich only when opted in AND scoped to a single slice (see the join-key + // note above); otherwise return the base rows unchanged. + if (!enrichUserIntent || (promptParams.projectIds ?? []).length !== 1) { return basePromise; } - // Fire the base call + one intent-filtered call per intent value in - // parallel (~one extra round-trip of latency). Enrichment is non-fatal - // PER intent value: each call catches its own failure and contributes an - // empty result, so one failing intent drops only that intent's rows — not - // the whole enrichment. + // `(prompt, prompt_topic)` join key — unique within the single requested slice. + const rowKey = (row) => `${row?.prompt ?? ''}${row?.prompt_topic ?? ''}`; + + // Base call + one intent-filtered call per intent value, in parallel + // (~one extra round-trip). Each intent call degrades independently. const intentPromise = mapWithConcurrency( Object.values(INTENT_VALUE), INTENT_ENRICH_CONCURRENCY, @@ -174,10 +187,11 @@ export function createElementsService(transport) { const raw = await transport.fetchElement( workspaceId, ELEMENT_IDS.PROMPTS, - buildPromptsPayload({ ...params, tags: [...(params.tags ?? []), `intent__${value}`] }), + buildPromptsPayload({ ...promptParams, tags: [...(promptParams.tags ?? []), `intent__${value}`] }), ); return { key, rows: transformPromptsResponse(raw).prompts }; - } catch { + } catch (e) { + log?.warn?.(`serenity userIntent enrichment: intent-filtered PROMPTS call failed for '${value}'`, { workspaceId, error: e?.message }); return { key, rows: [] }; } }, @@ -185,23 +199,20 @@ export function createElementsService(transport) { const [base, intentResults] = await Promise.all([basePromise, intentPromise]); - // prompt text → own intent. A prompt carries exactly one intent tag, so - // it appears in at most one filtered result. - const intentByPrompt = new Map(); + // (prompt, prompt_topic) → own intent. A prompt carries exactly one intent + // tag, so within a single slice it appears in at most one filtered result. + const intentByRow = new Map(); for (const { key, rows } of intentResults) { for (const row of rows) { - if (row?.prompt != null) { - intentByPrompt.set(row.prompt, key); - } + intentByRow.set(rowKey(row), key); } } - // Preserve the base response's `count` (may be a server-reported total) - // rather than substituting the local row count on the enriched path. const prompts = base.prompts.map((p) => ({ ...p, - userIntent: intentByPrompt.get(p.prompt) ?? '', + userIntent: intentByRow.get(rowKey(p)) ?? '', })); + // `count` mirrors the base response (currently equals the row count). return { count: base.count, prompts }; }, diff --git a/test/controllers/elements.test.js b/test/controllers/elements.test.js index f5e2cb1659..bea0ecd929 100644 --- a/test/controllers/elements.test.js +++ b/test/controllers/elements.test.js @@ -639,6 +639,14 @@ describe('ElementsController', () => { }); }); + it('passes enrichUserIntent: true to getPrompts when ?userIntent=true', async () => { + const ctx = fakeContext({ url: promptsUrl('?projectId=proj-a&userIntent=true') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + await ctrl.listPrompts(ctx); + const [, params] = serviceStub.getPrompts.firstCall.args; + expect(params.enrichUserIntent).to.equal(true); + }); + it('resolves the brand uuid via resolveBrandUuid before querying', async () => { const ctx = fakeContext({ url: promptsUrl() }); const ctrl = ElementsController(ctx, fakeLog(), ENV); diff --git a/test/support/elements/elements-service.test.js b/test/support/elements/elements-service.test.js index a0aca08d44..1a78d0f89c 100644 --- a/test/support/elements/elements-service.test.js +++ b/test/support/elements/elements-service.test.js @@ -257,8 +257,11 @@ describe('createElementsService', () => { }])); }); + // Enrichment requires exactly one projectId (single slice). + const ENRICH = { enrichUserIntent: true, projectIds: ['proj-a'] }; + it('stamps each row with its own intent (base + one call per intent value)', async () => { - const result = await service.getPrompts('ws-1', { enrichUserIntent: true }); + const result = await service.getPrompts('ws-1', ENRICH); const byPrompt = Object.fromEntries(result.prompts.map((p) => [p.prompt, p.userIntent])); expect(byPrompt['p-comm']).to.equal('commercial'); expect(byPrompt['p-info']).to.equal(''); @@ -268,29 +271,52 @@ describe('createElementsService', () => { }); it('does not enrich or make extra calls without the flag', async () => { - const result = await service.getPrompts('ws-1', {}); + const result = await service.getPrompts('ws-1', { projectIds: ['proj-a'] }); + const promptCalls = transport.fetchElement.getCalls() + .filter((c) => c.args[1] === ELEMENT_IDS.PROMPTS); + expect(promptCalls).to.have.length(1); + expect(result.prompts[0]).to.not.have.property('userIntent'); + }); + + it('skips enrichment when not scoped to exactly one projectId', async () => { + const result = await service.getPrompts('ws-1', { enrichUserIntent: true, projectIds: ['proj-a', 'proj-b'] }); const promptCalls = transport.fetchElement.getCalls() .filter((c) => c.args[1] === ELEMENT_IDS.PROMPTS); expect(promptCalls).to.have.length(1); expect(result.prompts[0]).to.not.have.property('userIntent'); }); - it('ANDs intent__X with any pre-existing tag filter', async () => { - await service.getPrompts('ws-1', { enrichUserIntent: true, tags: ['type__branded'] }); - const commercialCall = transport.fetchElement.getCalls() - .find((c) => c.args[1] === ELEMENT_IDS.PROMPTS - && c.args[2]?.filters?.advanced?.filters - ?.some((f) => f.col === 'tags' && f.val === 'intent__Commercial')); - const tagVals = commercialCall.args[2].filters.advanced.filters - .filter((f) => f.col === 'tags').map((f) => f.val); - expect(tagVals).to.include.members(['type__branded', 'intent__Commercial']); + it('joins on (prompt, prompt_topic) so identical text under different topics is labeled independently', async () => { + // Deterministic setup for this scenario only (unstubbed intent calls → empty). + transport.fetchElement.reset(); + // Same prompt text in two topics → two distinct rows with different intents. + transport.fetchElement.withArgs('ws-1', ELEMENT_IDS.PROMPTS, noIntentTag).resolves(rawWith([ + { + primary_intent: 'informational', prompt: 'best sofa', prompt_topic: 'Sofas', volume: 5, + }, + { + primary_intent: 'informational', prompt: 'best sofa', prompt_topic: 'Recliners', volume: 5, + }, + ])); + transport.fetchElement.withArgs('ws-1', ELEMENT_IDS.PROMPTS, withTag('intent__Commercial')) + .resolves(rawWith([{ + primary_intent: 'informational', prompt: 'best sofa', prompt_topic: 'Sofas', volume: 5, + }])); + transport.fetchElement.withArgs('ws-1', ELEMENT_IDS.PROMPTS, withTag('intent__Navigational')) + .resolves(rawWith([{ + primary_intent: 'informational', prompt: 'best sofa', prompt_topic: 'Recliners', volume: 5, + }])); + const result = await service.getPrompts('ws-1', ENRICH); + const byTopic = Object.fromEntries(result.prompts.map((p) => [p.prompt_topic, p.userIntent])); + expect(byTopic.Sofas).to.equal('commercial'); + expect(byTopic.Recliners).to.equal('navigational'); }); it('is non-fatal: a failing intent call degrades to blank userIntent', async () => { transport.fetchElement .withArgs('ws-1', ELEMENT_IDS.PROMPTS, withTag('intent__Commercial')) .rejects(new Error('intent call failed')); - const result = await service.getPrompts('ws-1', { enrichUserIntent: true }); + const result = await service.getPrompts('ws-1', ENRICH); expect(result.count).to.equal(2); result.prompts.forEach((p) => expect(p.userIntent).to.equal('')); });