diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index ac30cc5..b054ebc 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..342430d 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,126 @@ 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})

- -
+ {/* Competitors tab — company names (preferred) or domain fallback */} + {sidebarTab === 'competitors' && ( + <> + {display.recommendedCompetitors.length > 0 ? ( +
+

AI recommended instead

+
+ {display.recommendedCompetitors.map((name, i) => ( +
+ #{i + 1} + {name} +
+ ))} + {!isCited && ( +
+ {'\u2014'} + {project.project.displayName || project.project.name} + Not mentioned +
+ )} +
+
+ ) : 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 +
+ )} +
+
+ ) : ( +
+ No competitor data {isViewingHistory ? 'for this run' : 'yet'} +
+ )} + )} - {/* Evidence URLs */} - {display.evidenceUrls.length > 0 && ( -
-

Evidence URLs

-
    - {display.evidenceUrls.map((url) => ( -
  • - - {url} - -
  • - ))} -
-
+ {/* Sources tab — grounding sources + evidence URLs */} + {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/mock-data.ts b/apps/web/src/mock-data.ts index b2db0b4..0ef2a64 100644 --- a/apps/web/src/mock-data.ts +++ b/apps/web/src/mock-data.ts @@ -193,6 +193,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'https://downtownsmiles.com/emergency-dentist-brooklyn', 'https://harbordental.com/same-day-emergency-care', ], + recommendedCompetitors: [], competitorDomains: ['downtownsmiles.com', 'harbordental.com'], groundingSources: [], relatedTechnicalSignals: [ @@ -215,6 +216,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'Citypoint Dental is listed as a top emergency dentist in Brooklyn for same-day appointments and walk-in availability.', citedDomains: ['citypointdental.com', 'downtownsmiles.com'], evidenceUrls: ['https://citypointdental.com/emergency-dentist-brooklyn'], + recommendedCompetitors: [], competitorDomains: ['downtownsmiles.com'], groundingSources: [], relatedTechnicalSignals: ['Emergency page indexed with structured data'], @@ -233,6 +235,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'Based on the search results, top-rated emergency dental practices in Brooklyn include Downtown Smiles and Harbor Dental.', citedDomains: ['downtownsmiles.com', 'harbordental.com'], evidenceUrls: [], + recommendedCompetitors: [], competitorDomains: ['downtownsmiles.com', 'harbordental.com'], groundingSources: [], relatedTechnicalSignals: ['FAQ schema missing', 'llms.txt not found'], @@ -256,6 +259,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'https://citypointdental.com/invisalign-downtown-brooklyn', 'https://citypointdental.com/case-studies/invisalign-open-bite', ], + recommendedCompetitors: [], competitorDomains: ['clearlineortho.com'], groundingSources: [], relatedTechnicalSignals: [ @@ -277,6 +281,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'Citypoint Dental in Downtown Brooklyn is highlighted for its Invisalign expertise and patient outcomes.', citedDomains: ['citypointdental.com'], evidenceUrls: ['https://citypointdental.com/invisalign-downtown-brooklyn'], + recommendedCompetitors: [], competitorDomains: [], groundingSources: [], relatedTechnicalSignals: ['Case study pages well-indexed'], @@ -295,6 +300,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'For Invisalign in Downtown Brooklyn, Clear Line Ortho and Brooklyn Smiles are frequently recommended for their experienced orthodontists.', citedDomains: ['clearlineortho.com', 'brooklynsmiles.com'], evidenceUrls: [], + recommendedCompetitors: [], competitorDomains: ['clearlineortho.com'], groundingSources: [], relatedTechnicalSignals: ['No before/after schema on case study pages'], @@ -318,6 +324,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'https://brightkidsdental.com/pediatric-dentist-brooklyn-heights', 'https://parkpediatricdental.com/insurance', ], + recommendedCompetitors: [], competitorDomains: ['brightkidsdental.com', 'parkpediatricdental.com'], groundingSources: [], relatedTechnicalSignals: [ @@ -339,6 +346,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'For pediatric dentistry in Brooklyn Heights, Bright Kids Dental and Park Pediatric Dental are cited for family-friendly care.', citedDomains: ['brightkidsdental.com', 'parkpediatricdental.com'], evidenceUrls: [], + recommendedCompetitors: [], competitorDomains: ['brightkidsdental.com', 'parkpediatricdental.com'], groundingSources: [], relatedTechnicalSignals: ['No dedicated pediatric page for Brooklyn Heights'], @@ -357,6 +365,7 @@ const citypointEvidence: CitationInsightVm[] = [ 'Citypoint Dental is mentioned as offering pediatric services in Brooklyn, though specialized pediatric-only practices are also highlighted.', citedDomains: ['citypointdental.com', 'brightkidsdental.com'], evidenceUrls: ['https://citypointdental.com/family-dentistry'], + recommendedCompetitors: [], competitorDomains: ['brightkidsdental.com'], groundingSources: [], relatedTechnicalSignals: ['Family dentistry page recently updated'], @@ -581,7 +590,8 @@ const baseProjectCommandCenters: ProjectCommandCenterVm[] = [ 'Harbor Legal Group remains cited for borough-specific personal injury queries due to clear practice-area and case-result content.', citedDomains: ['harborlegal.com'], evidenceUrls: ['https://harborlegal.com/personal-injury/brooklyn'], - competitorDomains: ['shorelineinjury.com'], + recommendedCompetitors: [], + competitorDomains: ['shorelineinjury.com'], groundingSources: [], relatedTechnicalSignals: ['Practice-area schema intact', 'Case results link directly from service pages'], summary: 'Grounding remains durable after the service-page consolidation.', @@ -682,7 +692,8 @@ const baseProjectCommandCenters: ProjectCommandCenterVm[] = [ evidenceUrls: [ 'https://northstarortho.com/locations/westchester/knee-replacement', ], - competitorDomains: ['regionaljointcare.com'], + recommendedCompetitors: [], + competitorDomains: ['regionaljointcare.com'], groundingSources: [], relatedTechnicalSignals: ['Physician bios now linked from treatment pages'], summary: 'Template cleanup is helping, but proof depth still matters.', 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..ad663bd 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/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/canonry/src/job-runner.ts b/packages/canonry/src/job-runner.ts index 4f9cc3e..6165526 100644 --- a/packages/canonry/src/job-runner.ts +++ b/packages/canonry/src/job-runner.ts @@ -300,6 +300,7 @@ 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) // Move screenshot to canonical location if present let screenshotRelPath: string | null = null @@ -321,6 +322,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 +344,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 +667,89 @@ function computeCompetitorOverlap( return [...overlapSet] } + +/** + * Extract company/brand names recommended in an AI answer. + * Uses pattern matching (no LLM call) to keep runs cost-neutral. + * + * Targets common AI response patterns: + * - **Company Name** (bold markdown) + * - ### Company Name (headings) + * - 1. Company Name — description + * - Company Name: description (after newline) + * - [Company Name](url) + */ +function extractRecommendedCompetitors( + answerText: string | null | undefined, + ownDomains: string[], +): string[] { + if (!answerText || answerText.length < 20) return [] + + const candidates = new Set() + + // Pattern 1: **Bold Company Names** — most common in ChatGPT/Claude responses + const boldPattern = /\*\*([A-Z][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\*\*/g + let match: RegExpExecArray | null + while ((match = boldPattern.exec(answerText)) !== null) { + candidates.add(match[1].trim()) + } + + // Pattern 2: ### Heading Company Names (with optional numbering) + const headingPattern = /^#{1,4}\s+(?:\d+\.\s+)?(?:\*\*)?([A-Z][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?$/gm + while ((match = headingPattern.exec(answerText)) !== null) { + candidates.add(match[1].trim()) + } + + // Pattern 3: Numbered list — "1. Company Name —" or "1. Company Name:" + const numberedPattern = /^\s*\d+\.\s+(?:\*\*)?([A-Z][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013–-]/gm + while ((match = numberedPattern.exec(answerText)) !== null) { + candidates.add(match[1].trim()) + } + + // Pattern 4: Markdown links [Company Name](url) + const linkPattern = /\[([A-Z][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\]\(https?:\/\//g + while ((match = linkPattern.exec(answerText)) !== null) { + candidates.add(match[1].trim()) + } + + // Filter out noise + const ownBrands = new Set( + ownDomains.map(d => normalizeProjectDomain(d).split('.')[0].toLowerCase()), + ) + + const stopWords = new Set([ + 'here', 'why', 'how', 'what', 'when', 'where', 'which', 'who', + 'key', 'note', 'example', 'summary', 'overview', 'conclusion', + 'pros', 'cons', 'features', 'benefits', 'pricing', 'comparison', + 'step', 'option', 'important', 'recommendation', 'verdict', + 'best', 'top', 'leading', 'notable', 'major', 'other', 'additional', + 'why it matters', 'key features', 'key benefits', 'how it works', + 'bottom line', 'final thoughts', 'in summary', 'the bottom line', + ]) + + const results: string[] = [] + for (const candidate of candidates) { + const lower = candidate.toLowerCase().trim() + // Skip if it's your own brand + if (ownBrands.has(lower.split(/[\s.]/)[0])) continue + // Skip generic headings / noise + if (stopWords.has(lower)) continue + // Skip very short or very long + if (candidate.length < 2 || candidate.length > 60) continue + // Skip if it looks like a sentence (has too many spaces — likely a description, not a name) + if (candidate.split(/\s+/).length > 6) continue + // Skip common non-company patterns + if (/^(step|option|tier|plan|level)\s+\d/i.test(candidate)) continue + + results.push(candidate) + } + + // Deduplicate case-insensitively, keep first occurrence casing + const seen = new Map() + for (const r of results) { + const key = r.toLowerCase() + if (!seen.has(key)) seen.set(key, r) + } + + return [...seen.values()].slice(0, 15) +} diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 0275499..963e7de 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 competitors — company names extracted from answer text + `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'),