131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
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";
|
|
import {
|
|
urlMatchesResearchTarget,
|
|
type ResearchTarget,
|
|
} from "@/shared/researchScope";
|
|
|
|
type Bundle = {
|
|
platform: LlmPlatform;
|
|
topPages: LlmTopPagesItem[];
|
|
mentions: LlmMentionItem[];
|
|
};
|
|
|
|
type PromptExamples = Map<string, Map<string, number | null>>;
|
|
|
|
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.
|
|
*
|
|
* `pageFilter` narrows the rows to a URL-scoped research target (the provider
|
|
* has no URL-level targeting). Filtering happens before the per-platform cap so
|
|
* in-scope pages can't be crowded out by out-of-scope ones.
|
|
*/
|
|
export function deriveCitedSources(
|
|
bundles: Bundle[],
|
|
limits: { sourcesPerPlatform: number; keywordsPerSource: number },
|
|
pageFilter: ResearchTarget | null = null,
|
|
): 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;
|
|
if (pageFilter && !urlMatchesResearchTarget(url, pageFilter)) {
|
|
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<string, number | null>();
|
|
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<typeof row> => 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<LlmPlatform, typeof rows>();
|
|
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<string, number | null>();
|
|
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);
|
|
}
|