From 1b39b6fcda783c31e0c26a0064cccda2319cdb89 Mon Sep 17 00:00:00 2001 From: ani hammond Date: Tue, 17 Mar 2026 08:58:21 -0500 Subject: [PATCH 1/2] fix: make model in executions table an enum --- .../brand-presence-weeks-api.md | 9 ++-- .../filter-dimensions-api.md | 9 ++-- ...d-presence-schema-and-v2-config-writeup.md | 2 +- src/controllers/llmo/llmo-brand-presence.js | 52 ++++++++++++++++++- .../llmo/llmo-brand-presence.test.js | 36 +++++++++++-- 5 files changed, 93 insertions(+), 15 deletions(-) diff --git a/docs/llmo-brandalf-apis/brand-presence-weeks-api.md b/docs/llmo-brandalf-apis/brand-presence-weeks-api.md index 306a84f7f..23d6662e1 100644 --- a/docs/llmo-brandalf-apis/brand-presence-weeks-api.md +++ b/docs/llmo-brandalf-apis/brand-presence-weeks-api.md @@ -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 | --- @@ -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:** @@ -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 | diff --git a/docs/llmo-brandalf-apis/filter-dimensions-api.md b/docs/llmo-brandalf-apis/filter-dimensions-api.md index eba419fa6..9c30670df 100644 --- a/docs/llmo-brandalf-apis/filter-dimensions-api.md +++ b/docs/llmo-brandalf-apis/filter-dimensions-api.md @@ -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) | @@ -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®ionCode=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®ionCode=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®ionCode=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®ionCode=US&origin=ai ``` --- @@ -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 | diff --git a/docs/reference/llmo-brand-presence-schema-and-v2-config-writeup.md b/docs/reference/llmo-brand-presence-schema-and-v2-config-writeup.md index bb295847d..872837e60 100644 --- a/docs/reference/llmo-brand-presence-schema-and-v2-config-writeup.md +++ b/docs/reference/llmo-brand-presence-schema-and-v2-config-writeup.md @@ -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). | diff --git a/src/controllers/llmo/llmo-brand-presence.js b/src/controllers/llmo/llmo-brand-presence.js index 50525b004..cda499c9b 100644 --- a/src/controllers/llmo/llmo-brand-presence.js +++ b/src/controllers/llmo/llmo-brand-presence.js @@ -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; @@ -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 ?? '' }; @@ -109,7 +147,7 @@ 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 model = params.model || DEFAULT_MODEL; const { siteId, categoryId, topicId, regionCode, origin, } = params; @@ -335,7 +373,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; @@ -388,6 +430,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; diff --git a/test/controllers/llmo/llmo-brand-presence.test.js b/test/controllers/llmo/llmo-brand-presence.test.js index c8b693e6c..23ff4c007 100644 --- a/test/controllers/llmo/llmo-brand-presence.test.js +++ b/test/controllers/llmo/llmo-brand-presence.test.js @@ -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 }; @@ -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 () => { From 6424e87c5eebf49f5f27c513868fc3979d973863 Mon Sep 17 00:00:00 2001 From: ani hammond Date: Tue, 17 Mar 2026 09:06:58 -0500 Subject: [PATCH 2/2] test --- src/controllers/llmo/llmo-brand-presence.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/controllers/llmo/llmo-brand-presence.js b/src/controllers/llmo/llmo-brand-presence.js index cda499c9b..6438c3c19 100644 --- a/src/controllers/llmo/llmo-brand-presence.js +++ b/src/controllers/llmo/llmo-brand-presence.js @@ -147,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 || DEFAULT_MODEL; const { - siteId, categoryId, topicId, regionCode, origin, + model, siteId, categoryId, topicId, regionCode, origin, } = params; let q = client