From e5ecd48037f975b9d84fc3d129b304455212d513 Mon Sep 17 00:00:00 2001 From: Arber Xhindoli <14798762+arberx@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:10:56 -0400 Subject: [PATCH 1/2] fix: combine competitor evidence improvements --- apps/web/src/api.ts | 1 + apps/web/src/build-dashboard.ts | 2 + .../components/layout/EvidenceDetailModal.tsx | 172 ++++++++++++------ apps/web/src/styles.css | 13 ++ apps/web/src/view-models.ts | 1 + packages/api-routes/src/history.ts | 2 + packages/api-routes/src/runs.ts | 2 + packages/api-routes/test/index.test.ts | 64 ++++++- packages/canonry/src/job-runner.ts | 126 +++++++++++++ .../test/job-runner-competitors.test.ts | 38 ++++ packages/contracts/src/run.ts | 1 + packages/contracts/test/index.test.ts | 1 + packages/db/src/migrate.ts | 2 + packages/db/src/schema.ts | 1 + packages/db/test/index.test.ts | 1 + 15 files changed, 368 insertions(+), 59 deletions(-) create mode 100644 packages/canonry/test/job-runner-competitors.test.ts diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index ac30cc5..cdbf001 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -150,6 +150,7 @@ export interface ApiSnapshot { answerText: string | null citedDomains: string[] competitorOverlap: string[] + recommendedCompetitors?: string[] groundingSources: GroundingSource[] searchQueries: string[] model: string | null diff --git a/apps/web/src/build-dashboard.ts b/apps/web/src/build-dashboard.ts index c600327..21a82f7 100644 --- a/apps/web/src/build-dashboard.ts +++ b/apps/web/src/build-dashboard.ts @@ -421,6 +421,7 @@ function buildEvidenceFromTimeline( citedDomains: snap?.citedDomains ?? [], evidenceUrls: [], competitorDomains: snap?.competitorOverlap ?? [], + recommendedCompetitors: snap?.recommendedCompetitors ?? [], relatedTechnicalSignals: [], groundingSources: snap?.groundingSources ?? [], summary: evidenceSummary(snapState, entry.keyword), @@ -448,6 +449,7 @@ function buildEvidenceFromTimeline( citedDomains: [], evidenceUrls: [], competitorDomains: [], + recommendedCompetitors: [], relatedTechnicalSignals: [], groundingSources: [], summary: `"${kw.keyword}" has been added but no visibility run has been triggered yet.`, diff --git a/apps/web/src/components/layout/EvidenceDetailModal.tsx b/apps/web/src/components/layout/EvidenceDetailModal.tsx index dd2d902..c8e3ced 100644 --- a/apps/web/src/components/layout/EvidenceDetailModal.tsx +++ b/apps/web/src/components/layout/EvidenceDetailModal.tsx @@ -17,6 +17,7 @@ export interface EvidenceDisplayData { answerSnippet: string citedDomains: string[] competitorDomains: string[] + recommendedCompetitors: string[] groundingSources: GroundingSource[] evidenceUrls: string[] changeLabel: string @@ -33,6 +34,7 @@ export function EvidenceDetailModal({ onClose: () => void }) { const [showFullAnswer, setShowFullAnswer] = useState(false) + const [sidebarTab, setSidebarTab] = useState<'competitors' | 'sources'>('competitors') const [selectedRunIdx, setSelectedRunIdx] = useState(-1) // -1 = latest (current) const [historicalSnapshot, setHistoricalSnapshot] = useState(null) const [loadingHistory, setLoadingHistory] = useState(false) @@ -67,6 +69,7 @@ export function EvidenceDetailModal({ answerSnippet: snap.answerText, citedDomains: snap.citedDomains ?? evidence.citedDomains, competitorDomains: snap.competitorOverlap ?? evidence.competitorDomains, + recommendedCompetitors: snap.recommendedCompetitors ?? evidence.recommendedCompetitors ?? [], groundingSources: snap.groundingSources ?? evidence.groundingSources, evidenceUrls: [], changeLabel: evidence.changeLabel, @@ -87,6 +90,7 @@ export function EvidenceDetailModal({ answerSnippet: evidence.answerSnippet, citedDomains: evidence.citedDomains, competitorDomains: evidence.competitorDomains, + recommendedCompetitors: evidence.recommendedCompetitors ?? [], groundingSources: evidence.groundingSources, evidenceUrls: evidence.evidenceUrls, changeLabel: evidence.changeLabel, @@ -158,6 +162,7 @@ export function EvidenceDetailModal({ answerSnippet: snap.answerText ?? '', citedDomains: snap.citedDomains, competitorDomains: snap.competitorOverlap, + recommendedCompetitors: snap.recommendedCompetitors ?? [], groundingSources: snap.groundingSources, evidenceUrls: [], changeLabel: run.citationState, @@ -169,6 +174,7 @@ export function EvidenceDetailModal({ answerSnippet: '', citedDomains: [], competitorDomains: [], + recommendedCompetitors: [], groundingSources: [], evidenceUrls: [], changeLabel: run.citationState, @@ -186,6 +192,7 @@ export function EvidenceDetailModal({ answerSnippet: '', citedDomains: [], competitorDomains: [], + recommendedCompetitors: [], groundingSources: [], evidenceUrls: [], changeLabel: run.citationState, @@ -470,72 +477,121 @@ export function EvidenceDetailModal({ {/* Right: leaderboard + sources */}
- {/* Citation leaderboard */} - {display.citedDomains.length > 0 && ( -
-

Who was cited \u2014 in order

-
- {display.citedDomains.map((domain, i) => { - const norm = domain.toLowerCase().replace(/^www\./, '') - const isYou = myDomains.has(norm) - const isCompetitor = !isYou && display.competitorDomains.some( - c => c.toLowerCase().replace(/^www\./, '') === norm, - ) - const variant = isYou ? 'you' : isCompetitor ? 'competitor' : 'other' - return ( -
- #{i + 1} - {domain} - {isYou && You} - {isCompetitor && Competitor} -
- ) - })} - {!isCited && ( -
- {'\u2014'} - {project.project.canonicalDomain} - Not cited -
- )} -
+ {/* Tabbed sidebar navigation */} + {(display.citedDomains.length > 0 || display.recommendedCompetitors.length > 0 || display.groundingSources.length > 0 || display.evidenceUrls.length > 0) && ( +
+ +
)} - {/* Grounding sources */} - {display.groundingSources.length > 0 && ( -
-

Grounding sources ({display.groundingSources.length})

- -
+ {sidebarTab === 'competitors' && ( + <> + {display.recommendedCompetitors.length > 0 && ( +
+

AI recommended instead

+
+ {display.recommendedCompetitors.map((name, i) => ( +
+ #{i + 1} + {name} +
+ ))} +
+
+ )} + + {display.citedDomains.length > 0 ? ( +
+

Domains cited instead

+
+ {display.citedDomains.map((domain, i) => { + const norm = domain.toLowerCase().replace(/^www\./, '') + const isYou = myDomains.has(norm) + const isCompetitor = !isYou && display.competitorDomains.some( + c => c.toLowerCase().replace(/^www\./, '') === norm, + ) + const variant = isYou ? 'you' : isCompetitor ? 'competitor' : 'other' + return ( +
+ #{i + 1} + {domain} + {isYou && You} + {isCompetitor && Competitor} +
+ ) + })} + {!isCited && ( +
+ {'\u2014'} + {project.project.canonicalDomain} + Not cited +
+ )} +
+
+ ) : display.recommendedCompetitors.length === 0 ? ( +
+ No competitor data {isViewingHistory ? 'for this run' : 'yet'} +
+ ) : null} + )} - {/* Evidence URLs */} - {display.evidenceUrls.length > 0 && ( -
-

Evidence URLs

-
    - {display.evidenceUrls.map((url) => ( -
  • - - {url} - -
  • - ))} -
-
+ {sidebarTab === 'sources' && ( + <> + {display.groundingSources.length > 0 && ( +
+

Grounding sources ({display.groundingSources.length})

+ +
+ )} + + {display.evidenceUrls.length > 0 && ( +
+

Evidence URLs

+
    + {display.evidenceUrls.map((url) => ( +
  • + + {url} + +
  • + ))} +
+
+ )} + + {display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && ( +
+ No source data {isViewingHistory ? 'for this run' : 'yet'} +
+ )} + )} {/* No data state */} - {display.citedDomains.length === 0 && display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && ( + {display.citedDomains.length === 0 && display.recommendedCompetitors.length === 0 && display.groundingSources.length === 0 && display.evidenceUrls.length === 0 && (
No citation data {isViewingHistory ? 'for this run' : 'yet'}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8e34796..532a213 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1397,6 +1397,19 @@ @apply md:border-l md:border-zinc-800/40 md:pl-6; } + .sidebar-tabs { + @apply flex gap-1 p-0.5 rounded-lg bg-zinc-900/60 border border-zinc-800/40; + } + + .sidebar-tab { + @apply flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors; + @apply text-zinc-500 hover:text-zinc-300; + } + + .sidebar-tab--active { + @apply bg-zinc-800 text-zinc-200 shadow-sm; + } + .evidence-answer-collapsed { max-height: 200px; overflow: hidden; diff --git a/apps/web/src/view-models.ts b/apps/web/src/view-models.ts index e5e1f8a..38eb56b 100644 --- a/apps/web/src/view-models.ts +++ b/apps/web/src/view-models.ts @@ -108,6 +108,7 @@ export interface CitationInsightVm { citedDomains: string[] evidenceUrls: string[] competitorDomains: string[] + recommendedCompetitors?: string[] relatedTechnicalSignals: string[] groundingSources: GroundingSource[] summary: string diff --git a/packages/api-routes/src/history.ts b/packages/api-routes/src/history.ts index 4403af3..3c5567d 100644 --- a/packages/api-routes/src/history.ts +++ b/packages/api-routes/src/history.ts @@ -66,6 +66,7 @@ export async function historyRoutes(app: FastifyInstance) { answerText: querySnapshots.answerText, citedDomains: querySnapshots.citedDomains, competitorOverlap: querySnapshots.competitorOverlap, + recommendedCompetitors: querySnapshots.recommendedCompetitors, location: querySnapshots.location, createdAt: querySnapshots.createdAt, }) @@ -96,6 +97,7 @@ export async function historyRoutes(app: FastifyInstance) { answerText: s.answerText, citedDomains: tryParseJson(s.citedDomains, [] as string[]), competitorOverlap: tryParseJson(s.competitorOverlap, [] as string[]), + recommendedCompetitors: tryParseJson(s.recommendedCompetitors, [] as string[]), location: s.location, createdAt: s.createdAt, })), diff --git a/packages/api-routes/src/runs.ts b/packages/api-routes/src/runs.ts index 61c2929..8428df7 100644 --- a/packages/api-routes/src/runs.ts +++ b/packages/api-routes/src/runs.ts @@ -298,6 +298,7 @@ export async function runRoutes(app: FastifyInstance, opts: RunRoutesOptions) { answerText: querySnapshots.answerText, citedDomains: querySnapshots.citedDomains, competitorOverlap: querySnapshots.competitorOverlap, + recommendedCompetitors: querySnapshots.recommendedCompetitors, location: querySnapshots.location, rawResponse: querySnapshots.rawResponse, createdAt: querySnapshots.createdAt, @@ -321,6 +322,7 @@ export async function runRoutes(app: FastifyInstance, opts: RunRoutesOptions) { answerText: s.answerText, citedDomains: tryParseJson(s.citedDomains, []), competitorOverlap: tryParseJson(s.competitorOverlap, []), + recommendedCompetitors: tryParseJson(s.recommendedCompetitors, []), model: s.model ?? rawParsed.model, location: s.location, groundingSources: rawParsed.groundingSources, diff --git a/packages/api-routes/test/index.test.ts b/packages/api-routes/test/index.test.ts index 4f5050f..af1ce56 100644 --- a/packages/api-routes/test/index.test.ts +++ b/packages/api-routes/test/index.test.ts @@ -4,7 +4,7 @@ import path from 'node:path' import os from 'node:os' import crypto from 'node:crypto' import Fastify from 'fastify' -import { createClient, migrate, projects, runs } from '@ainyc/canonry-db' +import { createClient, migrate, projects, keywords, querySnapshots, runs } from '@ainyc/canonry-db' import { apiRoutes } from '../src/index.js' import type { ApiRoutesOptions } from '../src/index.js' @@ -250,6 +250,68 @@ describe('api-routes', () => { expect(body.length).toBeGreaterThan(0) }) + it('run detail and project snapshot history expose recommendedCompetitors', async () => { + const projectId = crypto.randomUUID() + const keywordId = crypto.randomUUID() + const runId = crypto.randomUUID() + const now = new Date(Date.now() + 30_000).toISOString() + + db.insert(projects).values({ + id: projectId, + name: 'snapshot-history-project', + displayName: 'Snapshot History Project', + canonicalDomain: 'example.com', + country: 'US', + language: 'en', + providers: '[]', + createdAt: now, + updatedAt: now, + }).run() + + db.insert(keywords).values({ + id: keywordId, + projectId, + keyword: 'best example software', + createdAt: now, + }).run() + + db.insert(runs).values({ + id: runId, + projectId, + status: 'completed', + createdAt: now, + finishedAt: now, + }).run() + + db.insert(querySnapshots).values({ + id: crypto.randomUUID(), + runId, + keywordId, + provider: 'gemini', + citationState: 'not-cited', + answerText: '1. Downtown Smiles - strong reviews', + citedDomains: '["downtownsmiles.com"]', + competitorOverlap: '["downtownsmiles.com"]', + recommendedCompetitors: '["Downtown Smiles"]', + rawResponse: '{"groundingSources":[],"searchQueries":[]}', + createdAt: now, + }).run() + + const runRes = await app.inject({ method: 'GET', url: `/api/v1/runs/${runId}` }) + expect(runRes.statusCode).toBe(200) + const runBody = JSON.parse(runRes.payload) as { + snapshots: Array<{ recommendedCompetitors: string[] }> + } + expect(runBody.snapshots[0]?.recommendedCompetitors).toEqual(['Downtown Smiles']) + + const historyRes = await app.inject({ method: 'GET', url: '/api/v1/projects/snapshot-history-project/snapshots' }) + expect(historyRes.statusCode).toBe(200) + const historyBody = JSON.parse(historyRes.payload) as { + snapshots: Array<{ recommendedCompetitors: string[] }> + } + expect(historyBody.snapshots[0]?.recommendedCompetitors).toEqual(['Downtown Smiles']) + }) + it('PUT /api/v1/projects/:name updates project settings', async () => { const res = await app.inject({ method: 'PUT', diff --git a/packages/canonry/src/job-runner.ts b/packages/canonry/src/job-runner.ts index 4f9cc3e..3c82c9e 100644 --- a/packages/canonry/src/job-runner.ts +++ b/packages/canonry/src/job-runner.ts @@ -300,6 +300,12 @@ export class JobRunner { log.info('query.result', { runId, provider: providerName, keyword: kw.keyword, citedDomains: normalized.citedDomains, groundingSources: normalized.groundingSources.map(s => s.uri), matchDomains: allDomains }) const citationState = determineCitationState(normalized, allDomains) const overlap = computeCompetitorOverlap(normalized, competitorDomains) + const extractedCompetitors = extractRecommendedCompetitors( + normalized.answerText, + allDomains, + normalized.citedDomains, + overlap, + ) // Move screenshot to canonical location if present let screenshotRelPath: string | null = null @@ -321,6 +327,7 @@ export class JobRunner { answerText: normalized.answerText, citedDomains: JSON.stringify(normalized.citedDomains), competitorOverlap: JSON.stringify(overlap), + recommendedCompetitors: JSON.stringify(extractedCompetitors), location: runLocation?.label ?? null, screenshotPath: screenshotRelPath, rawResponse: JSON.stringify({ @@ -342,6 +349,7 @@ export class JobRunner { answerText: normalized.answerText, citedDomains: JSON.stringify(normalized.citedDomains), competitorOverlap: JSON.stringify(overlap), + recommendedCompetitors: JSON.stringify(extractedCompetitors), location: runLocation?.label ?? null, rawResponse: JSON.stringify({ model: raw.model, @@ -664,3 +672,121 @@ function computeCompetitorOverlap( return [...overlapSet] } + +/** + * Extract brand names from the answer, but only when they line up with + * domains we already know were cited or matched as competitors. + */ +export function extractRecommendedCompetitors( + answerText: string | null | undefined, + ownDomains: string[], + citedDomains: string[], + competitorDomains: string[], +): string[] { + if (!answerText || answerText.length < 20) return [] + + const ownBrandKeys = new Set( + ownDomains.flatMap(domain => collectBrandKeysFromDomain(domain)), + ) + const knownCompetitorKeys = new Set( + [...citedDomains, ...competitorDomains] + .flatMap(domain => collectBrandKeysFromDomain(domain)) + .filter(key => !ownBrandKeys.has(key)), + ) + + if (knownCompetitorKeys.size === 0) return [] + + const candidatePatterns = [ + /^\s*(?:[-*]|\d+\.)\s+(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013–-]/gm, + /\*\*([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\*\*/g, + /^#{1,4}\s+(?:\d+\.\s+)?(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?$/gm, + /\[([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\]\(https?:\/\/[^\s)]+\)/g, + ] + const genericKeys = new Set([ + 'additional', + 'best', + 'benefits', + 'bottomline', + 'comparison', + 'conclusion', + 'directorylisting', + 'example', + 'expertise', + 'features', + 'finalthoughts', + 'howitworks', + 'important', + 'keybenefits', + 'keyfeatures', + 'major', + 'note', + 'notable', + 'option', + 'other', + 'overview', + 'pricing', + 'pros', + 'reviews', + 'step', + 'summary', + 'top', + 'verdict', + 'whattolookfor', + 'whyitmatters', + 'whyitstandsout', + 'whywechoseit', + ]) + + const seen = new Map() + for (const pattern of candidatePatterns) { + let match: RegExpExecArray | null + while ((match = pattern.exec(answerText)) !== null) { + const candidate = cleanCandidateName(match[1] ?? '') + const candidateKey = brandKeyFromText(candidate) + if (!candidateKey) continue + if (genericKeys.has(candidateKey)) continue + if (candidate.split(/\s+/).length > 6) continue + if (matchesBrandKey(candidateKey, ownBrandKeys)) continue + if (!matchesBrandKey(candidateKey, knownCompetitorKeys)) continue + if (!seen.has(candidateKey)) seen.set(candidateKey, candidate) + } + } + + return [...seen.values()].slice(0, 10) +} + +function cleanCandidateName(candidate: string): string { + return candidate + .replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, '') + .replace(/\s+/g, ' ') + .trim() +} + +function brandKeyFromText(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, '') +} + +function collectBrandKeysFromDomain(domain: string): string[] { + const hostname = normalizeProjectDomain(domain).split('/')[0] ?? '' + const labels = hostname.split('.').filter(Boolean) + const keys = new Set() + + const hostnameKey = hostname.replace(/[^a-z0-9]/gi, '').toLowerCase() + if (hostnameKey.length >= 4) keys.add(hostnameKey) + + for (const label of labels) { + const key = label.replace(/[^a-z0-9]/gi, '').toLowerCase() + if (key.length >= 4) keys.add(key) + } + + return [...keys] +} + +function matchesBrandKey(candidateKey: string, brandKeys: Set): boolean { + for (const brandKey of brandKeys) { + if (candidateKey === brandKey) return true + if (candidateKey.startsWith(brandKey) || candidateKey.endsWith(brandKey)) return true + if (brandKey.startsWith(candidateKey) || brandKey.endsWith(candidateKey)) return true + } + return false +} diff --git a/packages/canonry/test/job-runner-competitors.test.ts b/packages/canonry/test/job-runner-competitors.test.ts new file mode 100644 index 0000000..8e1eaa7 --- /dev/null +++ b/packages/canonry/test/job-runner-competitors.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'vitest' + +import { extractRecommendedCompetitors } from '../src/job-runner.js' + +test('extractRecommendedCompetitors excludes headings and the target brand', () => { + const answer = [ + '### Why it stands out', + 'Use a provider with borough coverage.', + '', + '1. **Citypoint Dental** - the target brand', + '2. **Downtown Smiles** - same-day care', + ].join('\n') + + expect( + extractRecommendedCompetitors( + answer, + ['citypointdental.com'], + ['citypointdental.com', 'downtownsmiles.com'], + [], + ), + ).toEqual(['Downtown Smiles']) +}) + +test('extractRecommendedCompetitors matches spaced company names to compact domains', () => { + const answer = [ + '1. Regional Joint Care — broad orthopedic network', + '2. Northstar Ortho — physician bios and outcomes', + ].join('\n') + + expect( + extractRecommendedCompetitors( + answer, + ['acmehealth.com'], + ['regionaljointcare.com', 'northstarortho.com'], + [], + ), + ).toEqual(['Regional Joint Care', 'Northstar Ortho']) +}) diff --git a/packages/contracts/src/run.ts b/packages/contracts/src/run.ts index 7eb5177..ce8215c 100644 --- a/packages/contracts/src/run.ts +++ b/packages/contracts/src/run.ts @@ -49,6 +49,7 @@ export const querySnapshotDtoSchema = z.object({ answerText: z.string().nullable().optional(), citedDomains: z.array(z.string()).default([]), competitorOverlap: z.array(z.string()).default([]), + recommendedCompetitors: z.array(z.string()).default([]), groundingSources: z.array(groundingSourceSchema).default([]), searchQueries: z.array(z.string()).default([]), model: z.string().nullable().optional(), diff --git a/packages/contracts/test/index.test.ts b/packages/contracts/test/index.test.ts index 75ae3d7..276ae42 100644 --- a/packages/contracts/test/index.test.ts +++ b/packages/contracts/test/index.test.ts @@ -197,6 +197,7 @@ test('querySnapshotDtoSchema applies defaults', () => { expect(snapshot.provider).toBe('gemini') expect(snapshot.citedDomains).toEqual([]) expect(snapshot.competitorOverlap).toEqual([]) + expect(snapshot.recommendedCompetitors).toEqual([]) }) test('querySnapshotDtoSchema accepts all provider names', () => { diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 0275499..894d893 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -297,6 +297,8 @@ const MIGRATIONS = [ `ALTER TABLE bing_url_inspections ADD COLUMN document_size INTEGER`, `ALTER TABLE bing_url_inspections ADD COLUMN anchor_count INTEGER`, `ALTER TABLE bing_url_inspections ADD COLUMN discovery_date TEXT`, + // v16: Recommended competitor names extracted from run answers + `ALTER TABLE query_snapshots ADD COLUMN recommended_competitors TEXT NOT NULL DEFAULT '[]'`, ] export function migrate(db: DatabaseClient) { diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index af2d08b..3e85702 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -65,6 +65,7 @@ export const querySnapshots = sqliteTable('query_snapshots', { answerText: text('answer_text'), citedDomains: text('cited_domains').notNull().default('[]'), competitorOverlap: text('competitor_overlap').notNull().default('[]'), + recommendedCompetitors: text('recommended_competitors').notNull().default('[]'), location: text('location'), screenshotPath: text('screenshot_path'), rawResponse: text('raw_response'), diff --git a/packages/db/test/index.test.ts b/packages/db/test/index.test.ts index 1ef07b9..dfb9c80 100644 --- a/packages/db/test/index.test.ts +++ b/packages/db/test/index.test.ts @@ -196,6 +196,7 @@ test('CRUD: insert run and query snapshot', () => { const [snap] = db.select().from(querySnapshots).where(eq(querySnapshots.runId, 'run_1')).all() expect(snap.citationState).toBe('cited') expect(JSON.parse(snap.citedDomains)).toEqual(['example.com']) + expect(JSON.parse(snap.recommendedCompetitors)).toEqual([]) }) test('unique constraint on keywords(project_id, keyword)', () => { From 22407cc579ff4b188b0a0be83c959c181b438dad Mon Sep 17 00:00:00 2001 From: Arber Xhindoli <14798762+arberx@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:18:19 -0400 Subject: [PATCH 2/2] fix(web): rename citations tab and add evidence tooltips --- .../components/layout/EvidenceDetailModal.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/layout/EvidenceDetailModal.tsx b/apps/web/src/components/layout/EvidenceDetailModal.tsx index c8e3ced..2b30baf 100644 --- a/apps/web/src/components/layout/EvidenceDetailModal.tsx +++ b/apps/web/src/components/layout/EvidenceDetailModal.tsx @@ -5,6 +5,7 @@ import { X } from 'lucide-react' import { effectiveDomains, normalizeProjectDomain } from '@ainyc/canonry-contracts' import { CitationBadge } from '../shared/CitationBadge.js' +import { InfoTooltip } from '../shared/InfoTooltip.js' import { highlightTermsInText } from '../../lib/highlight.js' import { fetchRunDetail, type GroundingSource } from '../../api.js' import type { CitationInsightVm, ProjectCommandCenterVm } from '../../view-models.js' @@ -34,7 +35,7 @@ export function EvidenceDetailModal({ onClose: () => void }) { const [showFullAnswer, setShowFullAnswer] = useState(false) - const [sidebarTab, setSidebarTab] = useState<'competitors' | 'sources'>('competitors') + const [sidebarTab, setSidebarTab] = useState<'citations' | 'sources'>('citations') const [selectedRunIdx, setSelectedRunIdx] = useState(-1) // -1 = latest (current) const [historicalSnapshot, setHistoricalSnapshot] = useState(null) const [loadingHistory, setLoadingHistory] = useState(false) @@ -481,27 +482,32 @@ export function EvidenceDetailModal({ {(display.citedDomains.length > 0 || display.recommendedCompetitors.length > 0 || display.groundingSources.length > 0 || display.evidenceUrls.length > 0) && (
)} - {sidebarTab === 'competitors' && ( + {sidebarTab === 'citations' && ( <> {display.recommendedCompetitors.length > 0 && (
-

AI recommended instead

+
+ Company names mentioned + +
{display.recommendedCompetitors.map((name, i) => (
@@ -515,7 +521,10 @@ export function EvidenceDetailModal({ {display.citedDomains.length > 0 ? (
-

Domains cited instead

+
+ Domains cited in answer + +
{display.citedDomains.map((domain, i) => { const norm = domain.toLowerCase().replace(/^www\./, '') @@ -544,7 +553,7 @@ export function EvidenceDetailModal({
) : display.recommendedCompetitors.length === 0 ? (
- No competitor data {isViewingHistory ? 'for this run' : 'yet'} + No citation data {isViewingHistory ? 'for this run' : 'yet'}
) : null} @@ -554,7 +563,10 @@ export function EvidenceDetailModal({ <> {display.groundingSources.length > 0 && (
-

Grounding sources ({display.groundingSources.length})

+
+ Grounding source links ({display.groundingSources.length}) + +
    {display.groundingSources.map((src, i) => (