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
27 changes: 22 additions & 5 deletions src/controllers/llmo/llmo-brand-presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -2611,7 +2611,14 @@ export function aggregateWeeklyDetailStats(rows) {

/**
* Aggregates source URLs from brand_presence_sources rows joined with source_urls.
* @param {Array<Object>} 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<Object>} sourceRows - Rows with url, hostname, content_type, execution_date,
* execution_id, prompt
* @returns {Array<Object>} Deduplicated source entries
* @internal Exported for testing
*/
Expand All @@ -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) => ({
Expand Down Expand Up @@ -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 || '',
};
}
Expand Down
79 changes: 79 additions & 0 deletions test/controllers/llmo/llmo-brand-presence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6572,6 +6572,74 @@ 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);
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 }]);
});
});

// ── createTopicDetailHandler ────────────────────────────────────────────────
Expand Down Expand Up @@ -6887,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',
Expand Down Expand Up @@ -6919,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);
});

Expand Down
Loading