Skip to content
Merged
70 changes: 66 additions & 4 deletions docs/index.html

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ paths:
$ref: './serenity-api.yaml#/v2-serenity-deactivate'
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/url-inspector/filter-dimensions:
$ref: './serenity-api.yaml#/v2-serenity-url-inspector-filter-dimensions'
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/sentiment-overview:
$ref: './serenity-api.yaml#/v2-serenity-brand-presence-sentiment-overview'
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/stats:
$ref: './serenity-api.yaml#/v2-serenity-brand-presence-stats'
/organizations/{organizationId}/sites:
Expand Down
73 changes: 73 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11554,6 +11554,79 @@ SerenityErrorResponse:
error: { type: string }
message: { type: string }

SerenityBrandPresenceSentimentOverview:
type: object
description: |
Per-week brand sentiment for the Serenity (Semrush Elements-backed) brand
presence dashboard, sourced from the Semrush Sentiment element and rolled up
from daily rows into ISO weeks. Emits the legacy `sentiment-overview` contract
verbatim so the existing chart consumes it drop-in — the absolute values
differ from the Postgres-backed legacy endpoint (Semrush metrics), but the
shape, `sentiment[]` ordering, and colors are identical.
required: [weeklyTrends]
properties:
weeklyTrends:
type: array
description: One entry per ISO week in the requested range, ordered oldest-first.
items:
type: object
required:
- week
- weekNumber
- year
- sentiment
- totalPrompts
- promptsWithSentiment
properties:
week:
type: string
description: ISO week label (`YYYY-Wnn`).
example: '2026-W24'
weekNumber: { type: integer, example: 24 }
year: { type: integer, example: 2026 }
sentiment:
type: array
description: |
Positive/Neutral/Negative percentages (integers summing to 100),
in that fixed order. `color` is the legacy swatch hex.
items:
type: object
required: [name, value, color]
properties:
name:
type: string
enum: [Positive, Neutral, Negative]
value:
type: integer
description: Percentage of prompts-with-sentiment in this bucket (0-100).
color:
type: string
description: Legacy sentiment swatch hex.
totalPrompts:
type: integer
description: |
Total prompts for the week (from the element's total-prompts line).
promptsWithSentiment:
type: integer
description: |
Positive + Neutral + Negative prompt counts. NOTE: the three
sentiment buckets are overlapping sets (a prompt with mixed-sentiment
mentions counts in each), so this can exceed `totalPrompts`; do not
derive a coverage ratio from the two.
mentions:
type: integer
description: Stubbed 0 (parity with the legacy contract).
citations:
type: integer
description: Stubbed 0 (parity with the legacy contract).
visibilityScore:
type: integer
description: Stubbed 0 (parity with the legacy contract).
competitors:
type: array
description: Stubbed empty (parity with the legacy contract).
items: { type: object }

SerenityFilterDimensionItem:
type: object
description: |
Expand Down
113 changes: 113 additions & 0 deletions docs/openapi/serenity-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,119 @@ v2-serenity-url-inspector-filter-dimensions:
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
'500': { $ref: './responses.yaml#/500' }

v2-serenity-brand-presence-sentiment-overview:
parameters:
- name: spaceCatId
in: path
required: true
description: SpaceCat Organization ID (UUID)
schema:
type: string
format: uuid
- name: brandId
in: path
required: true
description: Brand ID (UUID-only on the /serenity/* surface)
schema:
type: string
format: uuid
get:
tags: [serenity]
summary: Per-week brand sentiment for the Serenity brand presence dashboard
description: |
Returns per-week brand sentiment (positive/neutral/negative percentages)
sourced from the Semrush Sentiment element (not Adobe Postgres), rolled up
from daily rows into ISO weeks. Emits the legacy `sentiment-overview`
contract verbatim so the existing chart consumes it drop-in — absolute
values differ from the Postgres-backed legacy endpoint, but the shape and
`sentiment[]` ordering/colors are identical.

Brand-scoped via the brand's Semrush sub-workspace (resolved from `:brandId`);
`region` (when not `all`) is resolved to a Semrush `project_id` server-side,
`category` becomes a `category__<label>` tag, and `model`/`platform` are
translated to a Semrush model server-side.
operationId: listSerenityBrandPresenceSentimentOverview
security:
- ims_key: []
parameters:
- name: startDate
in: query
required: true
description: Inclusive range start (YYYY-MM-DD). Range must not exceed 366 days.
schema: { type: string, format: date }
- name: endDate
in: query
required: true
description: Inclusive range end (YYYY-MM-DD). Range must not exceed 366 days.
schema: { type: string, format: date }
- name: model
in: query
required: false
description: |
AI model to scope sentiment to. Accepts a Semrush Elements model name
directly, or a SpaceCat/UI platform code (translated server-side).
Defaults to `search-gpt` when omitted or unrecognized.
schema:
type: string
x-canonical-values:
- google-ai-mode
- grok-3
- google-ai-overview
- microsoft-copilot
- open-evidence
- gemini-2.5-flash
- claude-sonnet-4
- gpt-5
- deepseek
- search-gpt
- perplexity
x-known-aliases:
copilot: microsoft-copilot
gemini: gemini-2.5-flash
openai: gpt-5
chatgpt: search-gpt
- name: platform
in: query
required: false
description: Legacy alias for `model`; `model` takes precedence when both are given.
schema: { type: string }
- name: region
in: query
required: false
description: |
UI region code (e.g. `US`) resolved server-side to a Semrush
`project_id`. `all` or omitted → aggregate across the brand's markets.
schema: { type: string }
- name: categoryId
in: query
required: false
description: |
Category label sent to Semrush as a `category__<label>` tag. `category`
is accepted as an alias.
schema: { type: string }
responses:
'200':
description: Weekly sentiment retrieved.
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityBrandPresenceSentimentOverview' }
'400': { $ref: './responses.yaml#/400' }
'403':
description: Caller lacks access to the organization.
'404':
description: Organization not found, brand not found for this organization, the brand has no resolvable Semrush workspace, or no Semrush market resolves for the requested region.
'502':
description: Upstream returned a non-2xx response.
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
'503':
description: PostgREST client not available (required to resolve the brand's Semrush workspace before the element call).
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
'500': { $ref: './responses.yaml#/500' }

v2-serenity-brand-presence-stats:
parameters:
- name: spaceCatId
Expand Down
78 changes: 78 additions & 0 deletions src/controllers/elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,83 @@ export default function ElementsController(context, log, env) {
};
/* c8 ignore stop */

/**
* GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/sentiment-overview
* Returns per-week brand sentiment (positive/neutral/negative percentages) sourced from
* the Semrush Sentiment element, in the legacy `{ weeklyTrends: [...] }` contract so the
* existing brand-presence sentiment chart consumes it drop-in. Single upstream call
* (aggregate, no fan-out); region → top-level project_id like cited-domains.
*/
/* c8 ignore start -- LLMO-6300 POC endpoint; unit tests intentionally deferred */
const listSentimentOverview = async (ctx) => {
try {
const auth = await authorizeOrg(ctx);
if (auth.error) {
return auth.error;
}
const { brandId } = ctx?.params ?? {};
const { workspaceId, brand } = auth;
const query = extractQuery(ctx);

// Date range is required + validated (mirrors cited-domains) — never silently
// default to a rolling window nor forward a malformed date to Semrush.
const startDate = query.startDate || query.start_date;
const endDate = query.endDate || query.end_date;
if (!hasText(startDate) || !hasText(endDate)) {
return badRequest('startDate and endDate are required (YYYY-MM-DD)');
}
if (!isYmdDate(startDate) || !isYmdDate(endDate)) {
return badRequest('startDate and endDate must be valid YYYY-MM-DD dates');
}
if (startDate > endDate) {
return badRequest('startDate must not be after endDate');
}
// Bound the span (mirrors listOwnedUrls/listDomainUrls): a multi-year range is
// needlessly expensive upstream and inflates the in-memory weekly rollup.
const MAX_RANGE_DAYS = 366;
const spanDays = (Date.parse(`${endDate}T00:00:00Z`)
- Date.parse(`${startDate}T00:00:00Z`)) / 86400000;
if (spanDays > MAX_RANGE_DAYS) {
return badRequest(`Date range must not exceed ${MAX_RANGE_DAYS} days`);
}

const service = await buildService(ctx);

// Region scoping: a Semrush project == one (brand, market). Resolve the UI's region
// code to that project's id (via the Markets element) and pass it as top-level
// `project_id`. region=all/absent → aggregate across all the brand's markets.
let projectId;
const { region } = query;
if (hasText(region) && region.toLowerCase() !== 'all') {
const { BrandSemrushProject } = ctx?.dataAccess ?? {};
const brandSemrushProjects = await fetchBrandSemrushProjects(
BrandSemrushProject,
[brand],
);
projectId = await service.resolveRegionProjectId(workspaceId, {
brandId, region, brandSemrushProjects,
});
if (!hasText(projectId)) {
return notFound(`No Semrush market found for region: ${region}`);
}
}

const params = {
projectId,
model: query.model || query.platform,
startDate,
endDate,
category: query.categoryId || query.category,
};

const result = await service.getSentimentOverview(workspaceId, params);
return cachedOk(result);
} catch (e) {
return mapError(e, log);
}
};
/* c8 ignore stop */

/**
* GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence
* /url-inspector/owned-urls
Expand Down Expand Up @@ -881,6 +958,7 @@ export default function ElementsController(context, log, env) {
listWeeks,
listPrompts,
listCitedDomains,
listSentimentOverview,
listOwnedUrls,
listDomainUrls,
getStats,
Expand Down
1 change: 1 addition & 0 deletions src/routes/facs-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,7 @@ const routeFacsCapabilities = {
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/weeks': 'llmo/can_view',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/prompts': 'llmo/can_view',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': 'llmo/can_view',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/sentiment-overview': 'llmo/can_view',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': 'llmo/can_view',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': 'llmo/can_view',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats': 'llmo/can_view',
Expand Down
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export default function getRouteHandlers(
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/prompts': elementsController.listPrompts,
// eslint-disable-next-line max-len
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': elementsController.listCitedDomains,
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/sentiment-overview': elementsController.listSentimentOverview,
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': elementsController.listOwnedUrls,
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': elementsController.listDomainUrls,
// eslint-disable-next-line max-len
Expand Down
1 change: 1 addition & 0 deletions src/routes/required-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ const routeRequiredCapabilities = {
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/weeks': 'organization:read',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/prompts': 'organization:read',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': 'brand:read',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/sentiment-overview': 'brand:read',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': 'brand:read',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': 'brand:read',
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats': 'brand:read',
Expand Down
4 changes: 4 additions & 0 deletions src/support/elements/definitions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export {
export { buildWeeksPayload, transformWeeksResponse } from './weeks.js';
export { buildPromptsPayload, transformPromptsResponse } from './prompts.js';
export { buildCitedDomainsPayload, transformCitedDomainsResponse } from './cited-domains.js';
export {
buildSentimentOverviewPayload,
transformSentimentOverviewResponse,
} from './sentiment-overview.js';
export {
buildOwnedUrlsStatsPayload,
buildOwnedUrlsTrendPayload,
Expand Down
Loading
Loading