Skip to content
Merged
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
21 changes: 20 additions & 1 deletion src/controllers/elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion src/support/elements/definitions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/support/elements/definitions/prompts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}).
*
Expand Down Expand Up @@ -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`.
*/

/**
Expand Down
82 changes: 75 additions & 7 deletions src/support/elements/elements-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +29,7 @@ import {
transformWeeksResponse,
buildPromptsPayload,
transformPromptsResponse,
INTENT_ENRICH_CONCURRENCY,
buildCitedDomainsPayload,
transformCitedDomainsResponse,
buildSentimentOverviewPayload,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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__<value>`-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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): The .catch(() => []) wraps the entire mapWithConcurrency promise. If mapWithConcurrency rejects on the first unhandled rejection (standard Promise behavior), a single failing intent call wipes ALL intent data - even if 4 of 5 calls succeeded. The stated guarantee is "non-fatal per row" but the implementation is "non-fatal per batch."

Fix: move error handling inside the callback so each intent value degrades independently:

async (value) => {
  try {
    const raw = await transport.fetchElement(...);
    return { key: value.toLowerCase(), rows: transformPromptsResponse(raw).prompts };
  } catch {
    log.warn('intent enrichment failed for value', { value, workspaceId });
    return { key: value.toLowerCase(), rows: [] };
  }
}

Then remove the outer .catch(() => []). This also adds the logging visibility that the current silent catch lacks.

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 };
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): The non-enriched path returns base.count (from transformPromptsResponse, which may carry a server-reported total for pagination). The enriched path returns { count: prompts.length, prompts }, replacing that with the local array length. If count ever differs from the row count (e.g. pagination metadata), this silently changes the contract for consumers who opt into userIntent=true.

Fix: return { count: base.count, prompts };


/**
Expand Down
39 changes: 38 additions & 1 deletion test/controllers/elements.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});
});
100 changes: 100 additions & 0 deletions test/support/elements/elements-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 }] },
Expand Down
Loading