From 614bf33913ba1cf3c49c3177fd639b26d1784bc6 Mon Sep 17 00:00:00 2001 From: Sihlanic Alin Date: Wed, 22 Jul 2026 09:49:11 +0300 Subject: [PATCH 1/3] fix(llmo): dedupe brand-presence citation counts by execution_id aggregateDetailSources counted one citation per brand_presence_sources row instead of per distinct execution, so a URL cited multiple times within a single execution's answer inflated citationCount past the number of executions that actually cite it (e.g. 62 owned citations against 1 execution). Carry execution_id through flattenSourceRow and dedupe on it. Co-Authored-By: Claude Sonnet 5 --- src/controllers/llmo/llmo-brand-presence.js | 27 +++++++++-- .../llmo/llmo-brand-presence.test.js | 48 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/controllers/llmo/llmo-brand-presence.js b/src/controllers/llmo/llmo-brand-presence.js index a508f91824..d00efa1cb9 100644 --- a/src/controllers/llmo/llmo-brand-presence.js +++ b/src/controllers/llmo/llmo-brand-presence.js @@ -2611,7 +2611,14 @@ export function aggregateWeeklyDetailStats(rows) { /** * Aggregates source URLs from brand_presence_sources rows joined with source_urls. - * @param {Array} sourceRows - Rows with url, hostname, content_type, execution_date, prompt + * + * Citation/prompt counts are deduplicated by `execution_id`: a single execution can + * have multiple brand_presence_sources rows for the same URL (e.g. one row per inline + * citation marker in that execution's answer), which would otherwise inflate a URL's + * citationCount past the number of executions that actually cite it. Rows without an + * execution_id always count (not expected in practice, but keeps this permissive). + * @param {Array} sourceRows - Rows with url, hostname, content_type, execution_date, + * execution_id, prompt * @returns {Array} Deduplicated source entries * @internal Exported for testing */ @@ -2629,18 +2636,27 @@ export function aggregateDetailSources(sourceRows) { hostname: row.hostname || '', contentType: row.content_type || '', citationCount: 0, + seenExecutionIds: new Set(), weeks: new Set(), prompts: new Map(), }); } const s = sourceMap.get(url); - s.citationCount += 1; + + const executionId = row.execution_id; + const alreadyCountedForExec = Boolean(executionId) && s.seenExecutionIds.has(executionId); + if (!alreadyCountedForExec) { + s.citationCount += 1; + if (executionId) { + s.seenExecutionIds.add(executionId); + } + if (row.prompt) { + s.prompts.set(row.prompt, (s.prompts.get(row.prompt) || 0) + 1); + } + } if (row.execution_date) { s.weeks.add(weekFromExecDate(row.execution_date)); } - if (row.prompt) { - s.prompts.set(row.prompt, (s.prompts.get(row.prompt) || 0) + 1); - } }); return [...sourceMap.values()].map((s) => ({ @@ -2842,6 +2858,7 @@ function flattenSourceRow(srcRow, execMap) { hostname: su.hostname || '', content_type: srcRow.content_type || '', execution_date: srcRow.execution_date || '', + execution_id: srcRow.execution_id || '', prompt: exec?.prompt || '', }; } diff --git a/test/controllers/llmo/llmo-brand-presence.test.js b/test/controllers/llmo/llmo-brand-presence.test.js index ea8271d6c3..4d251b1f92 100644 --- a/test/controllers/llmo/llmo-brand-presence.test.js +++ b/test/controllers/llmo/llmo-brand-presence.test.js @@ -6572,6 +6572,54 @@ describe('llmo-brand-presence', () => { ]; expect(aggregateDetailSources(rows)).to.have.lengthOf(2); }); + + it('does not double-count citations from multiple rows sharing the same execution_id', () => { + // A single execution can produce more than one brand_presence_sources row for + // the same URL (e.g. one row per inline citation marker in the answer) — + // citationCount must reflect distinct executions, not raw row count. + const rows = [ + { + url: 'https://a.com', + hostname: 'a.com', + content_type: 'web', + execution_date: '2026-03-02', + execution_id: 'exec-1', + prompt: 'q1', + }, + { + url: 'https://a.com', + hostname: 'a.com', + content_type: 'web', + execution_date: '2026-03-02', + execution_id: 'exec-1', + prompt: 'q1', + }, + { + url: 'https://a.com', + hostname: 'a.com', + content_type: 'web', + execution_date: '2026-03-02', + execution_id: 'exec-1', + prompt: 'q1', + }, + ]; + const [entry] = aggregateDetailSources(rows); + expect(entry.citationCount).to.equal(1); + expect(entry.prompts).to.deep.equal([{ prompt: 'q1', count: 1 }]); + }); + + it('counts citations once per distinct execution_id', () => { + const rows = [ + { + url: 'https://a.com', hostname: 'a.com', content_type: 'web', execution_date: '2026-03-02', execution_id: 'exec-1', prompt: 'q1', + }, + { + url: 'https://a.com', hostname: 'a.com', content_type: 'web', execution_date: '2026-03-09', execution_id: 'exec-2', prompt: 'q1', + }, + ]; + const [entry] = aggregateDetailSources(rows); + expect(entry.citationCount).to.equal(2); + }); }); // ── createTopicDetailHandler ──────────────────────────────────────────────── From f71fb95d7d00a2707dcdcffc06e67479fe059838 Mon Sep 17 00:00:00 2001 From: Sihlanic Alin Date: Wed, 22 Jul 2026 10:49:34 +0300 Subject: [PATCH 2/3] test(llmo): assert prompts count and cover missing execution_id in citation dedup tests Addresses PR review feedback: lock in the prompts-count assertion for the distinct-execution-id case, and add explicit coverage for the "no execution_id" fallback path (each row counts independently). Co-Authored-By: Claude Sonnet 5 --- .../llmo/llmo-brand-presence.test.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/controllers/llmo/llmo-brand-presence.test.js b/test/controllers/llmo/llmo-brand-presence.test.js index 4d251b1f92..d1d7673bd7 100644 --- a/test/controllers/llmo/llmo-brand-presence.test.js +++ b/test/controllers/llmo/llmo-brand-presence.test.js @@ -6619,6 +6619,26 @@ describe('llmo-brand-presence', () => { ]; const [entry] = aggregateDetailSources(rows); expect(entry.citationCount).to.equal(2); + expect(entry.prompts).to.deep.equal([{ prompt: 'q1', count: 2 }]); + }); + + it('counts each row independently when execution_id is missing', () => { + // Rows without an execution_id (not expected in practice) always count, + // since there's no id to dedupe against. + const rows = [ + { + url: 'https://a.com', hostname: 'a.com', content_type: 'web', execution_date: '2026-03-02', prompt: 'q1', + }, + { + url: 'https://a.com', hostname: 'a.com', content_type: 'web', execution_date: '2026-03-02', prompt: 'q1', + }, + { + url: 'https://a.com', hostname: 'a.com', content_type: 'web', execution_date: '2026-03-02', execution_id: null, prompt: 'q1', + }, + ]; + const [entry] = aggregateDetailSources(rows); + expect(entry.citationCount).to.equal(3); + expect(entry.prompts).to.deep.equal([{ prompt: 'q1', count: 3 }]); }); }); From 0e4a86f0d5293f47f1e6f91322e52974aa42b843 Mon Sep 17 00:00:00 2001 From: Sihlanic Alin Date: Wed, 22 Jul 2026 11:24:47 +0300 Subject: [PATCH 3/3] test(llmo): cover duplicate-source-row dedup in the full handler test Extends the existing fetchSourcesForExecutions handler test (mocked DB, no real Postgres needed) with a second brand_presence_sources row for the same execution_id + URL, proving the dedup fix holds through the full request/response path, not just the isolated aggregateDetailSources unit. Confirmed this fails against the pre-fix code (citationCount: 2) and passes with the fix (citationCount: 1). Co-Authored-By: Claude Sonnet 5 --- test/controllers/llmo/llmo-brand-presence.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/controllers/llmo/llmo-brand-presence.test.js b/test/controllers/llmo/llmo-brand-presence.test.js index d1d7673bd7..7f13703dbf 100644 --- a/test/controllers/llmo/llmo-brand-presence.test.js +++ b/test/controllers/llmo/llmo-brand-presence.test.js @@ -6955,6 +6955,15 @@ describe('llmo-brand-presence', () => { url_id: 'u1', source_urls: { url: 'https://example.com', hostname: 'example.com' }, }, + // Same execution + same URL as a second brand_presence_sources row (e.g. the + // AI answer cited example.com twice) — must NOT double-count citationCount. + { + execution_id: 'exec-1', + execution_date: '2026-03-02', + content_type: 'web', + url_id: 'u1-dup', + source_urls: { url: 'https://example.com', hostname: 'example.com' }, + }, // null source_urls exercises the || {} fallback in flattenSourceRow { execution_id: 'exec-1', @@ -6987,6 +6996,8 @@ describe('llmo-brand-presence', () => { expect(body.sources).to.have.lengthOf(2); const exampleSource = body.sources.find((s) => s.url === 'https://example.com'); expect(exampleSource).to.exist; + // Only 1 execution (exec-1) exists for this topic, so citationCount must stay 1 + // even though two brand_presence_sources rows reference it for that execution. expect(exampleSource.citationCount).to.equal(1); });