diff --git a/src/controllers/elements.js b/src/controllers/elements.js index e481693377..b1d64eb252 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. @@ -373,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); } /** @@ -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..0133d795b7 100644 --- a/src/support/elements/definitions/index.js +++ b/src/support/elements/definitions/index.js @@ -21,7 +21,11 @@ export { transformOtherTagsForFilterDimensions, } from './topics.js'; export { buildWeeksPayload, transformWeeksResponse } from './weeks.js'; -export { buildPromptsPayload, transformPromptsResponse } from './prompts.js'; +export { + buildPromptsPayload, + transformPromptsResponse, + 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..48313ce9d3 100644 --- a/src/support/elements/definitions/prompts.js +++ b/src/support/elements/definitions/prompts.js @@ -12,6 +12,9 @@ import { resolveElementModel } from '../constants.js'; +/** 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 +92,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..a4e616ce63 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,6 +29,7 @@ import { transformWeeksResponse, buildPromptsPayload, transformPromptsResponse, + INTENT_ENRICH_CONCURRENCY, buildCitedDomainsPayload, transformCitedDomainsResponse, buildSentimentOverviewPayload, @@ -60,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. @@ -135,17 +138,82 @@ export function createElementsService(transport) { /** * Fetches the prompts matching the given filters, plus their count. * + * 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, 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), + const { enrichUserIntent, ...promptParams } = params ?? {}; + const basePromise = transport + .fetchElement(workspaceId, ELEMENT_IDS.PROMPTS, buildPromptsPayload(promptParams)) + .then(transformPromptsResponse); + + // 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; + } + + // `(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, + async (value) => { + const key = value.toLowerCase(); + try { + const raw = await transport.fetchElement( + workspaceId, + ELEMENT_IDS.PROMPTS, + buildPromptsPayload({ ...promptParams, tags: [...(promptParams.tags ?? []), `intent__${value}`] }), + ); + return { key, rows: transformPromptsResponse(raw).prompts }; + } catch (e) { + log?.warn?.(`serenity userIntent enrichment: intent-filtered PROMPTS call failed for '${value}'`, { workspaceId, error: e?.message }); + return { key, rows: [] }; + } + }, ); - return transformPromptsResponse(raw); + + const [base, intentResults] = await Promise.all([basePromise, intentPromise]); + + // (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) { + intentByRow.set(rowKey(row), key); + } + } + + const prompts = base.prompts.map((p) => ({ + ...p, + 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 3ae28d25d3..bea0ecd929 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,9 +635,18 @@ describe('ElementsController', () => { platform: undefined, tags: ['type__branded', 'category__Brand'], projectIds: ['proj-a', 'proj-b'], + enrichUserIntent: false, }); }); + 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); @@ -1187,4 +1196,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..1a78d0f89c 100644 --- a/test/support/elements/elements-service.test.js +++ b/test/support/elements/elements-service.test.js @@ -16,6 +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 { INTENT_VALUE } from '../../../src/support/serenity/prompt-tags.js'; + +const INTENT_VALUES = Object.values(INTENT_VALUE); use(chaiAsPromised); use(sinonChai); @@ -222,6 +225,103 @@ 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. + INTENT_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, + }])); + }); + + // 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', 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(''); + const promptCalls = transport.fetchElement.getCalls() + .filter((c) => c.args[1] === ELEMENT_IDS.PROMPTS); + expect(promptCalls).to.have.length(1 + INTENT_VALUES.length); + }); + + it('does not enrich or make extra calls without the flag', async () => { + 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('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', ENRICH); + expect(result.count).to.equal(2); + result.prompts.forEach((p) => expect(p.userIntent).to.equal('')); + }); + }); + describe('getBrandPresenceStats', () => { const simpleNumeric = (value) => ({ blocks: { firstSectionMainValue: [{ firstSectionMainValue: value }] },