393 lines
13 KiB
TypeScript

import { waitUntil } from "cloudflare:workers";
import { identity, sortBy } from "remeda";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseo";
import {
buildLlmTarget,
CHATGPT_LANGUAGE_CODE,
CHATGPT_LOCATION_CODE,
type LlmPlatform,
} from "@/server/lib/dataforseo";
import type { LlmCrossAggregatedItem } from "@/server/lib/dataforseoLlmSchemas";
import { AppError } from "@/server/lib/errors";
import { buildCacheKey, getCached, setCached } from "@/server/lib/r2-cache";
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 "@/shared/targetDetection";
import {
parseResearchTarget,
type ResearchTarget,
} from "@/shared/researchScope";
/**
* Brand Lookup is the AI-search analog of Domain Overview. The user types a
* brand name or domain; we hit DataForSEO's LLM Mentions API across ChatGPT
* (US-only) and Google AI Overview, then shape the response into something
* the UI can render directly. Stateless — no DB writes, R2 caching only.
*/
/** Brand lookup data refreshes daily; underlying API is updated monthly. */
const BRAND_LOOKUP_TTL_SECONDS = 24 * 60 * 60;
const PLATFORMS: LlmPlatform[] = ["chat_gpt", "google"];
// 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<BrandLookupResult> {
const detected = detectTarget(input.query);
const researchTarget = resolveResearchTarget(input, detected);
// The LLM mentions API only scopes a domain target by subdomain inclusion;
// exact_url/subfolder are honored by post-filtering page rows in shaping.
const includeSubdomains =
researchTarget === null || researchTarget.scope === "subdomains";
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,
// 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: sortBy(
competitorGroups.map((g) => g.detected.value.toLowerCase()),
identity(),
).join("|"),
locationCode: input.locationCode,
languageCode: input.languageCode,
// Scope changes both the provider call (include_subdomains) and the
// page-level filtering, so it must not share a cache entry. The path only
// affects output under URL scopes — keying it for domain/subdomains would
// re-buy identical fan-outs for example.com vs example.com/blog.
scope: researchTarget?.scope ?? null,
path:
researchTarget?.scope === "exact_url" ||
researchTarget?.scope === "subfolder"
? researchTarget.path
: "",
});
const cached = brandLookupResultSchema.safeParse(await getCached(cacheKey));
if (cached.success) {
return {
...cached.data,
query: input.query,
resolvedTarget: researchTarget?.display ?? detected.value,
};
}
const dataforseo = createDataforseoClient(billingCustomer);
// 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<PromiseSettledResult<PlatformBundle>> = [];
for (const platform of PLATFORMS) {
settled.push(
await settle(() =>
fetchPlatformData(
platform,
detected,
includeSubdomains,
input,
dataforseo,
),
),
);
}
rethrowIfBlockingAiSearchError(settled);
const crossSettled =
competitorGroups.length > 0
? await settle(() =>
fetchCrossAggregated(
detected,
competitorGroups,
includeSubdomains,
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") {
return { platform, status: "success", bundle: settledResult.value };
}
console.error(
`ai-search.brand-lookup.${platform}.error:`,
settledResult.reason,
);
return { platform, status: "error", bundle: null };
});
const result = shapeResult({
query: input.query,
detected,
researchTarget,
platformBundles,
crossOutcomes,
competitorKeys: competitorGroups.map((g) => g.label),
userLocationCode: input.locationCode,
userLanguageCode: input.languageCode,
});
// 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) => {
console.error("ai-search.brand-lookup.cache-write failed:", err);
}),
);
}
return result;
}
/**
* Scopes only apply to domain/URL queries — a brand keyword has no URL to
* narrow. A domain the parser rejects (fake TLD) keeps today's unscoped
* behavior and fails downstream with the provider's own validation error.
*/
function resolveResearchTarget(
input: BrandLookupInput,
detected: ReturnType<typeof detectTarget>,
): ResearchTarget | null {
if (detected.type !== "domain") return null;
const parsed = parseResearchTarget(input.query, input.scope);
if (!parsed.ok) {
// An explicit scope that doesn't fit the input (Subfolder without a path)
// must error, not silently run an unscoped lookup.
if (input.scope) throw new AppError("VALIDATION_ERROR", parsed.message);
return null;
}
return parsed.target;
}
async function settle<T>(
execute: () => Promise<T>,
): Promise<PromiseSettledResult<T>> {
try {
return { status: "fulfilled", value: await execute() };
} catch (reason) {
return { status: "rejected", reason };
}
}
type PlatformFetchInput = Pick<
BrandLookupInput,
"locationCode" | "languageCode"
>;
async function fetchPlatformData(
platform: LlmPlatform,
detected: ReturnType<typeof detectTarget>,
includeSubdomains: boolean,
input: PlatformFetchInput,
dataforseo: ReturnType<typeof createDataforseoClient>,
): Promise<PlatformBundle> {
const target = buildLlmTarget({
type: detected.type,
value: detected.value,
includeSubdomains,
});
// ChatGPT mentions DB only contains US/en data per DataForSEO docs.
const locationCode =
platform === "chat_gpt" ? CHATGPT_LOCATION_CODE : input.locationCode;
const languageCode =
platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : input.languageCode;
// 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,
locationCode,
languageCode,
internalListLimit: 20,
}),
);
const topPages = await settle(() =>
dataforseo.aiSearch.topPages({
target,
platform,
locationCode,
languageCode,
itemsListLimit: TOP_SOURCES_PER_PLATFORM,
}),
);
const mentions = await settle(() =>
dataforseo.aiSearch.mentionsSearch({
target,
platform,
locationCode,
languageCode,
limit: MENTIONS_PER_PLATFORM,
}),
);
rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]);
// If every sub-call failed we have nothing to render for this platform —
// reject so the outer `allSucceeded` gate refuses to cache a blank result.
const allRejected =
aggregated.status === "rejected" &&
topPages.status === "rejected" &&
mentions.status === "rejected";
if (allRejected) throw aggregated.reason;
return {
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.
*
* Share of Voice always compares domain against domain — the provider has no
* URL-level targeting — so every group (target and competitors) uses the same
* subdomain rule and the UI labels the section domain-level under URL scopes.
*/
async function fetchCrossAggregated(
detected: ReturnType<typeof detectTarget>,
competitors: CompetitorGroup[],
includeSubdomains: boolean,
input: PlatformFetchInput,
dataforseo: ReturnType<typeof createDataforseoClient>,
): Promise<CrossOutcome[]> {
const groups = [
{
key: detected.value,
target: buildLlmTarget({
type: detected.type,
value: detected.value,
includeSubdomains,
}),
},
...competitors.map((competitor) => ({
key: competitor.label,
target: buildLlmTarget({
type: competitor.detected.type,
value: competitor.detected.value,
includeSubdomains,
}),
})),
];
const settled: Array<PromiseSettledResult<LlmCrossAggregatedItem[]>> = [];
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<PromiseSettledResult<unknown>>,
): void {
for (const result of results) {
if (
result.status === "rejected" &&
result.reason instanceof AppError &&
(result.reason.code === "INSUFFICIENT_CREDITS" ||
result.reason.code === "AI_SEARCH_BILLING_ISSUE")
) {
throw result.reason;
}
}
}
function fulfilledOr<T>(
result: PromiseSettledResult<T>,
fallback: () => T,
platform: LlmPlatform,
label: string,
): T {
if (result.status === "fulfilled") return result.value;
console.error(
`ai-search.brand-lookup.${platform}.${label}.error:`,
result.reason,
);
return fallback();
}