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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "canonry",
"private": true,
"version": "4.83.0",
"version": "4.84.0",
"type": "module",
"packageManager": "pnpm@10.28.2",
"scripts": {
Expand Down
16 changes: 15 additions & 1 deletion packages/api-routes/src/composites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,14 +872,28 @@ function mapInsightRow(r: typeof insights.$inferSelect): InsightDto {
}

function mapHealthRow(r: typeof healthSnapshots.$inferSelect): HealthSnapshotDto {
// Coalesce legacy provider entries (written before v80, no mention keys) to 0.
const providerBreakdown: HealthSnapshotDto['providerBreakdown'] = {}
for (const [provider, entry] of Object.entries(r.providerBreakdown)) {
providerBreakdown[provider] = {
citedRate: entry.citedRate,
mentionRate: entry.mentionRate ?? 0,
cited: entry.cited,
mentioned: entry.mentioned ?? 0,
total: entry.total,
}
}
return {
id: r.id,
projectId: r.projectId,
runId: r.runId ?? null,
overallCitedRate: Number(r.overallCitedRate),
// Legacy rows (persisted before v80) have NULL mention columns → 0.
overallMentionRate: r.overallMentionRate == null ? 0 : Number(r.overallMentionRate),
totalPairs: r.totalPairs,
citedPairs: r.citedPairs,
providerBreakdown: r.providerBreakdown,
mentionedPairs: r.mentionedPairs ?? 0,
providerBreakdown,
createdAt: r.createdAt,
status: 'ready',
}
Expand Down
45 changes: 41 additions & 4 deletions packages/api-routes/src/intelligence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ function emptyHealthSnapshot(projectId: string): HealthSnapshotDto {
projectId,
runId: null,
overallCitedRate: 0,
overallMentionRate: 0,
totalPairs: 0,
citedPairs: 0,
mentionedPairs: 0,
providerBreakdown: {},
createdAt: '',
status: 'no-data',
Expand All @@ -36,15 +38,40 @@ function mapInsightRow(r: typeof insights.$inferSelect): InsightDto {
}
}

/**
* Coalesce a persisted providerBreakdown into the current DTO shape. Rows
* written before the mention columns existed have entries with no
* `mentionRate` / `mentioned` keys — fill them with 0 so the contract field
* is always present. Cited fields pass through untouched.
*/
function coalesceProviderBreakdown(
breakdown: Record<string, { citedRate: number; mentionRate?: number; cited: number; mentioned?: number; total: number }>,
): HealthSnapshotDto['providerBreakdown'] {
const out: HealthSnapshotDto['providerBreakdown'] = {}
for (const [provider, entry] of Object.entries(breakdown)) {
out[provider] = {
citedRate: entry.citedRate,
mentionRate: entry.mentionRate ?? 0,
cited: entry.cited,
mentioned: entry.mentioned ?? 0,
total: entry.total,
}
}
return out
}

function mapHealthRow(r: typeof healthSnapshots.$inferSelect): HealthSnapshotDto {
return {
id: r.id,
projectId: r.projectId,
runId: r.runId ?? null,
overallCitedRate: Number(r.overallCitedRate),
// Legacy rows (persisted before v80) have NULL mention columns → 0.
overallMentionRate: r.overallMentionRate == null ? 0 : Number(r.overallMentionRate),
totalPairs: r.totalPairs,
citedPairs: r.citedPairs,
providerBreakdown: r.providerBreakdown,
mentionedPairs: r.mentionedPairs ?? 0,
providerBreakdown: coalesceProviderBreakdown(r.providerBreakdown),
createdAt: r.createdAt,
status: 'ready',
}
Expand All @@ -69,28 +96,36 @@ function aggregateHealthSnapshots(

let totalPairs = 0
let citedPairs = 0
const mergedProviders: Record<string, { total: number; cited: number; citedRate: number }> = {}
let mentionedPairs = 0
const mergedProviders: Record<string, { total: number; cited: number; mentioned: number; citedRate: number; mentionRate: number }> = {}
let newestCreatedAt = ''
const runIds: string[] = []

for (const row of rows) {
totalPairs += row.totalPairs
citedPairs += row.citedPairs
// Legacy rows (pre-v80) have NULL mention columns → contribute 0 to the
// numerator. Cited and mention are merged identically but independently.
mentionedPairs += row.mentionedPairs ?? 0
if (row.createdAt > newestCreatedAt) newestCreatedAt = row.createdAt
if (row.runId) runIds.push(row.runId)
const providerBreakdown = row.providerBreakdown
for (const [provider, entry] of Object.entries(providerBreakdown)) {
const existing = mergedProviders[provider] ?? { total: 0, cited: 0, citedRate: 0 }
const existing = mergedProviders[provider] ?? { total: 0, cited: 0, mentioned: 0, citedRate: 0, mentionRate: 0 }
existing.total += entry.total
existing.cited += entry.cited
existing.mentioned += entry.mentioned ?? 0
mergedProviders[provider] = existing
}
}
// Compute per-provider rates after summing.
// Compute per-provider rates after summing. Cited and mention are computed
// separately — neither is derived from the other.
for (const entry of Object.values(mergedProviders)) {
entry.citedRate = entry.total > 0 ? entry.cited / entry.total : 0
entry.mentionRate = entry.total > 0 ? entry.mentioned / entry.total : 0
}
const overallCitedRate = totalPairs > 0 ? citedPairs / totalPairs : 0
const overallMentionRate = totalPairs > 0 ? mentionedPairs / totalPairs : 0

return {
// Synthetic id so consumers can tell this is an aggregate; concatenate
Expand All @@ -99,8 +134,10 @@ function aggregateHealthSnapshots(
projectId,
runId: runIds[0] ?? null,
overallCitedRate,
overallMentionRate,
totalPairs,
citedPairs,
mentionedPairs,
providerBreakdown: mergedProviders,
createdAt: newestCreatedAt,
status: 'ready',
Expand Down
63 changes: 56 additions & 7 deletions packages/api-routes/test/health-latest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ test('returns 200 with no-data sentinel when no health snapshot exists', async (
projectId,
runId: null,
overallCitedRate: 0,
overallMentionRate: 0,
totalPairs: 0,
citedPairs: 0,
mentionedPairs: 0,
providerBreakdown: {},
createdAt: '',
status: 'no-data',
Expand All @@ -71,9 +73,11 @@ test('returns 200 with status:"ready" when a snapshot exists', async () => {
projectId,
runId: null,
overallCitedRate: 0.42,
overallMentionRate: 0.3,
totalPairs: 10,
citedPairs: 4,
providerBreakdown: { gemini: { citedRate: 0.5, cited: 5, total: 10 } },
mentionedPairs: 3,
providerBreakdown: { gemini: { citedRate: 0.5, mentionRate: 0.3, cited: 5, mentioned: 3, total: 10 } },
createdAt: '2026-04-27T00:00:00Z',
}).run()
await ctx.app.ready()
Expand All @@ -86,7 +90,42 @@ test('returns 200 with status:"ready" when a snapshot exists', async () => {
expect(body.overallCitedRate).toBe(0.42)
expect(body.citedPairs).toBe(4)
expect(body.totalPairs).toBe(10)
expect(body.providerBreakdown).toEqual({ gemini: { citedRate: 0.5, cited: 5, total: 10 } })
// Mention is surfaced alongside cited, never in place of it.
expect(body.overallMentionRate).toBe(0.3)
expect(body.mentionedPairs).toBe(3)
expect(body.providerBreakdown).toEqual({ gemini: { citedRate: 0.5, mentionRate: 0.3, cited: 5, mentioned: 3, total: 10 } })
})

test('coalesces a legacy row with NULL mention columns to 0 instead of crashing', async () => {
const projectId = insertProject(ctx.db, 'legacy-row')
// Simulate a row persisted before the v80 mention migration: the mention
// columns are NULL and the providerBreakdown JSON has no mention keys.
ctx.db.insert(healthSnapshots).values({
id: 'legacy-1',
projectId,
runId: null,
overallCitedRate: 0.6,
overallMentionRate: null,
totalPairs: 10,
citedPairs: 6,
mentionedPairs: null,
// Cast: this is intentionally the OLD JSON shape with no mention keys.
providerBreakdown: { gemini: { citedRate: 0.6, cited: 6, total: 10 } } as never,
createdAt: '2026-04-20T00:00:00Z',
}).run()
await ctx.app.ready()

const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/projects/legacy-row/health/latest' })
expect(res.statusCode).toBe(200)
const body = JSON.parse(res.body) as HealthSnapshotDto
expect(body.status).toBe('ready')
// Cited fields read through unchanged.
expect(body.overallCitedRate).toBe(0.6)
expect(body.citedPairs).toBe(6)
// Missing mention data reads back as 0 (NULL→0), not NaN/null/undefined.
expect(body.overallMentionRate).toBe(0)
expect(body.mentionedPairs).toBe(0)
expect(body.providerBreakdown.gemini).toEqual({ citedRate: 0.6, mentionRate: 0, cited: 6, mentioned: 0, total: 10 })
})

test('still returns 404 when the project itself does not exist', async () => {
Expand All @@ -109,27 +148,31 @@ test('aggregates healthSnapshots across the latest fan-out group when a multi-lo
{ id: miRunId, projectId, kind: 'answer-visibility', status: 'completed', trigger: 'manual', location: 'michigan', createdAt, finishedAt: createdAt },
]).run()

// florida: 6 of 10 pairs cited. michigan: 2 of 10 pairs cited.
// Project-level aggregate: 8 of 20 (40%).
// florida: 6 of 10 pairs cited, 4 mentioned. michigan: 2 of 10 cited, 1 mentioned.
// Project-level aggregate: cited 8/20 (40%), mentioned 5/20 (25%).
ctx.db.insert(healthSnapshots).values([
{
id: 'snap-fl',
projectId,
runId: flRunId,
overallCitedRate: '0.6',
overallMentionRate: '0.4',
totalPairs: 10,
citedPairs: 6,
providerBreakdown: { gemini: { citedRate: 0.6, cited: 6, total: 10 } },
mentionedPairs: 4,
providerBreakdown: { gemini: { citedRate: 0.6, mentionRate: 0.4, cited: 6, mentioned: 4, total: 10 } },
createdAt,
},
{
id: 'snap-mi',
projectId,
runId: miRunId,
overallCitedRate: '0.2',
overallMentionRate: '0.1',
totalPairs: 10,
citedPairs: 2,
providerBreakdown: { gemini: { citedRate: 0.2, cited: 2, total: 10 } },
mentionedPairs: 1,
providerBreakdown: { gemini: { citedRate: 0.2, mentionRate: 0.1, cited: 2, mentioned: 1, total: 10 } },
createdAt,
},
]).run()
Expand All @@ -144,10 +187,16 @@ test('aggregates healthSnapshots across the latest fan-out group when a multi-lo
expect(body.citedPairs).toBe(8)
expect(body.overallCitedRate).toBeCloseTo(0.4, 5)

// Per-provider breakdown also aggregated.
// Mention sums independently of cited: 4 + 1 = 5 over 20 = 25%.
expect(body.mentionedPairs).toBe(5)
expect(body.overallMentionRate).toBeCloseTo(0.25, 5)

// Per-provider breakdown also aggregated — cited AND mention merged.
expect(body.providerBreakdown.gemini?.total).toBe(20)
expect(body.providerBreakdown.gemini?.cited).toBe(8)
expect(body.providerBreakdown.gemini?.citedRate).toBeCloseTo(0.4, 5)
expect(body.providerBreakdown.gemini?.mentioned).toBe(5)
expect(body.providerBreakdown.gemini?.mentionRate).toBeCloseTo(0.25, 5)

// Synthesized id signals this is a group aggregate.
expect(body.id).toMatch(/^group:/)
Expand Down
2 changes: 1 addition & 1 deletion packages/canonry/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ainyc/canonry",
"version": "4.83.0",
"version": "4.84.0",
"type": "module",
"description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain",
"license": "FSL-1.1-ALv2",
Expand Down
26 changes: 17 additions & 9 deletions packages/canonry/src/commands/health-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@ export async function showHealth(
return
}

console.log('Date Cited Rate Cited/Total')
console.log('─'.repeat(55))
// Mention leads, cited second — both signals are independent and shown
// side by side (never one in place of the other).
console.log('Date Mention Rate Mentioned/Total Cited Rate Cited/Total')
console.log('─'.repeat(86))
for (const snap of snapshots) {
const rate = (snap.overallCitedRate * 100).toFixed(1).padStart(5) + '%'
const ratio = `${snap.citedPairs}/${snap.totalPairs}`
const mRate = (snap.overallMentionRate * 100).toFixed(1).padStart(5) + '%'
const mRatio = `${snap.mentionedPairs}/${snap.totalPairs}`.padEnd(15)
const cRate = (snap.overallCitedRate * 100).toFixed(1).padStart(5) + '%'
const cRatio = `${snap.citedPairs}/${snap.totalPairs}`
const date = snap.createdAt.slice(0, 19).padEnd(25)
console.log(`${date} ${rate} ${ratio}`)
console.log(`${date} ${mRate} ${mRatio} ${cRate} ${cRatio}`)
}
return
}
Expand All @@ -52,15 +56,19 @@ export async function showHealth(
return
}

const rate = (health.overallCitedRate * 100).toFixed(1)
console.log(`Health: ${rate}% cited (${health.citedPairs}/${health.totalPairs} pairs)`)
// Mention leads, cited second — two independent signals, both surfaced.
const mentionRate = (health.overallMentionRate * 100).toFixed(1)
const citedRate = (health.overallCitedRate * 100).toFixed(1)
console.log(`Health: ${mentionRate}% mentioned (${health.mentionedPairs}/${health.totalPairs} pairs)`)
console.log(` ${citedRate}% cited (${health.citedPairs}/${health.totalPairs} pairs)`)
console.log('')

if (health.providerBreakdown && Object.keys(health.providerBreakdown).length > 0) {
console.log('Provider Breakdown:')
for (const [provider, stats] of Object.entries(health.providerBreakdown)) {
const pRate = (stats.citedRate * 100).toFixed(1)
console.log(` ${provider.padEnd(15)} ${pRate}% (${stats.cited}/${stats.total})`)
const pMention = (stats.mentionRate * 100).toFixed(1)
const pCited = (stats.citedRate * 100).toFixed(1)
console.log(` ${provider.padEnd(15)} ${pMention}% mentioned (${stats.mentioned}/${stats.total}) ${pCited}% cited (${stats.cited}/${stats.total})`)
}
}
}
7 changes: 7 additions & 0 deletions packages/canonry/src/intelligence-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,8 +690,10 @@ export class IntelligenceService {
projectId,
runId,
overallCitedRate: String(result.health.overallCitedRate),
overallMentionRate: String(result.health.overallMentionRate),
totalPairs: result.health.totalPairs,
citedPairs: result.health.citedPairs,
mentionedPairs: result.health.mentionedPairs,
providerBreakdown: result.health.providerBreakdown,
createdAt: now,
}).run()
Expand Down Expand Up @@ -874,6 +876,7 @@ export class IntelligenceService {
queryText: querySnapshots.queryText,
provider: querySnapshots.provider,
citationState: querySnapshots.citationState,
answerMentioned: querySnapshots.answerMentioned,
citedDomains: querySnapshots.citedDomains,
competitorOverlap: querySnapshots.competitorOverlap,
snapshotLocation: querySnapshots.location,
Expand Down Expand Up @@ -904,6 +907,10 @@ export class IntelligenceService {
query: resolvedQuery,
provider: r.provider,
cited: r.citationState === CitationStates.cited,
// Independent answer-text signal. Tri-state passes through untouched
// (true / false / null = "not checked"); computeHealth counts only
// exact `true`. Never coerce null→false here.
answerMentioned: r.answerMentioned,
// The project's OWN cited domain — never a co-cited competitor that
// happens to sort first in the full citedDomains set.
citationUrl: pickProjectCitedDomain(domains, projectDomains),
Expand Down
2 changes: 2 additions & 0 deletions packages/canonry/src/run-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,10 @@ function analysisResultFromInsights(insights: Insight[]): AnalysisResult {
competitorLosses: [],
health: {
overallCitedRate: 0,
overallMentionRate: 0,
totalPairs: 0,
citedPairs: 0,
mentionedPairs: 0,
providerBreakdown: {},
},
insights,
Expand Down
Loading
Loading