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
9 changes: 5 additions & 4 deletions docs/llmo-brandalf-apis/brand-presence-weeks-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Returns applicable ISO weeks (YYYY-Wnn) for the given model, optionally filtered

| Parameter | Aliases | Type | Default | Description |
|-----------|---------|------|---------|-------------|
| `model` | — | string | `chatgpt` | LLM model (e.g. openai, chatgpt, gemini, copilot) |
| `model` | — | enum | `chatgpt-free` | LLM model. Must be one of: `chatgpt-paid`, `chatgpt-free`, `google-ai-overview`, `perplexity`, `google-ai-mode`, `copilot`, `gemini`, `google`, `microsoft`, `mistral`, `anthropic`, `amazon`. Returns 400 if invalid. |
| `siteId` | `site_id` | string (UUID) | — | Filter weeks to a specific site |

---
Expand Down Expand Up @@ -101,14 +101,14 @@ Each item has `week` (ISO week string YYYY-Wnn), `startDate` (Monday), and `endD

## Sample URLs

**All brands, default model (chatgpt):**
**All brands, default model (chatgpt-free):**
```
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/weeks
```

**Single brand, openai model:**
**Single brand, chatgpt-paid model:**
```
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/019cb903-1184-7f92-8325-f9d1176af316/brand-presence/weeks?model=openai
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/019cb903-1184-7f92-8325-f9d1176af316/brand-presence/weeks?model=chatgpt-paid
```

**Weeks for a specific site:**
Expand All @@ -130,6 +130,7 @@ GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/weeks?mo
| Status | Condition |
|--------|-----------|
| 400 | PostgREST not configured (DATA_SERVICE_PROVIDER ≠ postgres) |
| 400 | Invalid `model` query parameter (not in llm_model enum) |
| 400 | Organization not found |
| 400 | PostgREST/PostgreSQL query error |
| 403 | User does not belong to the organization |
Expand Down
9 changes: 5 additions & 4 deletions docs/llmo-brandalf-apis/filter-dimensions-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Returns available filter options (brands, categories, topics, origins, regions,
|-----------|---------|------|---------|-------------|
| `startDate` | `start_date` | string (YYYY-MM-DD) | 28 days ago | Start of date range |
| `endDate` | `end_date` | string (YYYY-MM-DD) | today | End of date range |
| `model` | — | string | `chatgpt` | LLM model (e.g. chatgpt, gemini, copilot) |
| `model` | — | enum | `chatgpt-free` | LLM model. Must be one of: `chatgpt-paid`, `chatgpt-free`, `google-ai-overview`, `perplexity`, `google-ai-mode`, `copilot`, `gemini`, `google`, `microsoft`, `mistral`, `anthropic`, `amazon`. Returns 400 if invalid. |
| `siteId` | `site_id` | string (UUID) | — | Filter by site |
| `categoryId` | `category_id` | string (UUID or name) | — | Filter by category. If UUID → `category_id`; if not UUID (e.g. "Acrobat") → `category_name` |
| `topicId` | `topic_id`, `topic`, `topics` | string | — | Filter by topic (exact match on `topics` column) |
Expand All @@ -42,19 +42,19 @@ Returns available filter options (brands, categories, topics, origins, regions,
|-----------|---------|
| `startDate` | 28 days before today |
| `endDate` | Today |
| `model` | `chatgpt` |
| `model` | `chatgpt-free` |

---

## Sample URL (All Parameters)

```
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/filter-dimensions?startDate=2025-09-27&endDate=2025-09-30&model=google-ai-mode&siteId=c2473d89-e997-458d-a86d-b4096649c12b&categoryId=Acrobat&topicId=combine%20pdf&regionCode=US&origin=AI
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/filter-dimensions?startDate=2025-09-27&endDate=2025-09-30&model=google-ai-mode&siteId=c2473d89-e997-458d-a86d-b4096649c12b&categoryId=Acrobat&topicId=combine%20pdf&regionCode=US&origin=ai
```

**Single brand variant:**
```
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/019cb903-1184-7f92-8325-f9d1176af316/brand-presence/filter-dimensions?startDate=2025-09-27&endDate=2025-09-30&model=chatgpt&siteId=c2473d89-e997-458d-a86d-b4096649c12b&categoryId=Acrobat&topicId=combine%20pdf&regionCode=US&origin=AI
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/019cb903-1184-7f92-8325-f9d1176af316/brand-presence/filter-dimensions?startDate=2025-09-27&endDate=2025-09-30&model=chatgpt-free&siteId=c2473d89-e997-458d-a86d-b4096649c12b&categoryId=Acrobat&topicId=combine%20pdf&regionCode=US&origin=ai
```

---
Expand Down Expand Up @@ -164,6 +164,7 @@ client.from('page_intents').select('page_intent').eq('site_id', siteId).limit(50
| Status | Condition |
|--------|-----------|
| 400 | PostgREST not configured (DATA_SERVICE_PROVIDER ≠ postgres) |
| 400 | Invalid `model` query parameter (not in llm_model enum) |
| 400 | Organization not found |
| 400 | PostgREST/PostgreSQL error |
| 403 | User does not belong to the organization |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Partitioned by execution_date. organization_id is set from site on insert (trigg
| id | uuid | no | Default uuid_generate_v7(). PK is (id, execution_date). |
| site_id | uuid | yes | FK sites(id). |
| execution_date | date | yes | Partition key. |
| model | text | yes | e.g. chatgpt, gemini, copilot. |
| model | llm_model | yes | Enum: chatgpt-paid, chatgpt-free, google-ai-overview, perplexity, google-ai-mode, copilot, gemini, google, microsoft, mistral, anthropic, amazon. |
| brand_id | uuid | no | FK brands(id). |
| brand_name | text | yes | Denormalized. |
| category_id | uuid | no | FK categories(id). |
Expand Down
53 changes: 50 additions & 3 deletions src/controllers/llmo/llmo-brand-presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@ import { hasText, isValidUUID } from '@adobe/spacecat-shared-utils';
* spaceCatId = organization_id. brandId = 'all' or UUID.
*/

/**
* LLM model enum values from mysticat-data-service llm_model type.
* Must match db/migrations/*_brand_presence_model_enum.sql exactly.
*/
export const LLM_MODEL_VALUES = Object.freeze([
'chatgpt-paid',
'chatgpt-free',
'google-ai-overview',
'perplexity',
'google-ai-mode',
'copilot',
'gemini',
'google',
'microsoft',
'mistral',
'anthropic',
'amazon',
]);

const LLM_MODEL_SET = new Set(LLM_MODEL_VALUES);
const DEFAULT_MODEL = 'chatgpt-free';

const SKIP_VALUES = new Set(['all', '', undefined, null, '*']);
const IN_FILTER_CHUNK_SIZE = 50;
const QUERY_LIMIT = 5000;
Expand Down Expand Up @@ -75,6 +97,22 @@ function shouldApplyFilter(value) {
return hasText(String(value));
}

/**
* Validates model param against llm_model enum. Returns resolved model or error.
* @param {string} [model] - Raw model from query (optional; defaults to chatgpt-free)
* @returns {{ valid: boolean, model?: string, error?: string }}
*/
function validateModel(model) {
const resolved = hasText(model) ? String(model).trim() : DEFAULT_MODEL;
if (!LLM_MODEL_SET.has(resolved)) {
return {
valid: false,
error: `Invalid model. Must be one of: ${LLM_MODEL_VALUES.join(', ')}`,
};
}
return { valid: true, model: resolved };
}

/** @internal Exported for testing null/undefined fallbacks */
export function toFilterOption(id, label) {
return { id: id ?? '', label: label ?? id ?? '' };
Expand Down Expand Up @@ -109,9 +147,8 @@ function defaultDateRange() {
function buildExecutionsQuery(client, organizationId, params, defaults, filterByBrandId) {
const startDate = params.startDate || defaults.startDate;
const endDate = params.endDate || defaults.endDate;
const model = params.model || 'chatgpt';
const {
siteId, categoryId, topicId, regionCode, origin,
model, siteId, categoryId, topicId, regionCode, origin,
} = params;

let q = client
Expand Down Expand Up @@ -335,7 +372,11 @@ export function createBrandPresenceWeeksHandler(getOrgAndValidateAccess) {
async (ctx, client) => {
const params = parseWeeksParams(ctx);
const { model: modelParam, siteId } = params;
const model = modelParam || 'chatgpt';
const modelValidation = validateModel(modelParam);
if (!modelValidation.valid) {
return badRequest(modelValidation.error);
}
const { model } = modelValidation;
const { spaceCatId, brandId } = ctx.params;
const organizationId = spaceCatId;
const filterByBrandId = brandId && brandId !== 'all' ? brandId : null;
Expand Down Expand Up @@ -388,6 +429,12 @@ export function createFilterDimensionsHandler(getOrgAndValidateAccess) {
async (ctx, client) => {
const { spaceCatId, brandId } = ctx.params;
const params = parseFilterDimensionsParams(ctx);
const modelValidation = validateModel(params.model);
if (!modelValidation.valid) {
return badRequest(modelValidation.error);
}
params.model = modelValidation.model;

const defaults = defaultDateRange();
const organizationId = spaceCatId;
const filterByBrandId = brandId && brandId !== 'all' ? brandId : null;
Expand Down
36 changes: 32 additions & 4 deletions test/controllers/llmo/llmo-brand-presence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,20 @@ describe('llmo-brand-presence', () => {
);
});

it('returns badRequest when model is invalid', async () => {
mockContext.dataAccess.Site.postgrestService = mockClient;
mockContext.data = { model: 'openai' };

const handler = createFilterDimensionsHandler(getOrgAndValidateAccess);
const result = await handler(mockContext);

expect(result.status).to.equal(400);
const body = await result.json();
expect(body.message).to.include('Invalid model');
expect(body.message).to.include('chatgpt-paid');
expect(body.message).to.include('chatgpt-free');
});

it('handles executions query returning data: null (uses empty rows fallback)', async () => {
const emptySites = { data: [], error: null };
const emptyPageIntents = { data: [], error: null };
Expand Down Expand Up @@ -761,25 +775,39 @@ describe('llmo-brand-presence', () => {
});
});

it('defaults model to chatgpt when not provided', async () => {
it('defaults model to chatgpt-free when not provided', async () => {
const chainMock = createChainableMock({ data: [], error: null });
mockContext.dataAccess.Site.postgrestService = chainMock;

const handler = createBrandPresenceWeeksHandler(getOrgAndValidateAccess);
await handler(mockContext);

expect(chainMock.eq).to.have.been.calledWith('model', 'chatgpt');
expect(chainMock.eq).to.have.been.calledWith('model', 'chatgpt-free');
});

it('uses model from query param when provided', async () => {
const chainMock = createChainableMock({ data: [], error: null });
mockContext.data = { model: 'openai' };
mockContext.data = { model: 'gemini' };
mockContext.dataAccess.Site.postgrestService = chainMock;

const handler = createBrandPresenceWeeksHandler(getOrgAndValidateAccess);
await handler(mockContext);

expect(chainMock.eq).to.have.been.calledWith('model', 'openai');
expect(chainMock.eq).to.have.been.calledWith('model', 'gemini');
});

it('returns badRequest when model is invalid', async () => {
mockContext.dataAccess.Site.postgrestService = mockClient;
mockContext.data = { model: 'invalid-model' };

const handler = createBrandPresenceWeeksHandler(getOrgAndValidateAccess);
const result = await handler(mockContext);

expect(result.status).to.equal(400);
const body = await result.json();
expect(body.message).to.include('Invalid model');
expect(body.message).to.include('chatgpt-paid');
expect(body.message).to.include('chatgpt-free');
});

it('filters by brandId when single brand route', async () => {
Expand Down
Loading