From 8a85b7b7940d4cc81f68f41f4987e308b4816c74 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:33:11 -0400 Subject: [PATCH] AI Visibility: competitor Share of Voice + clarity fixes for AI citations (#248) --- README.md | 10 + scripts/brand-lookup-cost-profile.ts | 46 +- .../features/ai-search/BrandLookupPage.tsx | 117 +++-- .../ai-search/brandLookupFiltering.ts | 6 +- .../components/BrandLookupCitationTables.tsx | 404 ++++++++++++++---- .../components/BrandLookupCitationsCard.tsx | 269 ++++++++++++ .../components/BrandLookupFilterPanel.tsx | 2 +- .../components/BrandLookupHistorySection.tsx | 17 +- .../components/BrandLookupResults.tsx | 280 +++--------- .../components/BrandLookupSearchCard.tsx | 124 ++++-- .../components/BrandLookupShareOfVoice.tsx | 110 +++++ .../ai-search/components/brandLookupExport.ts | 11 +- .../features/ai-search/platformLabels.ts | 11 + .../ai-search/useBrandLookupFilters.ts | 5 +- .../hooks/useBrandLookupSearchHistory.ts | 8 +- .../_project/p/$projectId/brand-lookup.tsx | 11 +- .../ai-search/services/brandLookup.test.ts | 283 ++++++++++++ .../ai-search/services/brandLookup.ts | 392 +++++++---------- .../services/brandLookupShaping.test.ts | 166 +++++++ .../ai-search/services/brandLookupShaping.ts | 228 ++++++++++ .../ai-search/services/citedSources.test.ts | 97 +++++ .../ai-search/services/citedSources.ts | 118 +++++ .../ai-search/services/shareOfVoice.test.ts | 80 ++++ .../ai-search/services/shareOfVoice.ts | 132 ++++++ src/server/lib/dataforseo/ai.ts | 64 +++ src/server/lib/dataforseo/client.test.ts | 1 + src/server/lib/dataforseo/client.ts | 2 + src/server/lib/dataforseo/endpoints.test.ts | 78 +++- src/server/lib/dataforseoLlmSchemas.ts | 21 + .../targetDetection.test.ts | 0 .../ai-search => shared}/targetDetection.ts | 0 src/types/schemas/ai-search.ts | 91 +++- 32 files changed, 2551 insertions(+), 633 deletions(-) create mode 100644 src/client/features/ai-search/components/BrandLookupCitationsCard.tsx create mode 100644 src/client/features/ai-search/components/BrandLookupShareOfVoice.tsx create mode 100644 src/server/features/ai-search/services/brandLookup.test.ts create mode 100644 src/server/features/ai-search/services/brandLookupShaping.test.ts create mode 100644 src/server/features/ai-search/services/brandLookupShaping.ts create mode 100644 src/server/features/ai-search/services/citedSources.test.ts create mode 100644 src/server/features/ai-search/services/citedSources.ts create mode 100644 src/server/features/ai-search/services/shareOfVoice.test.ts create mode 100644 src/server/features/ai-search/services/shareOfVoice.ts rename src/{server/features/ai-search => shared}/targetDetection.test.ts (100%) rename src/{server/features/ai-search => shared}/targetDetection.ts (100%) diff --git a/README.md b/README.md index 415d2ef..ce17d86 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,16 @@ Searching ten pages deep costs 8x more than one page. Tracking both devices cost - Opening extra tabs like `Referring Domains` or `Top Pages` adds about `+$0.02` each. - Exact cost can vary slightly based on returned rows and DataForSEO pricing. +### 6) AI Search — Brand Lookup + +- One lookup = 6 DataForSEO AI Optimization calls (`aggregated_metrics` + `top_pages` + `mentions_search` across ChatGPT and Google AI Overview): up to about `$0.85` per lookup. + - `aggregated_metrics`: `$0.101` per platform. + - `top_pages`: page-ranked cited sources per platform. + - `mentions_search`: row-priced; `$0.20` per platform at the app's full 100-row sample (lower-volume brands return fewer rows and cost less). +- Adding competitors (Share of Voice) adds 2 `cross_aggregated_metrics` calls: about `$0.10` each, `$0.20` total. +- Results are cached for 24 hours, so repeating the same lookup (same target + competitor set) is free within a day. +- Re-measure anytime with `pnpm billing:brand-lookup --target=example.com --competitors=a.com,b.com --confirmLive=true`. + ### Planning examples - 100 keyword research requests at the default 150 results: `$3.50` diff --git a/scripts/brand-lookup-cost-profile.ts b/scripts/brand-lookup-cost-profile.ts index 72959b6..7de8ea7 100644 --- a/scripts/brand-lookup-cost-profile.ts +++ b/scripts/brand-lookup-cost-profile.ts @@ -4,11 +4,14 @@ import { CHATGPT_LANGUAGE_CODE, CHATGPT_LOCATION_CODE, fetchLlmAggregatedMetrics, + fetchLlmCrossAggregatedMetrics, fetchLlmMentionsSearch, fetchLlmTopPages, type LlmPlatform, } from "@/server/lib/dataforseo/ai"; import { applyBillingMarkupUsd } from "@/shared/billing"; +import { resolveCompetitorGroups } from "@/server/features/ai-search/services/shareOfVoice"; +import { parseCompetitorList } from "@/types/schemas/ai-search"; import { loadLocalEnv, parseArgs } from "./cli-utils"; loadLocalEnv(); @@ -48,8 +51,22 @@ async function main() { const userLocationCode = parsePositiveInteger(args.locationCode, 2840); const userLanguageCode = args.languageCode ?? "en"; const repeat = parsePositiveInteger(args.repeat, 1); + // Optional comma-separated competitors — adds the Share of Voice + // cross_aggregated_metrics call per platform, mirroring the service. + const competitors = parseCompetitorList(args.competitors ?? ""); + const competitorGroups = resolveCompetitorGroups(target, competitors); const llmTarget = buildLlmTarget({ type: targetType, value: target }); + const crossGroups = [ + { key: target, target: llmTarget }, + ...competitorGroups.map((competitor) => { + const detected = competitor.detected; + return { + key: competitor.label, + target: buildLlmTarget({ type: detected.type, value: detected.value }), + }; + }), + ]; const platforms: LlmPlatform[] = ["chat_gpt", "google"]; const allRuns: RunSummary[] = []; @@ -83,20 +100,42 @@ async function main() { }); calls.push(toRecord(platform, "top_pages", topPages.billing)); + // Prompt rows provide examples for the cited-source table. const mentions = await fetchLlmMentionsSearch({ target: llmTarget, platform, locationCode, languageCode, - limit: 25, + limit: 100, }); calls.push(toRecord(platform, "mentions_search", mentions.billing)); + + if (competitorGroups.length > 0) { + const cross = await fetchLlmCrossAggregatedMetrics({ + groups: crossGroups, + platform, + locationCode, + languageCode, + }); + calls.push( + toRecord(platform, "cross_aggregated_metrics", cross.billing), + ); + } } const totalRawUsd = sum(calls.map((c) => c.rawUsd)); + const crossRawUsd = sum( + calls + .filter((c) => c.endpoint === "cross_aggregated_metrics") + .map((c) => c.rawUsd), + ); allRuns.push({ run: runIndex + 1, calls, + // Split out so the base "Est. $X" and the "+$Y to compare competitors" + // UI constants can each be checked against reality. + baseRawUsd: round(totalRawUsd - crossRawUsd), + crossRawUsd: round(crossRawUsd), totalRawUsd: round(totalRawUsd), totalBilledUsd: applyBillingMarkupUsd(totalRawUsd), }); @@ -113,6 +152,7 @@ async function main() { targetType, userLocationCode, userLanguageCode, + competitors: competitorGroups.map((group) => group.label), repeat, }, runs: allRuns, @@ -140,6 +180,8 @@ type CallRecord = { type RunSummary = { run: number; calls: CallRecord[]; + baseRawUsd: number; + crossRawUsd: number; totalRawUsd: number; totalBilledUsd: number; }; @@ -183,7 +225,7 @@ function round(value: number): number { function printUsageAndExit(message: string): never { console.error(message); console.error( - "Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]", + "Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--competitors=a.com,b.com] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]", ); process.exit(1); } diff --git a/src/client/features/ai-search/BrandLookupPage.tsx b/src/client/features/ai-search/BrandLookupPage.tsx index 43c2141..f6eb70d 100644 --- a/src/client/features/ai-search/BrandLookupPage.tsx +++ b/src/client/features/ai-search/BrandLookupPage.tsx @@ -23,24 +23,29 @@ import { AiSearchSetupGate } from "@/client/features/ai-search/components/AiSear import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate"; import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess"; import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory"; -import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; +import { + BRAND_LOOKUP_MAX_INPUT_LENGTH, + parseCompetitorList, +} from "@/types/schemas/ai-search"; +import { detectTarget } from "@/shared/targetDetection"; type Props = { projectId: string; initialQuery: string; - onQueryChange: (next: string) => void; + initialCompetitors: string[]; + onSearchChange: (nextQuery: string, nextCompetitors: string[]) => void; }; const BRAND_LOOKUP_BULLETS = [ { icon: TrendingUp, title: "Track AI visibility", - body: "Count how often ChatGPT and Google AI Overview cite your brand, and watch the trend month over month.", + body: "See estimated counts for ChatGPT and Google AI Overview answers that cite your brand, and watch the trend month over month.", }, { icon: Quote, title: "See the prompts", - body: "View the actual user questions where LLMs reference your domain — the real demand driving AI traffic.", + body: "View sample user questions where LLMs reference your brand or domain.", }, { icon: BarChart3, @@ -60,24 +65,38 @@ export function BrandLookupPage(props: Props) { function BrandLookupPageInner({ projectId, initialQuery, - onQueryChange, + initialCompetitors, + onSearchChange, planGate, }: Props & { planGate: HostedPlanGateState }) { const [query, setQuery] = useState(initialQuery); - const [validationError, setValidationError] = useState(null); + // Raw comma-separated competitor text; parsed into a deduped array on submit. + const [competitorsInput, setCompetitorsInput] = useState( + initialCompetitors.join(", "), + ); + // Field-tagged so the error styling lands on the input that caused it. + const [validationError, setValidationError] = useState<{ + field: "query" | "competitors"; + message: string; + } | null>(null); const access = useAiSearchAccess(projectId); const trimmedInitialQuery = initialQuery.trim(); const hasActiveQuery = trimmedInitialQuery.length > 0; + // The URL `c` param is the source of truth for the active lookup; the local + // `competitorsInput` text only drives the input until the next submit. A + // stable string key, since `initialCompetitors` is a fresh array each render. + const competitorKey = initialCompetitors.join(","); const lookupQuery = useQuery({ - queryKey: ["brand-lookup", projectId, trimmedInitialQuery], + queryKey: ["brand-lookup", projectId, trimmedInitialQuery, competitorKey], queryFn: () => lookupBrand({ data: { projectId, query: trimmedInitialQuery, + competitors: initialCompetitors, locationCode: 2840, languageCode: "en", }, @@ -96,38 +115,83 @@ function BrandLookupPageInner({ // Dedup ref prevents repeat adds — `addSearch` identity is not stable // across renders, so we'd otherwise re-write the same item every render. - const lastAddedQueryRef = useRef(null); + // Key on query + competitors so changing competitors records a fresh entry. + const lastAddedKeyRef = useRef(null); useEffect(() => { if (!hasActiveQuery || !lookupQuery.isSuccess) return; - if (lastAddedQueryRef.current === trimmedInitialQuery) return; - lastAddedQueryRef.current = trimmedInitialQuery; - addSearch({ query: trimmedInitialQuery }); - }, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]); + const addedKey = `${trimmedInitialQuery}::${competitorKey}`; + if (lastAddedKeyRef.current === addedKey) return; + lastAddedKeyRef.current = addedKey; + addSearch({ + query: trimmedInitialQuery, + competitors: competitorKey ? competitorKey.split(",") : [], + }); + }, [ + hasActiveQuery, + lookupQuery.isSuccess, + trimmedInitialQuery, + competitorKey, + addSearch, + ]); const handleSubmit = (event: FormEvent) => { event.preventDefault(); const trimmed = query.trim(); if (trimmed.length === 0) { - setValidationError("Enter a brand name or domain"); + setValidationError({ + field: "query", + message: "Enter a brand name or domain", + }); return; } if (trimmed.length > BRAND_LOOKUP_MAX_INPUT_LENGTH) { - setValidationError( - `Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`, - ); + setValidationError({ + field: "query", + message: `Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`, + }); + return; + } + const competitors = parseCompetitorList(competitorsInput); + // Mirror the server's input schema (per-item max) and its competitor + // resolution (a competitor that resolves to the target is dropped) so the + // user gets an inline message instead of a generic server error or a + // silently missing Share of Voice section. + const tooLong = competitors.find( + (competitor) => competitor.length > BRAND_LOOKUP_MAX_INPUT_LENGTH, + ); + if (tooLong) { + setValidationError({ + field: "competitors", + message: `Keep each competitor under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`, + }); + return; + } + const targetValue = detectTarget(trimmed).value.toLowerCase(); + const matchesTarget = competitors.find( + (competitor) => + detectTarget(competitor).value.toLowerCase() === targetValue, + ); + if (matchesTarget) { + setValidationError({ + field: "competitors", + message: `"${matchesTarget}" matches the brand you're looking up — remove it from competitors`, + }); return; } setValidationError(null); - onQueryChange(trimmed); + onSearchChange(trimmed, competitors); }; - // The query input is reset whenever the URL `q` changes — including the - // browser-back path and Cmd+click navigation. This keeps local form state - // in sync with the URL source-of-truth. + // The form inputs are reset whenever the URL `q`/`c` changes — including the + // browser-back path and Cmd+click navigation. This keeps local form state in + // sync with the URL source-of-truth. Depend on the stable `competitorKey` + // string (not the fresh-each-render `initialCompetitors` array) so typing in + // the competitor field isn't clobbered on every render. useEffect(() => { setQuery(initialQuery); + setCompetitorsInput(competitorKey.split(",").join(", ")); setValidationError(null); - }, [initialQuery]); + }, [initialQuery, competitorKey]); const isLoading = hasActiveQuery && lookupQuery.isPending; const errorMessage = @@ -159,7 +223,7 @@ function BrandLookupPageInner({ ) : planGate.isFreePlan ? ( ) : ( @@ -170,6 +234,11 @@ function BrandLookupPageInner({ setQuery(next); if (validationError) setValidationError(null); }} + competitors={competitorsInput} + onCompetitorsChange={(next) => { + setCompetitorsInput(next); + if (validationError) setValidationError(null); + }} onSubmit={handleSubmit} isLoading={isLoading} validationError={validationError} @@ -194,7 +263,7 @@ function BrandLookupPageInner({ from="/p/$projectId/brand-lookup" to="/p/$projectId/brand-lookup" params={{ projectId }} - search={{ q: undefined }} + search={{ q: undefined, c: undefined }} replace className="btn btn-ghost btn-sm gap-2 px-0 text-base-content/70 hover:bg-transparent" > @@ -202,7 +271,7 @@ function BrandLookupPageInner({ Recent searches - + ) : !errorMessage ? ( { - const textFields = [row.url, row.domain] + const textFields = [ + row.url, + row.domain, + ...row.keywords.map((keyword) => keyword.question), + ] .filter((v): v is string => Boolean(v)) .join(" "); diff --git a/src/client/features/ai-search/components/BrandLookupCitationTables.tsx b/src/client/features/ai-search/components/BrandLookupCitationTables.tsx index 5f63959..1f4d92f 100644 --- a/src/client/features/ai-search/components/BrandLookupCitationTables.tsx +++ b/src/client/features/ai-search/components/BrandLookupCitationTables.tsx @@ -1,11 +1,15 @@ +import { useState } from "react"; import { createColumnHelper, type Table } from "@tanstack/react-table"; -import { ExternalLink } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { ExternalLink, Sparkles } from "lucide-react"; import { AppDataTable } from "@/client/components/table/AppDataTable"; import { SortableHeader } from "@/client/components/table/SortableHeader"; +import { HeaderHelpLabel } from "@/client/features/keywords/components"; import { numericNullsLast } from "@/client/components/table/nullSafeSort"; import { formatCount, - formatPlatformLabel, + PLATFORM_DOT_CLASS, + PLATFORM_SHORT_LABEL, } from "@/client/features/ai-search/platformLabels"; import { formatUrlForDisplay } from "@/client/components/table/url"; import type { BrandLookupResult } from "@/types/schemas/ai-search"; @@ -14,105 +18,327 @@ type TopPageRow = BrandLookupResult["topPages"][number]; type TopQueryRow = BrandLookupResult["topQueries"][number]; type PlatformKey = TopPageRow["platform"]; -const PLATFORM_BADGE_CLASS: Record = { - chat_gpt: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500", - google: "border-sky-500/40 bg-sky-500/10 text-sky-500", -}; - -function PlatformBadge({ platform }: { platform: PlatformKey }) { +/** Uppercase column header with a hover/focus popover explaining the column. */ +function HeaderWithHelp({ + label, + helpText, +}: { + label: string; + helpText: string; +}) { return ( - - {formatPlatformLabel(platform)} + + ); } +const PLATFORM_HELP = + "Which AI surface produced the answer — ChatGPT or Google AI Overview."; + +/** + * Platform indicator used only when a table actually spans >1 platform. A dot + + * short label replaces the old full-width pill that repeated identically on + * every row. + */ +function PlatformCell({ platform }: { platform: PlatformKey }) { + return ( + + + {PLATFORM_SHORT_LABEL[platform]} + + ); +} + +function urlPath(rawUrl: string): string { + try { + const url = new URL(rawUrl); + const path = `${url.pathname}${url.search}`; + return path === "/" ? "" : path; + } catch { + return ""; + } +} + +function normalizeDomain(value: string): string { + return value.replace(/^www\./i, "").toLowerCase(); +} + +/** + * The lookup targets a domain with include_subdomains, so the target's own + * pages can surface under any subdomain (docs.acme.com for acme.com) — those + * must get the "You" badge too. + */ +function isTargetDomain(domain: string, targetDomain: string): boolean { + const candidate = normalizeDomain(domain); + const target = normalizeDomain(targetDomain); + return candidate === target || candidate.endsWith(`.${target}`); +} + +/** Domain-led cited page: bold domain + truncated path, links out. */ +function PageUrlCell({ + row, + targetDomain, +}: { + row: TopPageRow; + targetDomain: string | null; +}) { + const path = urlPath(row.url); + const isOwn = + targetDomain != null && + row.domain != null && + isTargetDomain(row.domain, targetDomain); + + return ( + + + + {row.domain ?? formatUrlForDisplay(row.url)} + + {isOwn ? ( + You + ) : null} + + + {path ? ( + + {path} + + ) : null} + + ); +} + +/** + * The prompts (keywords) whose answers cited this page. Shows the top 3 inline; + * if there are more, a "+N more" toggle reveals the rest. Each prompt links into + * Prompt Explorer prefilled with it. + */ +function KeywordsCell({ + keywords, + projectId, + brand, +}: { + keywords: TopPageRow["keywords"]; + projectId: string; + brand: string; +}) { + const [expanded, setExpanded] = useState(false); + + if (keywords.length === 0) { + return ; + } + + const visible = expanded ? keywords : keywords.slice(0, 3); + const remaining = keywords.length - visible.length; + + return ( +
+
    + {visible.map((keyword) => ( +
  • + + + {keyword.question} + + + {formatCount(keyword.aiSearchVolume)} vol. + + +
  • + ))} +
+ {keywords.length > 3 ? ( + + ) : null} +
+ ); +} + const pagesHelper = createColumnHelper(); const queriesHelper = createColumnHelper(); -export const topPagesColumns = [ - pagesHelper.accessor("url", { - id: "url", - header: () => URL, - enableSorting: false, - cell: ({ row }) => ( - <> - - - {formatUrlForDisplay(row.original.url)} - - - - {row.original.domain ? ( -

{row.original.domain}

- ) : null} - - ), - }), - pagesHelper.accessor("platform", { - id: "platform", - header: () => Platform, - enableSorting: false, - cell: ({ getValue }) => , - }), - pagesHelper.accessor("mentions", { - id: "mentions", - header: ({ column }) => ( - - ), - cell: ({ getValue }) => ( - {formatCount(getValue())} - ), - sortingFn: numericNullsLast, - sortDescFirst: true, - }), -]; +export function buildTopPagesColumns({ + showPlatform, + targetDomain, + projectId, + brand, +}: { + showPlatform: boolean; + targetDomain: string | null; + projectId: string; + brand: string; +}) { + return [ + pagesHelper.accessor("url", { + id: "url", + header: () => ( + + ), + enableSorting: false, + cell: ({ row }) => ( + + ), + }), + ...(showPlatform + ? [ + pagesHelper.accessor("platform", { + id: "platform", + header: () => ( + + ), + enableSorting: false, + cell: ({ getValue }) => , + }), + ] + : []), + pagesHelper.display({ + id: "keywords", + header: () => ( + + ), + cell: ({ row }) => ( + + ), + }), + pagesHelper.accessor("capturedVolume", { + id: "capturedVolume", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => ( + {formatCount(getValue())} + ), + sortingFn: numericNullsLast, + sortDescFirst: true, + }), + ]; +} -export const topQueriesColumns = [ - queriesHelper.accessor("question", { - id: "question", - header: () => Query, - enableSorting: false, - cell: ({ row }) => ( - <> -

{row.original.question}

- {row.original.brandsMentioned.length > 0 ? ( -

- Brands: {row.original.brandsMentioned.slice(0, 5).join(", ")} -

- ) : null} - - ), - }), - queriesHelper.accessor("platform", { - id: "platform", - header: () => Platform, - enableSorting: false, - cell: ({ getValue }) => , - }), - queriesHelper.accessor("aiSearchVolume", { - id: "aiSearchVolume", - header: ({ column }) => ( - - ), - cell: ({ getValue }) => ( - {formatCount(getValue())} - ), - sortingFn: numericNullsLast, - sortDescFirst: true, - }), -]; +export function buildTopQueriesColumns({ + showPlatform, + projectId, + brand, +}: { + showPlatform: boolean; + projectId: string; + brand: string; +}) { + return [ + queriesHelper.accessor("question", { + id: "question", + header: () => ( + + ), + enableSorting: false, + cell: ({ row }) => ( + <> +

{row.original.question}

+ {row.original.brandsMentioned.length > 0 ? ( +

+ Brands: {row.original.brandsMentioned.slice(0, 5).join(", ")} +

+ ) : null} + + ), + }), + ...(showPlatform + ? [ + queriesHelper.accessor("platform", { + id: "platform", + header: () => ( + + ), + enableSorting: false, + cell: ({ getValue }) => , + }), + ] + : []), + queriesHelper.accessor("aiSearchVolume", { + id: "aiSearchVolume", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => ( + {formatCount(getValue())} + ), + sortingFn: numericNullsLast, + sortDescFirst: true, + }), + queriesHelper.display({ + id: "action", + header: () => Actions, + meta: { cellClassName: "w-px whitespace-nowrap text-right align-top" }, + cell: ({ row }) => ( + + + + + + ), + }), + ]; +} export function TopPagesTable({ table }: { table: Table }) { if (table.getRowModel().rows.length === 0) { return (

- No cited pages returned. + No cited sources to show.

); } @@ -142,6 +368,7 @@ function BrandLookupTable({ return ( "group transition-colors hover:bg-base-200/40"} getCellClassName={(_, columnId) => cellClassName( columnId, @@ -161,6 +388,9 @@ function cellClassName( if (columnId === urlLikeColumnId) { return "min-w-80 max-w-2xl align-top"; } + if (columnId === "keywords") { + return "max-w-lg align-top"; + } if (isNumeric) { return "whitespace-nowrap text-right align-top"; } diff --git a/src/client/features/ai-search/components/BrandLookupCitationsCard.tsx b/src/client/features/ai-search/components/BrandLookupCitationsCard.tsx new file mode 100644 index 0000000..821fb7a --- /dev/null +++ b/src/client/features/ai-search/components/BrandLookupCitationsCard.tsx @@ -0,0 +1,269 @@ +import { useMemo, useState } from "react"; +import { type SortingState } from "@tanstack/react-table"; +import { ChevronDown, Download, Sheet, SlidersHorizontal } from "lucide-react"; +import { useAppTable } from "@/client/components/table/AppDataTable"; +import { exportTableToSheets } from "@/client/lib/exportToSheets"; +import { + buildBrandLookupExport, + downloadBrandLookupCsv, +} from "@/client/features/ai-search/components/brandLookupExport"; +import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel"; +import { + TopPagesTable, + TopQueriesTable, + buildTopPagesColumns, + buildTopQueriesColumns, +} from "@/client/features/ai-search/components/BrandLookupCitationTables"; +import { + formatPlatformLabel, + PLATFORM_DOT_CLASS, +} from "@/client/features/ai-search/platformLabels"; +import { + filterQueries, + filterTopPages, +} from "@/client/features/ai-search/brandLookupFiltering"; +import { useBrandLookupFilters } from "@/client/features/ai-search/useBrandLookupFilters"; +import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; + +const DEFAULT_PAGES_SORT: SortingState = [{ id: "capturedVolume", desc: true }]; +const DEFAULT_QUERIES_SORT: SortingState = [ + { id: "aiSearchVolume", desc: true }, +]; + +// DaisyUI focus-dropdowns stay open until the active element blurs. +function closeExportMenu(): void { + const active = document.activeElement; + if (active instanceof HTMLElement) active.blur(); +} + +export function CitationTabsCard({ + result, + projectId, +}: { + result: BrandLookupResult; + projectId: string; +}) { + const [activeTab, setActiveTab] = useState("queries"); + const [pagesSort, setPagesSort] = useState(DEFAULT_PAGES_SORT); + const [queriesSort, setQueriesSort] = + useState(DEFAULT_QUERIES_SORT); + const filters = useBrandLookupFilters(); + + // The platform column only earns its place when a tab actually spans >1 + // platform; otherwise it repeats one value on every row. + const queryPlatforms = [ + ...new Set(result.topQueries.map((query) => query.platform)), + ]; + const pagePlatforms = [ + ...new Set(result.topPages.map((page) => page.platform)), + ]; + const showQueryPlatform = queryPlatforms.length > 1; + const showPagePlatform = pagePlatforms.length > 1; + const targetDomain = + result.detectedTargetType === "domain" ? result.resolvedTarget : null; + + const filteredPages = useMemo( + () => filterTopPages(result.topPages, filters.pages.values), + [result.topPages, filters.pages.values], + ); + const filteredQueries = useMemo( + () => filterQueries(result.topQueries, filters.queries.values), + [result.topQueries, filters.queries.values], + ); + + const pagesColumns = useMemo( + () => + buildTopPagesColumns({ + showPlatform: showPagePlatform, + targetDomain, + projectId, + brand: result.resolvedTarget, + }), + [showPagePlatform, targetDomain, projectId, result.resolvedTarget], + ); + const queriesColumns = useMemo( + () => + buildTopQueriesColumns({ + showPlatform: showQueryPlatform, + projectId, + brand: result.resolvedTarget, + }), + [showQueryPlatform, projectId, result.resolvedTarget], + ); + + const pagesTable = useAppTable({ + data: filteredPages, + columns: pagesColumns, + state: { sorting: pagesSort }, + onSortingChange: setPagesSort, + withSorting: true, + // Stable identity (default is the array index): KeywordsCell holds + // expanded state, which must follow the page when filtering/sorting + // reorders rows, not stick to whatever row lands in the same slot. + getRowId: (row) => `${row.platform}:${row.url}`, + }); + const queriesTable = useAppTable({ + data: filteredQueries, + columns: queriesColumns, + state: { sorting: queriesSort }, + onSortingChange: setQueriesSort, + withSorting: true, + }); + + // Not memoized: TanStack's `getSortedRowModel()` is internally cached, and + // memoing on the table refs alone (which are stable across renders) would + // serve stale data when sort or filters change. + const exportTable = buildBrandLookupExport( + activeTab, + pagesTable.getSortedRowModel().rows.map((row) => row.original), + queriesTable.getSortedRowModel().rows.map((row) => row.original), + ); + + const handleExportCsv = () => { + downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable); + closeExportMenu(); + }; + + const handleExportSheets = () => { + void exportTableToSheets({ + headers: exportTable.headers, + rows: exportTable.rows, + feature: `brand_lookup_${activeTab}`, + }); + closeExportMenu(); + }; + + const canExport = exportTable.rows.length > 0; + + const currentFilterCount = filters[activeTab].activeFilterCount; + const queriesActive = activeTab === "queries"; + const pagesActive = activeTab === "pages"; + + // When the active tab's platform column is hidden, surface the lone platform + // once here instead of repeating it on every row. + const activePlatforms = pagesActive ? pagePlatforms : queryPlatforms; + const captionPlatform = + activePlatforms.length === 1 ? activePlatforms[0] : null; + + return ( +
+
+
+ + +
+ +
+
+ + Export + +
+
    +
  • + +
  • +
  • + +
  • +
+
+
+ +
+ +
+ +
+ + {activeTab === "pages" ? ( + <> + Pages cited alongside{" "} + + {result.resolvedTarget} + {" "} + in AI answers. Prompt examples come from the fetched sample. + + ) : ( + <> + Fetched sample of prompts whose AI answer cited{" "} + + {result.resolvedTarget} + {" "} + in its text or sources. + + )} + + {captionPlatform ? ( + + + {formatPlatformLabel(captionPlatform)} + + ) : null} +
+ + {filters.showFilters ? ( + + ) : null} + + {activeTab === "pages" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/client/features/ai-search/components/BrandLookupFilterPanel.tsx b/src/client/features/ai-search/components/BrandLookupFilterPanel.tsx index 57dbfe7..faccaf6 100644 --- a/src/client/features/ai-search/components/BrandLookupFilterPanel.tsx +++ b/src/client/features/ai-search/components/BrandLookupFilterPanel.tsx @@ -146,7 +146,7 @@ function TopPagesFilters({
diff --git a/src/client/features/ai-search/components/BrandLookupHistorySection.tsx b/src/client/features/ai-search/components/BrandLookupHistorySection.tsx index 3f39630..292c00d 100644 --- a/src/client/features/ai-search/components/BrandLookupHistorySection.tsx +++ b/src/client/features/ai-search/components/BrandLookupHistorySection.tsx @@ -25,7 +25,13 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) { from="/p/$projectId/brand-lookup" to="/p/$projectId/brand-lookup" params={{ projectId }} - search={{ q: item.query }} + search={{ + q: item.query, + c: + item.competitors.length > 0 + ? item.competitors.join(",") + : undefined, + }} replace className={HISTORY_ITEM_LINK_CLASS} > @@ -33,7 +39,14 @@ export function BrandLookupHistorySection({ projectId, ...props }: Props) { )} renderItem={(item) => ( -

{item.query}

+
+

{item.query}

+ {item.competitors.length > 0 ? ( +

+ vs {item.competitors.join(", ")} +

+ ) : null} +
)} /> ); diff --git a/src/client/features/ai-search/components/BrandLookupResults.tsx b/src/client/features/ai-search/components/BrandLookupResults.tsx index fe7a8a5..a910e22 100644 --- a/src/client/features/ai-search/components/BrandLookupResults.tsx +++ b/src/client/features/ai-search/components/BrandLookupResults.tsx @@ -1,45 +1,23 @@ -import { useMemo, useState } from "react"; -import { type SortingState } from "@tanstack/react-table"; -import { Download, Info, SlidersHorizontal } from "lucide-react"; -import { useAppTable } from "@/client/components/table/AppDataTable"; -import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton"; -import { - buildBrandLookupExport, - downloadBrandLookupCsv, -} from "@/client/features/ai-search/components/brandLookupExport"; +import { Info } from "lucide-react"; import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard"; -import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel"; -import { - TopPagesTable, - TopQueriesTable, - topPagesColumns, - topQueriesColumns, -} from "@/client/features/ai-search/components/BrandLookupCitationTables"; +import { BrandLookupShareOfVoice } from "@/client/features/ai-search/components/BrandLookupShareOfVoice"; +import { CitationTabsCard } from "@/client/features/ai-search/components/BrandLookupCitationsCard"; import { formatCount, formatPlatformLabel, + PLATFORM_DOT_CLASS, } from "@/client/features/ai-search/platformLabels"; -import { - filterQueries, - filterTopPages, -} from "@/client/features/ai-search/brandLookupFiltering"; -import { useBrandLookupFilters } from "@/client/features/ai-search/useBrandLookupFilters"; -import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes"; import type { BrandLookupResult } from "@/types/schemas/ai-search"; type Props = { result: BrandLookupResult; + projectId: string; }; type PlatformRow = BrandLookupResult["perPlatform"][number]; -type MetricKey = "mentions" | "aiSearchVolume" | "impressions"; +type MetricKey = "mentions" | "aiSearchVolume"; -const PLATFORM_DOT_CLASS: Record = { - chat_gpt: "bg-emerald-500", - google: "bg-sky-500", -}; - -export function BrandLookupResults({ result }: Props) { +export function BrandLookupResults({ result, projectId }: Props) { if (!result.hasData) { const erroredPlatforms = result.perPlatform.filter( (p) => p.status === "error", @@ -76,17 +54,27 @@ export function BrandLookupResults({ result }: Props) { } const hasTrendData = result.monthlyVolume.length > 0; + const sov = result.shareOfVoice; return ( -
+
+ + {/* One shared grid so the cards align by construction: stats left, trend + right, Share of Voice flowing into the next free half-width cell — + whichever of trend/SoV is absent, the rest stay column-aligned. A + lone stats card keeps full width instead of half a grid. */}
- + {hasTrendData ? : null} + {sov ? : null}
- + +
); } @@ -109,64 +97,54 @@ function BrandHeader({ result }: { result: BrandLookupResult }) { ); } -function KpiTiles({ result }: { result: BrandLookupResult }) { +function StatsCard({ result }: { result: BrandLookupResult }) { return ( -
- - - +
+
+ + +
); } -function KpiTile({ +function StatBlock({ label, tooltip, - total, + value, perPlatform, metric, }: { label: string; tooltip: string; - total: number | null; + value: number | null; perPlatform: PlatformRow[]; metric: MetricKey; }) { return ( -
-
-

- {label} - - - -

-

- {formatCount(total)} -

-
-
+
+

+ {label} + + + +

+

+ {formatCount(value)} +

+
{perPlatform.map((row) => ( ))} @@ -193,7 +171,7 @@ function PlatformStatRow({ {formatPlatformLabel(row.platform)} {row.platform === "chat_gpt" ? ( @@ -225,156 +203,6 @@ function MentionTrendCard({ result }: { result: BrandLookupResult }) { ); } -const DEFAULT_PAGES_SORT: SortingState = [{ id: "mentions", desc: true }]; -const DEFAULT_QUERIES_SORT: SortingState = [ - { id: "aiSearchVolume", desc: true }, -]; - -function CitationTabsCard({ result }: { result: BrandLookupResult }) { - const [activeTab, setActiveTab] = useState("queries"); - const [pagesSort, setPagesSort] = useState(DEFAULT_PAGES_SORT); - const [queriesSort, setQueriesSort] = - useState(DEFAULT_QUERIES_SORT); - const filters = useBrandLookupFilters(); - - const filteredPages = useMemo( - () => filterTopPages(result.topPages, filters.pages.values), - [result.topPages, filters.pages.values], - ); - const filteredQueries = useMemo( - () => filterQueries(result.topQueries, filters.queries.values), - [result.topQueries, filters.queries.values], - ); - - const pagesTable = useAppTable({ - data: filteredPages, - columns: topPagesColumns, - state: { sorting: pagesSort }, - onSortingChange: setPagesSort, - withSorting: true, - }); - const queriesTable = useAppTable({ - data: filteredQueries, - columns: topQueriesColumns, - state: { sorting: queriesSort }, - onSortingChange: setQueriesSort, - withSorting: true, - }); - - // Not memoized: TanStack's `getSortedRowModel()` is internally cached, and - // memoing on the table refs alone (which are stable across renders) would - // serve stale data when sort or filters change. - const exportTable = buildBrandLookupExport( - activeTab, - pagesTable.getSortedRowModel().rows.map((row) => row.original), - queriesTable.getSortedRowModel().rows.map((row) => row.original), - ); - - const handleExport = () => - downloadBrandLookupCsv(activeTab, result.resolvedTarget, exportTable); - - const canExport = exportTable.rows.length > 0; - - const currentFilterCount = filters[activeTab].activeFilterCount; - const queriesActive = activeTab === "queries"; - const pagesActive = activeTab === "pages"; - - return ( -
-
-
- - -
- -
- - -
-
- -
- -
- -
- {activeTab === "pages" ? ( - <> - Other pages LLMs cited in the same answers that referenced{" "} - - {result.resolvedTarget} - - . Useful for spotting the sources competing for attention alongside - your domain. - - ) : ( - <> - User prompts where the LLM's answer referenced{" "} - - {result.resolvedTarget} - {" "} - in its text or citations. The prompt itself does not have to mention - your domain. - - )} -
- - {filters.showFilters ? ( - - ) : null} - - {activeTab === "pages" ? ( - - ) : ( - - )} -
- ); -} - function formatRelative(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return "just now"; diff --git a/src/client/features/ai-search/components/BrandLookupSearchCard.tsx b/src/client/features/ai-search/components/BrandLookupSearchCard.tsx index e5f3c37..f84e3e9 100644 --- a/src/client/features/ai-search/components/BrandLookupSearchCard.tsx +++ b/src/client/features/ai-search/components/BrandLookupSearchCard.tsx @@ -7,72 +7,114 @@ import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; type Props = { query: string; onQueryChange: (next: string) => void; + competitors: string; + onCompetitorsChange: (next: string) => void; onSubmit: (event: FormEvent) => void; isLoading: boolean; - validationError: string | null; + validationError: { field: "query" | "competitors"; message: string } | null; }; /** - * One brand lookup = 6 DataForSEO calls (3 endpoints × 2 platforms). Measured - * live at ~$0.634 raw via `pnpm billing:brand-lookup`; rounded up to leave - * headroom for per-query variance. + * One brand lookup = 6 DataForSEO calls (aggregated_metrics + top_pages + + * mentions_search × 2 platforms). Rounded up with headroom because + * mentions_search is row-priced at the full 100-row sample per platform. */ -const BRAND_LOOKUP_RAW_COST_USD = 0.65; +const BRAND_LOOKUP_RAW_COST_USD = 0.85; + +/** + * Adding competitors triggers 2 extra cross_aggregated_metrics calls (one per + * platform). Measured live (Jun 2026) at $0.101 each — $0.202 total for a + * 4-group comparison — via `pnpm billing:brand-lookup --competitors=...`. A + * fixed estimate, marked up once at module load exactly like the base. + */ +const BRAND_LOOKUP_COMPETITOR_RAW_COST_USD = 0.2; // Hosted customers are billed the marked-up USD; self-hosted users pay // DataForSEO directly at the raw rate. -const BRAND_LOOKUP_DISPLAYED_COST_USD = isHostedClientAuthMode() - ? applyBillingMarkupUsd(BRAND_LOOKUP_RAW_COST_USD) - : BRAND_LOOKUP_RAW_COST_USD; +const markup = (rawUsd: number) => + isHostedClientAuthMode() ? applyBillingMarkupUsd(rawUsd) : rawUsd; + +const BRAND_LOOKUP_DISPLAYED_COST_USD = markup(BRAND_LOOKUP_RAW_COST_USD); +const BRAND_LOOKUP_COMPETITOR_DISPLAYED_COST_USD = markup( + BRAND_LOOKUP_COMPETITOR_RAW_COST_USD, +); export function BrandLookupSearchCard({ query, onQueryChange, + competitors, + onCompetitorsChange, onSubmit, isLoading, validationError, }: Props) { + const hasCompetitors = competitors.trim().length > 0; + const queryError = validationError?.field === "query"; + const competitorsError = validationError?.field === "competitors"; + return (
-
-
diff --git a/src/client/features/ai-search/components/BrandLookupShareOfVoice.tsx b/src/client/features/ai-search/components/BrandLookupShareOfVoice.tsx new file mode 100644 index 0000000..0bf8e7a --- /dev/null +++ b/src/client/features/ai-search/components/BrandLookupShareOfVoice.tsx @@ -0,0 +1,110 @@ +import { + formatCount, + formatPlatformLabel, +} from "@/client/features/ai-search/platformLabels"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; + +type ShareOfVoice = NonNullable; +type ShareEntry = ShareOfVoice["entries"][number]; + +/** + * Competitor Share of Voice leaderboard. The server sorts entries descending by + * mentions and flags `isTarget`; this component only renders. Bars are scaled to + * the leader (the exact % is shown on every row, so nothing is hidden) so a + * dominant competitor reads as a full bar and small shares stay visible. + */ +export function BrandLookupShareOfVoice({ + shareOfVoice, +}: { + shareOfVoice: ShareOfVoice; +}) { + const target = shareOfVoice.entries.find((entry) => entry.isTarget) ?? null; + const maxPct = Math.max( + 0, + ...shareOfVoice.entries.map((entry) => entry.sharePct ?? 0), + ); + + return ( +
+
+

Share of Voice

+ {target ? ( + + + {target.label} + {" "} + {target.sharePct == null + ? "· no comparable data" + : `· ${Math.round(target.sharePct)}%`} + + ) : null} +
+ +
    + {shareOfVoice.entries.map((entry, index) => ( + + ))} +
+ + {/* Captions only the platforms actually summed — when one platform's + cross_aggregated call failed, the leaderboard must not claim both. */} +

+ Mentions share across{" "} + {shareOfVoice.platforms.map(formatPlatformLabel).join(" and ")} · bars + relative to the leader. +

+
+ ); +} + +function LeaderboardRow({ + entry, + rank, + maxPct, +}: { + entry: ShareEntry; + rank: number; + maxPct: number; +}) { + const hasData = entry.mentions != null && entry.sharePct != null; + const barWidth = + hasData && maxPct > 0 ? ((entry.sharePct ?? 0) / maxPct) * 100 : 0; + + return ( +
  • + {rank} +
    +
    + {entry.label} + {entry.isTarget ? ( + You + ) : null} + + {/* Null mentions = "no data"; render a dash, not zero. */} + {entry.mentions == null ? "—" : formatCount(entry.mentions)} + +
    +
    +
    +
    +
    + + {hasData ? `${Math.round(entry.sharePct ?? 0)}%` : "—"} + +
  • + ); +} diff --git a/src/client/features/ai-search/components/brandLookupExport.ts b/src/client/features/ai-search/components/brandLookupExport.ts index 6584a85..a5a8ad0 100644 --- a/src/client/features/ai-search/components/brandLookupExport.ts +++ b/src/client/features/ai-search/components/brandLookupExport.ts @@ -14,12 +14,21 @@ export function buildBrandLookupExport( ): { headers: string[]; rows: CsvValue[][] } { if (tab === "pages") { return { - headers: ["URL", "Domain", "Platform", "Mentions"], + headers: [ + "URL", + "Domain", + "Platform", + "Source mentions", + "Source AI search volume", + "Fetched-sample prompt examples", + ], rows: sortedPages.map((row) => [ row.url, row.domain ?? "", formatPlatformLabel(row.platform), row.mentions ?? "", + row.capturedVolume ?? "", + row.keywords.map((keyword) => keyword.question).join("; "), ]), }; } diff --git a/src/client/features/ai-search/platformLabels.ts b/src/client/features/ai-search/platformLabels.ts index 5dd2735..e42ddbe 100644 --- a/src/client/features/ai-search/platformLabels.ts +++ b/src/client/features/ai-search/platformLabels.ts @@ -48,6 +48,17 @@ export function formatPlatformLabel(platform: "chat_gpt" | "google"): string { return MENTION_PLATFORM_LABELS[platform]; } +/** Shared per-platform accent dot + short label for compact table/KPI rows. */ +export const PLATFORM_DOT_CLASS: Record<"chat_gpt" | "google", string> = { + chat_gpt: "bg-emerald-500", + google: "bg-sky-500", +}; + +export const PLATFORM_SHORT_LABEL: Record<"chat_gpt" | "google", string> = { + chat_gpt: "ChatGPT", + google: "Google", +}; + export function formatModelLabel(model: PromptExplorerModel): string { return MODEL_LABELS[model]; } diff --git a/src/client/features/ai-search/useBrandLookupFilters.ts b/src/client/features/ai-search/useBrandLookupFilters.ts index ad7ed6b..986fb78 100644 --- a/src/client/features/ai-search/useBrandLookupFilters.ts +++ b/src/client/features/ai-search/useBrandLookupFilters.ts @@ -8,7 +8,10 @@ import { } from "./brandLookupFilterTypes"; import { countActiveFilters } from "./brandLookupFiltering"; -const STORAGE_KEY_PREFIX = "brand-lookup-filters:"; +// v3: the pages tab returned to provider page-level metrics after a brief +// sampled-prompt scale. Bump the prefix so local min/max filters do not carry +// between incompatible metric scales. +const STORAGE_KEY_PREFIX = "brand-lookup-filters-v3:"; type FilterValues = Record; diff --git a/src/client/hooks/useBrandLookupSearchHistory.ts b/src/client/hooks/useBrandLookupSearchHistory.ts index 193e608..9ac41c4 100644 --- a/src/client/hooks/useBrandLookupSearchHistory.ts +++ b/src/client/hooks/useBrandLookupSearchHistory.ts @@ -3,6 +3,8 @@ import { useTimestampedSearchHistory } from "@/client/hooks/useTimestampedSearch const brandLookupSearchBodySchema = z.object({ query: z.string(), + // Optional/defaulted so pre-existing history entries (query only) still parse. + competitors: z.array(z.string()).optional().default([]), }); type BrandLookupSearchBody = z.infer; @@ -15,6 +17,10 @@ export function useBrandLookupSearchHistory(projectId: string) { return useTimestampedSearchHistory({ storageKey: `brand-lookup-search-history:${projectId}`, bodySchema: brandLookupSearchBodySchema, - isSame: (a, b) => a.query === b.query, + // Competitor set is part of the identity: a plain lookup must not replace + // the saved (already paid for) Share-of-Voice comparison of the same brand. + isSame: (a, b) => + a.query === b.query && + a.competitors.join(",") === b.competitors.join(","), }); } diff --git a/src/routes/_project/p/$projectId/brand-lookup.tsx b/src/routes/_project/p/$projectId/brand-lookup.tsx index 94efbfb..fa832ae 100644 --- a/src/routes/_project/p/$projectId/brand-lookup.tsx +++ b/src/routes/_project/p/$projectId/brand-lookup.tsx @@ -10,17 +10,24 @@ export const Route = createFileRoute("/_project/p/$projectId/brand-lookup")({ function BrandLookupRoute() { const { projectId } = Route.useParams(); const navigate = useNavigate({ from: Route.fullPath }); - const { q = "" } = Route.useSearch(); + // `c` is already an opaque competitor string array via the schema transform. + const { q = "", c = [] } = Route.useSearch(); return ( { + initialCompetitors={c} + onSearchChange={(nextQuery, nextCompetitors) => { void navigate({ search: (prev) => ({ ...prev, q: nextQuery.trim() || undefined, + // One serialization site: comma-join the competitor list. + c: + nextCompetitors.length > 0 + ? nextCompetitors.join(",") + : undefined, }), replace: true, }); diff --git a/src/server/features/ai-search/services/brandLookup.test.ts b/src/server/features/ai-search/services/brandLookup.test.ts new file mode 100644 index 0000000..da70be1 --- /dev/null +++ b/src/server/features/ai-search/services/brandLookup.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("cloudflare:workers", () => ({ waitUntil: vi.fn() })); + +const { dataforseoClientMock, cacheMock } = vi.hoisted(() => ({ + dataforseoClientMock: { + aiSearch: { + aggregatedMetrics: vi.fn(), + topPages: vi.fn(), + mentionsSearch: vi.fn(), + crossAggregatedMetrics: vi.fn(), + }, + }, + cacheMock: { + buildCacheKey: vi.fn(async (_prefix: string, params: unknown) => + JSON.stringify(params), + ), + getCached: vi.fn(), + setCached: vi.fn(async () => undefined), + }, +})); + +vi.mock("@/server/lib/dataforseo", () => { + return { + CHATGPT_LANGUAGE_CODE: "en", + CHATGPT_LOCATION_CODE: 2840, + buildLlmTarget: vi.fn( + ({ type, value }: { type: "domain" | "keyword"; value: string }) => + type === "domain" ? { domain: value } : { keyword: value }, + ), + createDataforseoClient: vi.fn(() => dataforseoClientMock), + }; +}); + +vi.mock("@/server/lib/r2-cache", () => cacheMock); + +import { getBrandLookup } from "./brandLookup"; +import { shapeResult, type ShapeArgs } from "./brandLookupShaping"; +import { resolveCompetitorGroups } from "./shareOfVoice"; +import { brandLookupSearchSchema } from "@/types/schemas/ai-search"; +import type { + LlmMentionItem, + LlmTopPagesItem, +} from "@/server/lib/dataforseoLlmSchemas"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; + +const billingCustomer: BillingCustomerContext = { + organizationId: "org_123", + userId: "user_123", + userEmail: "alice@example.com", +}; + +type PlatformBundle = { + aggregated: { platform?: Array> | null }; + topPages: LlmTopPagesItem[]; + mentions: LlmMentionItem[]; + complete: boolean; +}; + +function platformBundle( + platform: "chat_gpt" | "google", + mentions: number | null, + aiSearchVolume: number | null, +): ShapeArgs["platformBundles"][number] { + return { + platform, + status: "success", + bundle: { + aggregated: { + platform: [ + { + key: platform, + mentions, + ai_search_volume: aiSearchVolume, + // Deprecated field still present in upstream payloads; must be + // ignored end-to-end. + impressions: 999, + }, + ], + }, + topPages: [], + mentions: [], + complete: true, + } as PlatformBundle, + }; +} + +function baseArgs(overrides: Partial): ShapeArgs { + return { + query: "acme", + detected: { type: "keyword", value: "acme" }, + platformBundles: [ + platformBundle("chat_gpt", 10, 100), + platformBundle("google", 5, 50), + ], + crossOutcomes: [], + competitorKeys: [], + userLocationCode: 2840, + userLanguageCode: "en", + ...overrides, + }; +} + +function resetBrandLookupMocks(): void { + vi.clearAllMocks(); + cacheMock.getCached.mockResolvedValue(null); + cacheMock.setCached.mockResolvedValue(undefined); + dataforseoClientMock.aiSearch.aggregatedMetrics.mockResolvedValue({ + platform: [{ key: "google", mentions: 5, ai_search_volume: 50 }], + }); + dataforseoClientMock.aiSearch.topPages.mockImplementation( + async ({ platform }: { platform: "chat_gpt" | "google" }) => [ + topPage(`https://${platform}.example/source`, platform, 3, 300), + ], + ); + dataforseoClientMock.aiSearch.mentionsSearch.mockImplementation( + async ({ platform }: { platform: "chat_gpt" | "google" }) => [ + citedMention("best source", 100, [`https://${platform}.example/source`]), + ], + ); + dataforseoClientMock.aiSearch.crossAggregatedMetrics.mockResolvedValue([]); +} + +describe("getBrandLookup", () => { + it("fetches top_pages as part of the base lookup", async () => { + resetBrandLookupMocks(); + + await getBrandLookup( + { + projectId: "project_123", + query: "acme.com", + competitors: [], + locationCode: 2840, + languageCode: "en", + }, + billingCustomer, + ); + + expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledTimes(2); + expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "chat_gpt", + itemsListLimit: 10, + }), + ); + expect(dataforseoClientMock.aiSearch.topPages).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "google", + itemsListLimit: 10, + }), + ); + expect(cacheMock.setCached).toHaveBeenCalledTimes(1); + }); + + it("does not cache a renderable partial result when top_pages fails", async () => { + resetBrandLookupMocks(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + dataforseoClientMock.aiSearch.topPages.mockRejectedValueOnce( + new Error("top pages failed"), + ); + + const result = await getBrandLookup( + { + projectId: "project_123", + query: "acme.com", + competitors: [], + locationCode: 2840, + languageCode: "en", + }, + billingCustomer, + ); + + expect(result.hasData).toBe(true); + expect(cacheMock.setCached).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("uses semantic cache keys and reapplies the current display query", async () => { + resetBrandLookupMocks(); + cacheMock.getCached.mockResolvedValueOnce({ + ...shapeResult(baseArgs({})), + query: "Nike", + resolvedTarget: "Nike", + }); + + const result = await getBrandLookup( + { + projectId: "project_123", + query: "nike", + competitors: ["ADIDAS"], + locationCode: 2840, + languageCode: "en", + }, + billingCustomer, + ); + + expect(result.query).toBe("nike"); + expect(cacheMock.buildCacheKey).toHaveBeenCalledWith( + "ai-search:brand-lookup", + expect.objectContaining({ + targetValue: "nike", + competitors: "adidas", + }), + ); + expect( + dataforseoClientMock.aiSearch.aggregatedMetrics, + ).not.toHaveBeenCalled(); + }); +}); + +describe("resolveCompetitorGroups", () => { + it("dedupes case-insensitively and drops target collisions", () => { + // DataForSEO matches keyword targets case-insensitively, so "Nike" and + // "nike" would be two paid groups returning identical counts. + const groups = resolveCompetitorGroups("Nike", [ + "nike", + "Adidas", + "ADIDAS", + "puma.com", + "www.PUMA.com", + ]); + expect(groups.map((g) => g.label)).toEqual(["Adidas", "puma.com"]); + }); +}); + +describe("brandLookupSearchSchema — `c` competitor param", () => { + it("parses a raw comma-separated string from the URL", () => { + expect(brandLookupSearchSchema.parse({ c: "nike, adidas" }).c).toEqual([ + "nike", + "adidas", + ]); + }); + + it("accepts an already-parsed array (TanStack re-validates its own output)", () => { + // navigate() feeds the previous transformed output (a string[]) back through + // validateSearch — this must not throw "expected string, received array". + expect(brandLookupSearchSchema.parse({ c: ["nike", "adidas"] }).c).toEqual([ + "nike", + "adidas", + ]); + }); + + it("dedupes and caps at 5 regardless of input form", () => { + const many = ["a", "a", "b", "c", "d", "e", "f"]; + expect(brandLookupSearchSchema.parse({ c: many }).c).toEqual([ + "a", + "b", + "c", + "d", + "e", + ]); + }); + + it("leaves `c` undefined when absent", () => { + expect(brandLookupSearchSchema.parse({}).c).toBeUndefined(); + }); +}); + +function citedMention( + question: string, + aiSearchVolume: number | null, + urls: string[], +): LlmMentionItem { + return { + question, + ai_search_volume: aiSearchVolume, + sources: urls.map((url) => ({ url })), + }; +} + +function topPage( + url: string, + platform: "chat_gpt" | "google", + mentions: number | null, + aiSearchVolume: number | null, +): LlmTopPagesItem { + return { + key: url, + platform: [{ key: platform, mentions, ai_search_volume: aiSearchVolume }], + }; +} diff --git a/src/server/features/ai-search/services/brandLookup.ts b/src/server/features/ai-search/services/brandLookup.ts index 1dc8e93..260397f 100644 --- a/src/server/features/ai-search/services/brandLookup.ts +++ b/src/server/features/ai-search/services/brandLookup.ts @@ -1,5 +1,4 @@ import { waitUntil } from "cloudflare:workers"; -import { sortBy } from "remeda"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { createDataforseoClient } from "@/server/lib/dataforseo"; import { @@ -8,20 +7,25 @@ import { CHATGPT_LOCATION_CODE, type LlmPlatform, } from "@/server/lib/dataforseo"; -import type { - LlmAggregatedTotal, - LlmMentionItem, - LlmTopPagesItem, -} from "@/server/lib/dataforseoLlmSchemas"; +import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas"; import { AppError } from "@/server/lib/errors"; import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache"; -import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl"; +import { + resolveCompetitorGroups, + type CompetitorGroup, + type CrossOutcome, +} from "@/server/features/ai-search/services/shareOfVoice"; +import { + shapeResult, + type PlatformBundle, + type PlatformOutcome, +} from "@/server/features/ai-search/services/brandLookupShaping"; import { brandLookupResultSchema, type BrandLookupInput, type BrandLookupResult, } from "@/types/schemas/ai-search"; -import { detectTarget } from "@/server/features/ai-search/targetDetection"; +import { detectTarget } from "@/shared/targetDetection"; /** * Brand Lookup is the AI-search analog of Domain Overview. The user types a @@ -35,39 +39,77 @@ const BRAND_LOOKUP_TTL_SECONDS = 24 * 60 * 60; const PLATFORMS: LlmPlatform[] = ["chat_gpt", "google"]; -const TOP_PAGES_PER_PLATFORM = 10; -const TOP_QUERIES_PER_PLATFORM = 25; +// Prompt rows supply explainable examples for cited pages. Ranked source rows +// come from top_pages so the table is not limited to this sample. +const MENTIONS_PER_PLATFORM = 100; +const TOP_SOURCES_PER_PLATFORM = 10; export async function getBrandLookup( input: BrandLookupInput, billingCustomer: BillingCustomerContext, ): Promise { const detected = detectTarget(input.query); + const competitorGroups = resolveCompetitorGroups( + detected.value, + input.competitors, + ); + // Changing this key's param set orphans every pre-deploy cache entry; with a + // 24h TTL that's at most one re-charged lookup per cached target — accepted + // rather than maintaining parallel legacy-shape parsing. const cacheKey = await buildCacheKey("ai-search:brand-lookup", { organizationId: billingCustomer.organizationId, projectId: input.projectId, targetType: detected.type, - targetValue: detected.value, + // Values are lowercased for DataForSEO's matching semantics. Competitors + // are canonical detected values too, so equivalent casing/order shares one + // paid cache entry. + targetValue: detected.value.toLowerCase(), + competitors: competitorGroups + .map((g) => g.detected.value.toLowerCase()) + .toSorted() + .join("|"), locationCode: input.locationCode, languageCode: input.languageCode, }); const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey)); - if (cached.success) return cached.data; + if (cached.success) { + return { + ...cached.data, + query: input.query, + resolvedTarget: detected.value, + }; + } const dataforseo = createDataforseoClient(billingCustomer); - // Settle each platform independently so a failure in one doesn't discard - // the other (which the caller already paid for via meterDataforseoCall). - const settled = await Promise.allSettled( - PLATFORMS.map((platform) => - fetchPlatformData(platform, detected, input, dataforseo), - ), - ); + // Settle each platform independently so a failure in one doesn't discard the + // other. Keep the metered DataForSEO calls sequenced: in hosted mode each + // call checks balance before execution and records spend after, so parallel + // fan-out can overrun a low remaining balance. + const settled: Array> = []; + for (const platform of PLATFORMS) { + settled.push( + await settle(() => + fetchPlatformData(platform, detected, input, dataforseo), + ), + ); + } rethrowIfBlockingAiSearchError(settled); + const crossSettled = + competitorGroups.length > 0 + ? await settle(() => + fetchCrossAggregated(detected, competitorGroups, input, dataforseo), + ) + : ({ status: "fulfilled", value: [] } as PromiseFulfilledResult< + CrossOutcome[] + >); + if (crossSettled.status === "rejected") throw crossSettled.reason; + const crossOutcomes = crossSettled.value; + const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => { const platform = PLATFORMS[i]; if (settledResult.status === "fulfilled") { @@ -84,13 +126,20 @@ export async function getBrandLookup( query: input.query, detected, platformBundles, + crossOutcomes, + competitorKeys: competitorGroups.map((g) => g.label), userLocationCode: input.locationCode, userLanguageCode: input.languageCode, }); - // Only cache when every platform succeeded — otherwise users would see a - // stale partial result for 24h and have no way to retry without busting it. - const allSucceeded = platformBundles.every((b) => b.status === "success"); + // Only cache when every call succeeded — a platform bundle that swallowed a + // failed sub-call into empty fallback data is renderable but must not be + // frozen for 24h with no way to retry; same for a partial SoV miss when + // competitors were requested. + const allSucceeded = + platformBundles.every( + (b) => b.status === "success" && b.bundle?.complete, + ) && crossOutcomes.every((c) => c.status === "success"); if (allSucceeded && result.hasData) { waitUntil( setCached(cacheKey, result, BRAND_LOOKUP_TTL_SECONDS).catch((err) => { @@ -102,23 +151,21 @@ export async function getBrandLookup( return result; } +async function settle( + execute: () => Promise, +): Promise> { + try { + return { status: "fulfilled", value: await execute() }; + } catch (reason) { + return { status: "rejected", reason }; + } +} + type PlatformFetchInput = Pick< BrandLookupInput, "locationCode" | "languageCode" >; -type PlatformBundle = { - aggregated: LlmAggregatedTotal; - topPages: LlmTopPagesItem[]; - mentions: LlmMentionItem[]; -}; - -type PlatformOutcome = { - platform: LlmPlatform; - status: "success" | "error"; - bundle: PlatformBundle | null; -}; - async function fetchPlatformData( platform: LlmPlatform, detected: ReturnType, @@ -136,9 +183,9 @@ async function fetchPlatformData( const languageCode = platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : input.languageCode; - // `allSettled` so one sub-call failing doesn't discard the other two we - // already paid for. Each sub-call is metered independently upstream. - const [aggregated, topPages, mentions] = await Promise.allSettled([ + // Settle sub-calls independently so one failure doesn't discard the others we + // already paid for, but keep them sequenced for hosted billing checks. + const aggregated = await settle(() => dataforseo.aiSearch.aggregatedMetrics({ target, platform, @@ -146,21 +193,25 @@ async function fetchPlatformData( languageCode, internalListLimit: 20, }), + ); + const topPages = await settle(() => dataforseo.aiSearch.topPages({ target, platform, locationCode, languageCode, - itemsListLimit: TOP_PAGES_PER_PLATFORM, + itemsListLimit: TOP_SOURCES_PER_PLATFORM, }), + ); + const mentions = await settle(() => dataforseo.aiSearch.mentionsSearch({ target, platform, locationCode, languageCode, - limit: TOP_QUERIES_PER_PLATFORM, + limit: MENTIONS_PER_PLATFORM, }), - ]); + ); rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]); @@ -176,9 +227,76 @@ async function fetchPlatformData( aggregated: fulfilledOr(aggregated, () => ({}), platform, "aggregated"), topPages: fulfilledOr(topPages, () => [], platform, "topPages"), mentions: fulfilledOr(mentions, () => [], platform, "mentions"), + complete: + aggregated.status === "fulfilled" && + topPages.status === "fulfilled" && + mentions.status === "fulfilled", }; } +/** + * One cross_aggregated_metrics call per platform (ChatGPT forced to US/en), + * each comparing the target against the competitors. Settled per-platform so a + * single failure doesn't discard the other — matching the per-platform + * fan-out in {@link getBrandLookup}. The target's aggregation_key is the + * resolved target value so SoV can flag the target row. + */ +async function fetchCrossAggregated( + detected: ReturnType, + competitors: CompetitorGroup[], + input: PlatformFetchInput, + dataforseo: ReturnType, +): Promise { + const groups = [ + { + key: detected.value, + target: buildLlmTarget({ type: detected.type, value: detected.value }), + }, + ...competitors.map((competitor) => ({ + key: competitor.label, + target: buildLlmTarget({ + type: competitor.detected.type, + value: competitor.detected.value, + }), + })), + ]; + + const settled: Array> = []; + for (const platform of PLATFORMS) { + settled.push( + await settle(() => + dataforseo.aiSearch.crossAggregatedMetrics({ + groups, + platform, + // ChatGPT mentions DB only contains US/en data per DataForSEO docs. + locationCode: + platform === "chat_gpt" + ? CHATGPT_LOCATION_CODE + : input.locationCode, + languageCode: + platform === "chat_gpt" + ? CHATGPT_LANGUAGE_CODE + : input.languageCode, + }), + ), + ); + } + + rethrowIfBlockingAiSearchError(settled); + + return settled.map((result, i) => { + const platform = PLATFORMS[i]; + if (result.status === "fulfilled") { + return { platform, status: "success" as const, items: result.value }; + } + console.error( + `ai-search.brand-lookup.${platform}.cross-aggregated.error:`, + result.reason, + ); + return { platform, status: "error" as const, items: [] }; + }); +} + function rethrowIfBlockingAiSearchError( results: Array>, ): void { @@ -208,197 +326,3 @@ function fulfilledOr( ); return fallback(); } - -type ShapeArgs = { - query: string; - detected: ReturnType; - platformBundles: PlatformOutcome[]; - userLocationCode: number; - userLanguageCode: string; -}; - -function shapeResult(args: ShapeArgs): BrandLookupResult { - const successfulBundles = args.platformBundles.filter( - (b): b is PlatformOutcome & { bundle: PlatformBundle } => - b.status === "success" && b.bundle !== null, - ); - - // ChatGPT data is always fetched US/en (DataForSEO only indexes that - // locale), so when the user picks a non-US/en locale we must not fold its - // numbers into cross-platform totals or the monthly trend — doing so would - // mix two different datasets under one locale label. Per-platform rows - // still render ChatGPT separately with the "US-only" tooltip. - // Match the primary subtag so "en-US"/"en_US" still count as English. - const primaryLanguage = args.userLanguageCode.toLowerCase().split(/[-_]/)[0]; - const chatGptLocaleMatches = - args.userLocationCode === CHATGPT_LOCATION_CODE && - primaryLanguage === CHATGPT_LANGUAGE_CODE; - - const perPlatform = args.platformBundles.map((outcome) => { - if (outcome.status === "error" || !outcome.bundle) { - return { - platform: outcome.platform, - status: "error" as const, - mentions: null, - aiSearchVolume: null, - impressions: null, - }; - } - const platformGroup = outcome.bundle.aggregated.platform?.find( - (entry) => entry.key === outcome.platform, - ); - return { - platform: outcome.platform, - status: "success" as const, - mentions: roundOrNull(platformGroup?.mentions), - aiSearchVolume: roundOrNull(platformGroup?.ai_search_volume), - impressions: roundOrNull(platformGroup?.impressions), - }; - }); - - const aggregatablePlatforms = perPlatform.filter( - (p) => chatGptLocaleMatches || p.platform !== "chat_gpt", - ); - const totalMentions = sumNullable( - aggregatablePlatforms.map((p) => p.mentions), - ); - const totalAiSearchVolume = sumNullable( - aggregatablePlatforms.map((p) => p.aiSearchVolume), - ); - const totalImpressions = sumNullable( - aggregatablePlatforms.map((p) => p.impressions), - ); - - const topPages = sortBy( - successfulBundles.flatMap((bundle) => - bundle.bundle.topPages - .map((page) => { - const safeUrl = safeHttpUrl(page.key); - if (!safeUrl) return null; - return { - url: safeUrl, - domain: safeHostname(safeUrl), - mentions: roundOrNull( - page.platform?.find((entry) => entry.key === bundle.platform) - ?.mentions, - ), - platform: bundle.platform, - }; - }) - .filter((page): page is NonNullable => page !== null), - ), - [(page) => page.mentions ?? 0, "desc"], - ).slice(0, 20); - - const topQueries = sortBy( - successfulBundles.flatMap((bundle) => - bundle.bundle.mentions - .filter( - (item): item is LlmMentionItem & { question: string } => - typeof item.question === "string" && item.question.length > 0, - ) - .map((item) => ({ - question: item.question, - platform: bundle.platform, - aiSearchVolume: roundOrNull(item.ai_search_volume), - firstSeenAt: item.first_response_at ?? null, - lastSeenAt: item.last_response_at ?? null, - citedSources: (item.sources ?? []) - .map((src) => { - const safeUrl = safeHttpUrl(src.url); - if (!safeUrl) return null; - return { - url: safeUrl, - domain: src.domain ?? safeHostname(safeUrl), - title: src.title ?? null, - }; - }) - .filter((src): src is NonNullable => src !== null) - .slice(0, 10), - brandsMentioned: (item.brand_entities ?? []) - .map((entity) => entity.title ?? "") - .filter((title) => title.length > 0) - .slice(0, 20), - })), - ), - [(query) => query.aiSearchVolume ?? 0, "desc"], - ).slice(0, 50); - - const trendBundles = chatGptLocaleMatches - ? successfulBundles - : successfulBundles.filter((b) => b.platform !== "chat_gpt"); - const monthlyVolume = aggregateMonthlyVolume(trendBundles); - - const hasData = - (totalMentions ?? 0) > 0 || - topPages.length > 0 || - topQueries.length > 0 || - monthlyVolume.length > 0; - - return { - query: args.query, - detectedTargetType: args.detected.type, - resolvedTarget: args.detected.value, - fetchedAt: new Date().toISOString(), - hasData, - totalMentions, - totalAiSearchVolume, - totalImpressions, - perPlatform, - topPages, - topQueries, - monthlyVolume, - }; -} - -function sumNullable(values: Array): number | null { - let total = 0; - let hasValue = false; - for (const value of values) { - if (value != null) { - total += value; - hasValue = true; - } - } - return hasValue ? total : null; -} - -function roundOrNull(value: number | null | undefined): number | null { - if (value == null) return null; - return Math.round(value); -} - -/** - * Sum monthly mention volume across all returned mention items, regardless of - * platform. Returns the most recent 12 months in chronological order. - */ -function aggregateMonthlyVolume( - bundles: Array, -): BrandLookupResult["monthlyVolume"] { - const totals = new Map(); - - for (const outcome of bundles) { - for (const mention of outcome.bundle.mentions) { - for (const monthly of mention.monthly_searches ?? []) { - if (monthly.search_volume == null) continue; - const key = `${monthly.year}-${monthly.month}`; - totals.set(key, (totals.get(key) ?? 0) + monthly.search_volume); - } - } - } - - const entries = Array.from(totals.entries()).map(([key, volume]) => { - const [yearStr, monthStr] = key.split("-"); - return { - year: Number(yearStr), - month: Number(monthStr), - volume: Math.round(volume), - }; - }); - - return sortBy( - entries, - [(entry) => entry.year, "asc"], - [(entry) => entry.month, "asc"], - ).slice(-12); -} diff --git a/src/server/features/ai-search/services/brandLookupShaping.test.ts b/src/server/features/ai-search/services/brandLookupShaping.test.ts new file mode 100644 index 0000000..1ae368d --- /dev/null +++ b/src/server/features/ai-search/services/brandLookupShaping.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { shapeResult, type ShapeArgs } from "./brandLookupShaping"; +import type { + LlmCrossAggregatedItem, + LlmMentionItem, + LlmTopPagesItem, +} from "@/server/lib/dataforseoLlmSchemas"; +import { brandLookupResultSchema } from "@/types/schemas/ai-search"; + +function platformBundle( + platform: "chat_gpt" | "google", + mentions: number | null, + aiSearchVolume: number | null, +): ShapeArgs["platformBundles"][number] { + return { + platform, + status: "success", + bundle: { + aggregated: { + platform: [ + { key: platform, mentions, ai_search_volume: aiSearchVolume }, + ], + }, + topPages: [], + mentions: [], + complete: true, + }, + }; +} + +function crossItem( + key: string, + platformMentions: Array<{ key: string; mentions: number | null }>, +): LlmCrossAggregatedItem { + return { + key, + platform: platformMentions.map((p) => ({ + key: p.key, + mentions: p.mentions, + ai_search_volume: null, + })), + }; +} + +function baseArgs(overrides: Partial = {}): ShapeArgs { + return { + query: "acme", + detected: { type: "keyword", value: "acme" }, + platformBundles: [ + platformBundle("chat_gpt", 10, 100), + platformBundle("google", 5, 50), + ], + crossOutcomes: [], + competitorKeys: [], + userLocationCode: 2840, + userLanguageCode: "en", + ...overrides, + }; +} + +describe("shapeResult", () => { + it("excludes ChatGPT from totals and SoV outside US/en", () => { + const result = shapeResult( + baseArgs({ + userLocationCode: 2826, + competitorKeys: ["rival"], + crossOutcomes: [ + { + platform: "chat_gpt", + status: "success", + items: [ + crossItem("acme", [{ key: "chat_gpt", mentions: 90 }]), + crossItem("rival", [{ key: "chat_gpt", mentions: 10 }]), + ], + }, + { + platform: "google", + status: "success", + items: [ + crossItem("acme", [{ key: "google", mentions: 10 }]), + crossItem("rival", [{ key: "google", mentions: 30 }]), + ], + }, + ], + }), + ); + + expect(result.totalMentions).toBe(5); + expect(result.shareOfVoice?.platforms).toEqual(["google"]); + expect(result.shareOfVoice?.entries[0]).toMatchObject({ + label: "rival", + sharePct: 75, + }); + }); + + it("derives top-query cited source domains from urls and caps long output", () => { + const longTitle = "x".repeat(400); + const result = shapeResult( + baseArgs({ + platformBundles: [ + { + platform: "google", + status: "success", + bundle: { + aggregated: { platform: [] }, + topPages: [], + mentions: [ + { + question: "q".repeat(600), + ai_search_volume: 100, + sources: [ + { + url: "https://evil.example/path", + domain: "customer.example", + title: longTitle, + }, + ], + brand_entities: [{ title: "b".repeat(300) }], + } satisfies LlmMentionItem, + ], + complete: true, + }, + }, + ], + }), + ); + + expect(result.topQueries[0].question).toHaveLength(500); + expect(result.topQueries[0].citedSources[0]).toMatchObject({ + url: "https://evil.example/path", + domain: "evil.example", + title: "x".repeat(300), + }); + expect(result.topQueries[0].brandsMentioned[0]).toHaveLength(200); + }); + + it("round-trips through the cache schema", () => { + const topPage: LlmTopPagesItem = { + key: "https://a.com", + platform: [{ key: "google", mentions: 3, ai_search_volume: 300 }], + }; + const result = shapeResult( + baseArgs({ + platformBundles: [ + { + platform: "google", + status: "success", + bundle: { + aggregated: { platform: [] }, + topPages: [topPage], + mentions: [], + complete: true, + }, + }, + ], + }), + ); + + expect(result.topPages[0]).toMatchObject({ + domain: "a.com", + mentions: 3, + capturedVolume: 300, + }); + expect(brandLookupResultSchema.safeParse(result).success).toBe(true); + }); +}); diff --git a/src/server/features/ai-search/services/brandLookupShaping.ts b/src/server/features/ai-search/services/brandLookupShaping.ts new file mode 100644 index 0000000..236a99f --- /dev/null +++ b/src/server/features/ai-search/services/brandLookupShaping.ts @@ -0,0 +1,228 @@ +import { sortBy } from "remeda"; +import { + CHATGPT_LANGUAGE_CODE, + CHATGPT_LOCATION_CODE, + type LlmPlatform, +} from "@/server/lib/dataforseo/ai"; +import type { + LlmAggregatedTotal, + LlmMentionItem, + LlmTopPagesItem, +} from "@/server/lib/dataforseoLlmSchemas"; +import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl"; +import { deriveCitedSources } from "@/server/features/ai-search/services/citedSources"; +import { + computeShareOfVoice, + roundOrNull, + sumNullable, + type CrossOutcome, +} from "@/server/features/ai-search/services/shareOfVoice"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; +import type { detectTarget } from "@/shared/targetDetection"; + +const TOP_QUERIES_PER_PLATFORM = 25; +const TOP_SOURCES_PER_PLATFORM = 10; +const KEYWORDS_PER_SOURCE = 50; +const MAX_URL_LENGTH = 2048; +const MAX_TITLE_LENGTH = 300; +const MAX_QUESTION_LENGTH = 500; +const MAX_BRAND_ENTITY_LENGTH = 200; + +export type PlatformBundle = { + aggregated: LlmAggregatedTotal; + topPages: LlmTopPagesItem[]; + mentions: LlmMentionItem[]; + /** False when one of the sub-calls failed and fell back to empty data. */ + complete: boolean; +}; + +export type PlatformOutcome = { + platform: LlmPlatform; + status: "success" | "error"; + bundle: PlatformBundle | null; +}; + +export type ShapeArgs = { + query: string; + detected: ReturnType; + platformBundles: PlatformOutcome[]; + crossOutcomes: CrossOutcome[]; + /** Labels of the resolved competitor groups, as sent to cross_aggregated. */ + competitorKeys: string[]; + userLocationCode: number; + userLanguageCode: string; +}; + +export function shapeResult(args: ShapeArgs): BrandLookupResult { + const successfulBundles = args.platformBundles.filter( + (b): b is PlatformOutcome & { bundle: PlatformBundle } => + b.status === "success" && b.bundle !== null, + ); + + const primaryLanguage = args.userLanguageCode.toLowerCase().split(/[-_]/)[0]; + const chatGptLocaleMatches = + args.userLocationCode === CHATGPT_LOCATION_CODE && + primaryLanguage === CHATGPT_LANGUAGE_CODE; + + const perPlatform = args.platformBundles.map((outcome) => { + if (outcome.status === "error" || !outcome.bundle) { + return { + platform: outcome.platform, + status: "error" as const, + mentions: null, + aiSearchVolume: null, + }; + } + const platformGroup = outcome.bundle.aggregated.platform?.find( + (entry) => entry.key === outcome.platform, + ); + return { + platform: outcome.platform, + status: "success" as const, + mentions: roundOrNull(platformGroup?.mentions), + aiSearchVolume: roundOrNull(platformGroup?.ai_search_volume), + }; + }); + + const aggregatablePlatforms = perPlatform.filter( + (p) => chatGptLocaleMatches || p.platform !== "chat_gpt", + ); + const totalMentions = sumNullable( + aggregatablePlatforms.map((p) => p.mentions), + ); + const totalAiSearchVolume = sumNullable( + aggregatablePlatforms.map((p) => p.aiSearchVolume), + ); + + const topPages = deriveCitedSources( + successfulBundles.map((bundle) => ({ + platform: bundle.platform, + topPages: bundle.bundle.topPages, + mentions: bundle.bundle.mentions, + })), + { + sourcesPerPlatform: TOP_SOURCES_PER_PLATFORM, + keywordsPerSource: KEYWORDS_PER_SOURCE, + }, + ); + + const topQueries = shapeTopQueries(successfulBundles); + const trendBundles = chatGptLocaleMatches + ? successfulBundles + : successfulBundles.filter((b) => b.platform !== "chat_gpt"); + const monthlyVolume = aggregateMonthlyVolume(trendBundles); + const shareOfVoice = computeShareOfVoice( + chatGptLocaleMatches + ? args.crossOutcomes + : args.crossOutcomes.filter((outcome) => outcome.platform !== "chat_gpt"), + args.detected.value, + args.competitorKeys, + ); + + const hasData = + (totalMentions ?? 0) > 0 || + topPages.length > 0 || + topQueries.length > 0 || + monthlyVolume.length > 0 || + (shareOfVoice?.entries.some((e) => e.mentions != null) ?? false); + + return { + query: args.query, + detectedTargetType: args.detected.type, + resolvedTarget: args.detected.value, + fetchedAt: new Date().toISOString(), + hasData, + totalMentions, + totalAiSearchVolume, + perPlatform, + shareOfVoice, + topPages, + topQueries, + monthlyVolume, + }; +} + +function shapeTopQueries( + bundles: Array, +): BrandLookupResult["topQueries"] { + return sortBy( + bundles.flatMap((bundle) => + sortBy( + bundle.bundle.mentions + .filter( + (item): item is LlmMentionItem & { question: string } => + typeof item.question === "string" && item.question.length > 0, + ) + .map((item) => ({ + question: truncate(item.question, MAX_QUESTION_LENGTH), + platform: bundle.platform, + aiSearchVolume: roundOrNull(item.ai_search_volume), + firstSeenAt: item.first_response_at ?? null, + lastSeenAt: item.last_response_at ?? null, + citedSources: shapeQuerySources(item), + brandsMentioned: (item.brand_entities ?? []) + .map((entity) => entity.title ?? "") + .filter((title) => title.length > 0) + .map((title) => truncate(title, MAX_BRAND_ENTITY_LENGTH)) + .slice(0, 20), + })), + [(query) => query.aiSearchVolume ?? 0, "desc"], + ).slice(0, TOP_QUERIES_PER_PLATFORM), + ), + [(query) => query.aiSearchVolume ?? 0, "desc"], + ); +} + +function shapeQuerySources( + item: LlmMentionItem, +): BrandLookupResult["topQueries"][number]["citedSources"] { + return (item.sources ?? []) + .map((src) => { + const safeUrl = safeHttpUrl(src.url); + if (!safeUrl || safeUrl.length > MAX_URL_LENGTH) return null; + return { + url: safeUrl, + domain: safeHostname(safeUrl), + title: + typeof src.title === "string" + ? truncate(src.title, MAX_TITLE_LENGTH) + : null, + }; + }) + .filter((src): src is NonNullable => src !== null) + .slice(0, 10); +} + +function aggregateMonthlyVolume( + bundles: Array, +): BrandLookupResult["monthlyVolume"] { + const totals = new Map(); + for (const outcome of bundles) { + for (const mention of outcome.bundle.mentions) { + for (const monthly of mention.monthly_searches ?? []) { + if (monthly.search_volume == null) continue; + const key = `${monthly.year}-${monthly.month}`; + totals.set(key, (totals.get(key) ?? 0) + monthly.search_volume); + } + } + } + + const entries = Array.from(totals.entries()).map(([key, volume]) => { + const [yearStr, monthStr] = key.split("-"); + return { + year: Number(yearStr), + month: Number(monthStr), + volume: Math.round(volume), + }; + }); + + return sortBy( + entries, + [(entry) => entry.year, "asc"], + [(entry) => entry.month, "asc"], + ).slice(-12); +} + +function truncate(value: string, maxLength: number): string { + return value.length <= maxLength ? value : value.slice(0, maxLength); +} diff --git a/src/server/features/ai-search/services/citedSources.test.ts b/src/server/features/ai-search/services/citedSources.test.ts new file mode 100644 index 0000000..dfcc5eb --- /dev/null +++ b/src/server/features/ai-search/services/citedSources.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { deriveCitedSources } from "./citedSources"; +import type { + LlmMentionItem, + LlmTopPagesItem, +} from "@/server/lib/dataforseoLlmSchemas"; + +function citedMention( + question: string, + aiSearchVolume: number | null, + urls: string[], +): LlmMentionItem { + return { + question, + ai_search_volume: aiSearchVolume, + sources: urls.map((url) => ({ url })), + }; +} + +function topPage( + url: string, + platform: "chat_gpt" | "google", + mentions: number | null, + aiSearchVolume: number | null, +): LlmTopPagesItem { + return { + key: url, + platform: [{ key: platform, mentions, ai_search_volume: aiSearchVolume }], + }; +} + +describe("deriveCitedSources", () => { + it("uses top_pages metrics and attaches matching prompt examples", () => { + const sources = deriveCitedSources( + [ + { + platform: "google", + topPages: [ + topPage("https://a.com/x", "google", 9, 9000), + topPage("https://b.com/y", "google", 2, 1000), + ], + mentions: [ + citedMention("best seo tools", 1000, [ + "https://a.com/x", + "https://b.com/y", + ]), + citedMention("cheap seo", 500, ["https://a.com/x"]), + ], + }, + ], + { sourcesPerPlatform: 20, keywordsPerSource: 50 }, + ); + + expect(sources[0]).toMatchObject({ + domain: "a.com", + mentions: 9, + capturedVolume: 9000, + }); + expect(sources[0].keywords.map((k) => k.question).toSorted()).toEqual([ + "best seo tools", + "cheap seo", + ]); + }); + + it("dedupes sampled prompt examples and derives domains from urls", () => { + const sources = deriveCitedSources( + [ + { + platform: "google", + topPages: [topPage("https://evil.example/path", "google", 3, 300)], + mentions: [ + { + question: "q", + ai_search_volume: 200, + sources: [ + { + url: "https://evil.example/path", + domain: "customer.example", + }, + { url: "https://evil.example/path" }, + ], + }, + ], + }, + ], + { sourcesPerPlatform: 20, keywordsPerSource: 50 }, + ); + + expect(sources[0]).toMatchObject({ + url: "https://evil.example/path", + domain: "evil.example", + }); + expect(sources[0].keywords).toEqual([ + { question: "q", aiSearchVolume: 200 }, + ]); + }); +}); diff --git a/src/server/features/ai-search/services/citedSources.ts b/src/server/features/ai-search/services/citedSources.ts new file mode 100644 index 0000000..8e9e6dd --- /dev/null +++ b/src/server/features/ai-search/services/citedSources.ts @@ -0,0 +1,118 @@ +import { sortBy } from "remeda"; +import type { LlmPlatform } from "@/server/lib/dataforseo"; +import type { + LlmMentionItem, + LlmTopPagesItem, +} from "@/server/lib/dataforseoLlmSchemas"; +import { safeHostname, safeHttpUrl } from "@/server/features/ai-search/safeUrl"; +import { roundOrNull } from "@/server/features/ai-search/services/shareOfVoice"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; + +type Bundle = { + platform: LlmPlatform; + topPages: LlmTopPagesItem[]; + mentions: LlmMentionItem[]; +}; + +type PromptExamples = Map>; + +const MAX_URL_LENGTH = 2048; +const MAX_QUESTION_LENGTH = 500; + +/** + * Use DataForSEO top_pages for the ranked cited-source rows, then attach prompt + * examples from the mentions sample when the exact cited URL appears there. + * The page metrics stay authoritative while the prompt examples remain plainly + * sample-based. + */ +export function deriveCitedSources( + bundles: Bundle[], + limits: { sourcesPerPlatform: number; keywordsPerSource: number }, +): BrandLookupResult["topPages"] { + const promptExamples = buildPromptExamples(bundles); + + const rows = bundles.flatMap((bundle) => + bundle.topPages + .map((page) => { + const url = safeHttpUrl(page.key); + if (!url || url.length > MAX_URL_LENGTH) return null; + const platformGroup = page.platform?.find( + (entry) => entry.key === bundle.platform, + ); + const key = sourceKey(bundle.platform, url); + const examples = + promptExamples.get(key) ?? new Map(); + return { + url, + domain: safeHostname(url), + platform: bundle.platform, + mentions: roundOrNull(platformGroup?.mentions), + capturedVolume: roundOrNull(platformGroup?.ai_search_volume), + keywords: sortBy( + Array.from(examples.entries()).map( + ([question, aiSearchVolume]) => ({ + question, + aiSearchVolume, + }), + ), + [(keyword) => keyword.aiSearchVolume ?? 0, "desc"], + ).slice(0, limits.keywordsPerSource), + }; + }) + .filter((row): row is NonNullable => row !== null), + ); + + // Keep the top sources PER PLATFORM so a high-volume platform (Google) can't + // crowd out a sparse one (ChatGPT, US/en only) entirely. Then order the + // combined set by captured volume for a sensible default. + const byPlatform = new Map(); + for (const row of rows) { + const list = byPlatform.get(row.platform) ?? []; + list.push(row); + byPlatform.set(row.platform, list); + } + const capped = Array.from(byPlatform.values()).flatMap((list) => + sortBy(list, [(row) => row.capturedVolume ?? 0, "desc"]).slice( + 0, + limits.sourcesPerPlatform, + ), + ); + + return sortBy( + capped, + [(row) => row.capturedVolume ?? 0, "desc"], + [(row) => row.mentions ?? 0, "desc"], + ); +} + +function buildPromptExamples(bundles: Bundle[]): PromptExamples { + const examples: PromptExamples = new Map(); + for (const bundle of bundles) { + for (const mention of bundle.mentions) { + const question = + typeof mention.question === "string" + ? truncate(mention.question, MAX_QUESTION_LENGTH) + : ""; + if (question.length === 0) continue; + const volume = roundOrNull(mention.ai_search_volume); + + for (const source of mention.sources ?? []) { + const url = safeHttpUrl(source.url); + if (!url) continue; + const key = sourceKey(bundle.platform, url); + const existing = examples.get(key) ?? new Map(); + if (!existing.has(question)) existing.set(question, volume); + examples.set(key, existing); + } + } + } + return examples; +} + +function sourceKey(platform: LlmPlatform, url: string): string { + return `${platform}::${url}`; +} + +function truncate(value: string, maxLength: number): string { + return value.length <= maxLength ? value : value.slice(0, maxLength); +} diff --git a/src/server/features/ai-search/services/shareOfVoice.test.ts b/src/server/features/ai-search/services/shareOfVoice.test.ts new file mode 100644 index 0000000..791b79e --- /dev/null +++ b/src/server/features/ai-search/services/shareOfVoice.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + computeShareOfVoice, + resolveCompetitorGroups, + type CrossOutcome, +} from "./shareOfVoice"; +import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas"; + +function crossItem( + key: string, + platformMentions: Array<{ key: string; mentions: number | null }>, +): LlmCrossAggregatedItem { + return { + key, + platform: platformMentions.map((p) => ({ + key: p.key, + mentions: p.mentions, + ai_search_volume: null, + })), + }; +} + +describe("computeShareOfVoice", () => { + it("sums requested rows, excludes nulls, and ignores unrequested provider rows", () => { + const outcomes: CrossOutcome[] = [ + { + platform: "google", + status: "success", + items: [ + crossItem("acme", [{ key: "google", mentions: 30 }]), + crossItem("rival", [{ key: "google", mentions: 10 }]), + crossItem("ghost", [{ key: "google", mentions: null }]), + crossItem("unexpected", [{ key: "google", mentions: 60 }]), + ], + }, + ]; + + const entries = computeShareOfVoice(outcomes, "acme", [ + "rival", + "ghost", + ])!.entries; + + expect(entries.map((entry) => entry.label)).toEqual([ + "acme", + "rival", + "ghost", + ]); + expect(entries[0]).toMatchObject({ label: "acme", sharePct: 75 }); + expect(entries[1]).toMatchObject({ label: "rival", sharePct: 25 }); + expect(entries[2]).toMatchObject({ mentions: null, sharePct: null }); + }); + + it("returns null with no competitors or no successful calls", () => { + expect(computeShareOfVoice([], "acme", [])).toBe(null); + expect( + computeShareOfVoice( + [ + { platform: "chat_gpt", status: "error", items: [] }, + { platform: "google", status: "error", items: [] }, + ], + "acme", + ["rival"], + ), + ).toBe(null); + }); +}); + +describe("resolveCompetitorGroups", () => { + it("dedupes case-insensitively and drops target collisions", () => { + const groups = resolveCompetitorGroups("Nike", [ + "nike", + "Adidas", + "ADIDAS", + "puma.com", + "www.PUMA.com", + ]); + + expect(groups.map((g) => g.label)).toEqual(["Adidas", "puma.com"]); + }); +}); diff --git a/src/server/features/ai-search/services/shareOfVoice.ts b/src/server/features/ai-search/services/shareOfVoice.ts new file mode 100644 index 0000000..f8ed977 --- /dev/null +++ b/src/server/features/ai-search/services/shareOfVoice.ts @@ -0,0 +1,132 @@ +import { sortBy } from "remeda"; +import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas"; +import type { LlmPlatform } from "@/server/lib/dataforseo"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; +import { detectTarget } from "@/shared/targetDetection"; + +export type CrossOutcome = { + platform: LlmPlatform; + status: "success" | "error"; + items: LlmCrossAggregatedItem[]; +}; + +export type CompetitorGroup = { + label: string; + detected: ReturnType; +}; + +/** + * Resolve raw competitor inputs into comparison groups: detect each one's + * target type, dedupe by resolved value, and drop any that collide with the + * target — a duplicate aggregation group adds a redundant leaderboard row and + * wastes a paid comparison slot. Dedupe is case-insensitive: domains are + * already lowercased by detectTarget, but keyword targets preserve case while + * DataForSEO matches them case-insensitively, so "Nike" and "nike" would be + * two paid groups returning the same counts. + */ +export function resolveCompetitorGroups( + targetValue: string, + competitors: string[], +): CompetitorGroup[] { + const seen = new Set([targetValue.toLowerCase()]); + const groups: CompetitorGroup[] = []; + for (const competitor of competitors) { + const detected = detectTarget(competitor); + const dedupeKey = detected.value.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + groups.push({ label: detected.value, detected }); + } + return groups; +} + +/** + * Build Share of Voice from the per-platform cross_aggregated calls. For each + * brand (item.key), sum mentions across the platforms the calls returned. + * + * Every requested group (target + competitors) is seeded as a row up front, so + * a brand the API returned no item for renders as "no data" instead of + * silently vanishing from a leaderboard the user paid to compare it on. Echoed + * aggregation_keys are matched back to requested keys case-insensitively so + * vendor normalization can't orphan a row or the target's isTarget flag. + * + * US/en assumption: today the only locale that exists for both platforms is + * US/en (the UI hardcodes locationCode 2840 / languageCode en and has no locale + * selector), so we sum every returned platform. If a locale selector ships, + * gate chat_gpt here the same way the single-brand totals do in shapeResult — + * do not duplicate the chatGptLocaleMatches branch into this path. + * + * Null vs zero: a brand whose summed mentions is null is "no data" (excluded + * from the denominator, sharePct null); a brand with mentions 0 is known-zero + * and counts. sharePct = mentions / denominator * 100, guarded against + * divide-by-zero. Returns null when there are no competitors or both calls + * failed (so the UI omits the section rather than blanking the page). + */ +export function computeShareOfVoice( + crossOutcomes: CrossOutcome[], + targetKey: string, + competitorKeys: string[], +): BrandLookupResult["shareOfVoice"] { + if (competitorKeys.length === 0) return null; + const successful = crossOutcomes.filter((c) => c.status === "success"); + if (successful.length === 0) return null; + + const requestedKeys = [targetKey, ...competitorKeys]; + const labelByKey = new Map( + requestedKeys.map((key) => [key.toLowerCase(), key]), + ); + const mentionsByKey = new Map( + requestedKeys.map((key) => [key.toLowerCase(), null]), + ); + + for (const outcome of successful) { + for (const item of outcome.items) { + if (item.key == null) continue; + const key = item.key.toLowerCase(); + // The provider should echo only requested aggregation keys. If it ever + // returns extra rows, do not let them alter requested share percentages. + if (!labelByKey.has(key)) continue; + const platformMentions = sumNullable( + (item.platform ?? []).map((entry) => roundOrNull(entry.mentions)), + ); + const prior = mentionsByKey.get(key) ?? null; + // null + null stays null ("no data"); null + n = n; m + n = m + n. + mentionsByKey.set(key, sumNullable([prior, platformMentions])); + } + } + + const denominator = sumNullable(Array.from(mentionsByKey.values())) ?? 0; + const targetLower = targetKey.toLowerCase(); + + const entries = sortBy( + Array.from(mentionsByKey.entries()).map(([key, mentions]) => ({ + label: labelByKey.get(key) ?? key, + isTarget: key === targetLower, + mentions, + sharePct: + mentions == null || denominator <= 0 + ? null + : (mentions / denominator) * 100, + })), + [(entry) => entry.mentions ?? -1, "desc"], + ); + + return { platforms: successful.map((outcome) => outcome.platform), entries }; +} + +export function sumNullable(values: Array): number | null { + let total = 0; + let hasValue = false; + for (const value of values) { + if (value != null) { + total += value; + hasValue = true; + } + } + return hasValue ? total : null; +} + +export function roundOrNull(value: number | null | undefined): number | null { + if (value == null) return null; + return Math.round(value); +} diff --git a/src/server/lib/dataforseo/ai.ts b/src/server/lib/dataforseo/ai.ts index 2792eae..104f052 100644 --- a/src/server/lib/dataforseo/ai.ts +++ b/src/server/lib/dataforseo/ai.ts @@ -3,9 +3,11 @@ import { AiOptimizationChatGptLlmResponsesLiveRequestInfo, AiOptimizationClaudeLlmResponsesLiveRequestInfo, AiOptimizationGeminiLlmResponsesLiveRequestInfo, + AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo, AiOptimizationLLmMentionsDomainElement, AiOptimizationLLmMentionsKeywordElement, AiOptimizationLlmMentionsAggregatedMetricsLiveRequestInfo, + AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo, AiOptimizationLlmMentionsSearchLiveRequestInfo, AiOptimizationLlmMentionsTopPagesLiveRequestInfo, type BaseAiOptimizationLLmMentionsTargetElement, @@ -13,10 +15,12 @@ import { } from "dataforseo-client"; import { llmAggregatedTotalSchema, + llmCrossAggregatedItemSchema, llmMentionItemSchema, llmResponseResultSchema, llmTopPagesItemSchema, type LlmAggregatedTotal, + type LlmCrossAggregatedItem, type LlmMentionItem, type LlmResponseResult, type LlmTopPagesItem, @@ -240,6 +244,66 @@ export async function fetchLlmTopPages( return { data: items.data, billing: buildTaskBilling(task) }; } +// --------------------------------------------------------------------------- +// LLM Mentions Cross-Aggregated Metrics +// Compares 2..10 aggregation groups (target + competitors) in one call and +// returns one item per group, keyed by its aggregation_key (brand label). +// --------------------------------------------------------------------------- + +type LlmCrossAggregatedMetricsInput = { + groups: Array<{ key: string; target: LlmTarget }>; + platform: LlmPlatform; + locationCode: number; + languageCode: string; + internalListLimit?: number; +}; + +export async function fetchLlmCrossAggregatedMetrics( + input: LlmCrossAggregatedMetricsInput, +): Promise> { + if (input.groups.length < 2 || input.groups.length > 10) { + throw new AppError( + "VALIDATION_ERROR", + "DataForSEO llm_mentions/cross_aggregated_metrics requires 2 to 10 target groups", + ); + } + + const response = await aiOptimizationApi( + classifyAiSearchError, + ).llmMentionsCrossAggregatedMetricsLive([ + new AiOptimizationLlmMentionsCrossAggregatedMetricsLiveRequestInfo({ + targets: input.groups.map( + (group) => + new AiOptimizationLLmMentionsCrossAggregateMetricsTargetInfo({ + aggregation_key: group.key, + target: targetList(group.target), + }), + ), + platform: input.platform, + location_code: input.locationCode, + language_code: input.languageCode, + internal_list_limit: clampLimit(input.internalListLimit ?? 5, 1, 10), + }), + ]); + const task = assertOk( + response, + assertOptions( + "/v3/ai_optimization/llm_mentions/cross_aggregated_metrics/live", + ), + ); + + const items = z + .array(llmCrossAggregatedItemSchema) + .safeParse(firstResult(task)?.items ?? []); + if (!items.success) { + throw new AppError( + "INTERNAL_ERROR", + "DataForSEO llm_mentions/cross_aggregated_metrics returned an invalid shape", + ); + } + return { data: items.data, billing: buildTaskBilling(task) }; +} + // --------------------------------------------------------------------------- // LLM Responses (per-model) // --------------------------------------------------------------------------- diff --git a/src/server/lib/dataforseo/client.test.ts b/src/server/lib/dataforseo/client.test.ts index fcdf0db..d6c373e 100644 --- a/src/server/lib/dataforseo/client.test.ts +++ b/src/server/lib/dataforseo/client.test.ts @@ -80,6 +80,7 @@ vi.mock("@/server/lib/dataforseo/ai", () => ({ fetchLlmMentionsSearch: vi.fn(), fetchLlmAggregatedMetrics: vi.fn(), fetchLlmTopPages: vi.fn(), + fetchLlmCrossAggregatedMetrics: vi.fn(), fetchLlmResponse: vi.fn(), })); diff --git a/src/server/lib/dataforseo/client.ts b/src/server/lib/dataforseo/client.ts index 2ef3768..81f48fa 100644 --- a/src/server/lib/dataforseo/client.ts +++ b/src/server/lib/dataforseo/client.ts @@ -41,6 +41,7 @@ import { import { fetchLighthouseResult } from "@/server/lib/dataforseo/lighthouse"; import { fetchLlmAggregatedMetrics, + fetchLlmCrossAggregatedMetrics, fetchLlmMentionsSearch, fetchLlmResponse, fetchLlmTopPages, @@ -125,6 +126,7 @@ export function createDataforseoClient(customer: BillingCustomerContext) { mentionsSearch: meter(customer, fetchLlmMentionsSearch), aggregatedMetrics: meter(customer, fetchLlmAggregatedMetrics), topPages: meter(customer, fetchLlmTopPages), + crossAggregatedMetrics: meter(customer, fetchLlmCrossAggregatedMetrics), llmResponse: meter(customer, fetchLlmResponse), }, } as const; diff --git a/src/server/lib/dataforseo/endpoints.test.ts b/src/server/lib/dataforseo/endpoints.test.ts index 0881bb4..9233d2e 100644 --- a/src/server/lib/dataforseo/endpoints.test.ts +++ b/src/server/lib/dataforseo/endpoints.test.ts @@ -8,6 +8,7 @@ import { fetchQuestionsAnswers } from "@/server/lib/dataforseo/business"; import { buildLlmTarget, fetchLlmAggregatedMetrics, + fetchLlmCrossAggregatedMetrics, fetchLlmMentionsSearch, fetchLlmResponse, fetchLlmTopPages, @@ -83,7 +84,7 @@ describe("DataForSEO SDK-backed endpoints", () => { }); }); - it("serializes LLM mentions domain targets for all live endpoints", async () => { + it("serializes LLM mentions domain targets for search, top pages, and aggregated endpoints", async () => { const fetchMock = vi.fn().mockImplementation((url) => { const path = typeof url === "string" || url instanceof URL @@ -132,8 +133,8 @@ describe("DataForSEO SDK-backed endpoints", () => { platform: "google", locationCode: 2840, languageCode: "en", + itemsListLimit: 10, }); - const expectedTarget = [ { search_scope: ["any"], @@ -179,6 +180,79 @@ describe("DataForSEO SDK-backed endpoints", () => { ]); }); + it("serializes cross-aggregated target groups", async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + status_code: 20000, + tasks: [ + { + status_code: 20000, + path: [ + "v3", + "ai_optimization", + "llm_mentions", + "cross_aggregated_metrics", + "live", + ], + cost: 0.0001, + result_count: 1, + result: [{ items: [] }], + }, + ], + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchLlmCrossAggregatedMetrics({ + groups: [ + { + key: "example.com", + target: buildLlmTarget({ type: "domain", value: "example.com" }), + }, + { + key: "Acme Storage", + target: buildLlmTarget({ type: "keyword", value: "Acme Storage" }), + }, + ], + platform: "google", + locationCode: 2840, + languageCode: "en", + }); + + expect(parseDataforseoRequestBody(fetchMock.mock.calls[0]?.[1])).toEqual([ + { + targets: [ + { + aggregation_key: "example.com", + target: [ + { + search_scope: ["any"], + search_filter: "include", + domain: "example.com", + include_subdomains: true, + }, + ], + }, + { + aggregation_key: "Acme Storage", + target: [ + { + search_scope: ["any", "brand_entities"], + search_filter: "include", + keyword: "Acme Storage", + match_type: "word_match", + }, + ], + }, + ], + location_code: 2840, + language_code: "en", + platform: "google", + internal_list_limit: 5, + }, + ]); + }); + it("serializes LLM mentions keyword targets", async () => { const fetchMock = vi.fn().mockResolvedValue( Response.json({ diff --git a/src/server/lib/dataforseoLlmSchemas.ts b/src/server/lib/dataforseoLlmSchemas.ts index 486fe9c..6cd5df7 100644 --- a/src/server/lib/dataforseoLlmSchemas.ts +++ b/src/server/lib/dataforseoLlmSchemas.ts @@ -90,6 +90,27 @@ export const llmTopPagesItemSchema = z export type LlmTopPagesItem = z.infer; +// --------------------------------------------------------------------------- +// LLM Mentions Cross-Aggregated Metrics — `/v3/ai_optimization/llm_mentions/cross_aggregated_metrics/live` +// One item per requested aggregation group (target + competitors). +// `.passthrough()` because the real item also carries location, language, +// sources_domain, and brand_entities arrays we intentionally ignore. +// --------------------------------------------------------------------------- + +export const llmCrossAggregatedItemSchema = z + .object({ + // The shared SDK type AiOptimizationLlmMentionssLiveItem documents `key` as + // the URL of a found page, but for cross_aggregated `key` is the request + // aggregation_key (the brand label). + key: z.string().nullable().optional(), + platform: z.array(groupElementSchema).nullable().optional(), + }) + .passthrough(); + +export type LlmCrossAggregatedItem = z.infer< + typeof llmCrossAggregatedItemSchema +>; + // --------------------------------------------------------------------------- // LLM Responses — shared between ChatGPT/Claude/Gemini/Perplexity // All four model endpoints return the same envelope shape. diff --git a/src/server/features/ai-search/targetDetection.test.ts b/src/shared/targetDetection.test.ts similarity index 100% rename from src/server/features/ai-search/targetDetection.test.ts rename to src/shared/targetDetection.test.ts diff --git a/src/server/features/ai-search/targetDetection.ts b/src/shared/targetDetection.ts similarity index 100% rename from src/server/features/ai-search/targetDetection.ts rename to src/shared/targetDetection.ts diff --git a/src/types/schemas/ai-search.ts b/src/types/schemas/ai-search.ts index 735bac3..09e27e8 100644 --- a/src/types/schemas/ai-search.ts +++ b/src/types/schemas/ai-search.ts @@ -22,9 +22,34 @@ export const aiSearchProjectSchema = z.object({ /** Maximum allowed length for a free-text brand or domain search input. */ export const BRAND_LOOKUP_MAX_INPUT_LENGTH = 250; +/** Maximum number of competitors compared in one Share-of-Voice lookup. */ +const BRAND_LOOKUP_MAX_COMPETITORS = 5; + +/** + * Canonicalize raw comma-separated competitor text: split, trim, drop empties, + * dedupe, cap. Shared by the `c` URL-param transform and the page form so the + * two never diverge. + */ +export function parseCompetitorList(raw: string): string[] { + return Array.from( + new Set( + raw + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0), + ), + ).slice(0, BRAND_LOOKUP_MAX_COMPETITORS); +} + export const brandLookupInputSchema = z.object({ projectId: z.string().min(1), query: z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH), + // Optional competitor brands/domains to compare Share of Voice against. + // cross_aggregated_metrics caps groups at 10 (target + 9); we cap at 5. + competitors: z + .array(z.string().trim().min(1).max(BRAND_LOOKUP_MAX_INPUT_LENGTH)) + .max(BRAND_LOOKUP_MAX_COMPETITORS) + .default([]), locationCode: z.number().int().positive().default(2840), languageCode: z.string().min(2).max(8).default("en"), }); @@ -36,18 +61,42 @@ const brandPlatformBreakdownSchema = z.object({ status: z.enum(["success", "error"]), mentions: z.number().int().nonnegative().nullable(), aiSearchVolume: z.number().int().nonnegative().nullable(), - impressions: z.number().int().nonnegative().nullable(), +}); + +const brandShareOfVoiceSchema = z.object({ + // The platforms whose cross_aggregated call succeeded and are summed into + // the entries — so the UI can caption a single-platform leaderboard honestly + // when the other platform's call failed. + platforms: z.array(z.enum(["chat_gpt", "google"])), + entries: z.array( + z.object({ + label: z.string().max(BRAND_LOOKUP_MAX_INPUT_LENGTH), + isTarget: z.boolean(), + mentions: z.number().int().nonnegative().nullable(), + sharePct: z.number().nullable(), + }), + ), +}); + +const brandTopPageKeywordSchema = z.object({ + question: z.string().max(500), + aiSearchVolume: z.number().int().nonnegative().nullable(), }); const brandTopPageSchema = z.object({ - url: z.string(), - domain: z.string().nullable(), - mentions: z.number().int().nonnegative().nullable(), + url: z.string().max(2048), + domain: z.string().max(253).nullable(), platform: z.enum(["chat_gpt", "google"]), + // Page-level citation mentions from DataForSEO top_pages. + mentions: z.number().int().nonnegative().nullable(), + // Page-level AI search volume from DataForSEO top_pages. + capturedVolume: z.number().int().nonnegative().nullable(), + // Example prompts from the fetched mentions sample that cited this page. + keywords: z.array(brandTopPageKeywordSchema).max(50), }); const brandTopQuerySchema = z.object({ - question: z.string(), + question: z.string().max(500), platform: z.enum(["chat_gpt", "google"]), aiSearchVolume: z.number().int().nonnegative().nullable(), firstSeenAt: z.string().nullable(), @@ -55,13 +104,13 @@ const brandTopQuerySchema = z.object({ citedSources: z .array( z.object({ - url: z.string(), - domain: z.string().nullable(), - title: z.string().nullable(), + url: z.string().max(2048), + domain: z.string().max(253).nullable(), + title: z.string().max(300).nullable(), }), ) .max(10), - brandsMentioned: z.array(z.string()).max(20), + brandsMentioned: z.array(z.string().max(200)).max(20), }); const brandMonthlyVolumeSchema = z.object({ @@ -78,9 +127,13 @@ export const brandLookupResultSchema = z.object({ hasData: z.boolean(), totalMentions: z.number().int().nonnegative().nullable(), totalAiSearchVolume: z.number().int().nonnegative().nullable(), - totalImpressions: z.number().int().nonnegative().nullable(), perPlatform: z.array(brandPlatformBreakdownSchema), - topPages: z.array(brandTopPageSchema).max(20), + // Competitor Share of Voice — null when no competitors were supplied or both + // cross_aggregated calls failed. No legacy-cache shim: pre-SoV cache entries + // are unreachable anyway (the cache key's param set changed), see the + // buildCacheKey comment in brandLookup.ts. + shareOfVoice: brandShareOfVoiceSchema.nullable(), + topPages: z.array(brandTopPageSchema).max(40), topQueries: z.array(brandTopQuerySchema).max(50), monthlyVolume: z.array(brandMonthlyVolumeSchema), }); @@ -205,9 +258,23 @@ export type PromptExplorerResult = z.infer; // URL search params // --------------------------------------------------------------------------- -/** /p/$projectId/brand-lookup query params — `q` keeps the lookup shareable. */ +/** + * /p/$projectId/brand-lookup query params. `q` keeps the lookup shareable; `c` + * is a comma-joined competitor list (route + page treat the parsed result as an + * opaque string array). `c` accepts a raw string (from the URL) OR an array + * (TanStack Router re-validates its own transformed output on navigate) — same + * union pattern as `models` below. + */ export const brandLookupSearchSchema = z.object({ q: z.string().optional(), + c: z + .union([z.string(), z.array(z.string())]) + .optional() + .transform((value) => + value === undefined + ? undefined + : parseCompetitorList(Array.isArray(value) ? value.join(",") : value), + ), }); /**