import type { QueryClient } from "@tanstack/react-query"; import type { ProfileAnalysisContext, ProfileAnalysisRunResult, } from "#/lib/profile-analysis"; import { responseError } from "#/lib/client-http"; import { PROFILE_HYDRATION_BATCH_LIMIT, hydrateProfileHandles, normalizeProfileHydrationHandle, } from "#/lib/profile-hydration-client"; import type { ProfileRecord } from "#/lib/types"; export interface ProfileAnalysisRequestOptions { refresh: boolean; maxTweets: number; maxPages: number; maxConversations: number; maxConversationPages: number; } export const DEFAULT_PROFILE_ANALYSIS_LIMITS = { maxTweets: 10000, maxPages: 100, maxConversations: 80, maxConversationPages: 3, } as const; const PROFILE_MENTION_RE = /(^|[^\w@./])@([A-Za-z0-9_]{1,15})\b/g; export const normalizeProfileHandle = normalizeProfileHydrationHandle; export function handlesFromText(value: string) { return Array.from(value.matchAll(PROFILE_MENTION_RE)).map( (match) => match[2], ); } export function knownProfileHandles(context: ProfileAnalysisContext) { const handles = new Set(); handles.add(normalizeProfileHandle(context.profile.handle)); for (const profile of context.profiles ?? []) { handles.add(normalizeProfileHandle(profile.handle)); } for (const tweet of context.conversations) { handles.add(normalizeProfileHandle(tweet.author)); } return handles; } export function collectProfileAnalysisHydrationHandles({ context, analysis, markdown, }: { context: ProfileAnalysisContext; analysis?: ProfileAnalysisRunResult["analysis"]; markdown?: string; }) { const handles = new Set(); const known = knownProfileHandles(context); const add = (value: string | undefined) => { if (!value) return; const handle = normalizeProfileHandle(value); if (!/^[a-z0-9_]{1,15}$/.test(handle) || known.has(handle)) return; handles.add(handle); }; for (const handle of analysis?.sourceHandles ?? []) add(handle); for (const theme of analysis?.themes ?? []) { for (const handle of theme.handles) add(handle); } if (markdown) { for (const handle of handlesFromText(markdown)) add(handle); } for (const handle of handlesFromText(context.profile.bio)) add(handle); for (const tweet of context.tweets) { for (const handle of handlesFromText(tweet.text)) add(handle); } for (const tweet of context.conversations) { for (const handle of handlesFromText(tweet.text)) add(handle); for (const handle of handlesFromText(tweet.bio)) add(handle); } return [...handles]; } export function applyHydratedProfilesToProfileAnalysisContext( context: ProfileAnalysisContext, profiles: ProfileRecord[], ) { const existing = new Map(); for (const profile of context.profiles ?? []) { existing.set(normalizeProfileHandle(profile.handle), profile); } for (const profile of profiles) { existing.set(normalizeProfileHandle(profile.handle), profile); } return { ...context, profiles: [...existing.values()], }; } export async function hydrateProfileAnalysisProfiles({ queryClient, context, analysis, markdown, }: { queryClient: QueryClient; context: ProfileAnalysisContext; analysis?: ProfileAnalysisRunResult["analysis"]; markdown?: string; }) { const handles = collectProfileAnalysisHydrationHandles({ context, analysis, markdown, }); const { profiles } = await hydrateProfileHandles(queryClient, handles, { limit: PROFILE_HYDRATION_BATCH_LIMIT, }); return profiles; } export function profileAnalysisUrl( handle: string, options: ProfileAnalysisRequestOptions, ) { const params = new URLSearchParams(); params.set("handle", handle); params.set("maxTweets", String(options.maxTweets)); params.set("maxPages", String(options.maxPages)); params.set("maxConversations", String(options.maxConversations)); params.set("maxConversationPages", String(options.maxConversationPages)); if (options.refresh) { params.set("refresh", "true"); } return `/api/profile-analysis?${params.toString()}`; } export async function profileAnalysisRequestError(response: Response) { return responseError(response, { label: "Profile analysis failed" }); } export function formatProfileAnalysisCounts( context: ProfileAnalysisContext | null, ) { if (!context) return "xurl profile backfill with cached AI analysis."; return [ context.fetchCached ? "cached backfill" : "fresh xurl backfill", `${String(context.counts.tweets)} tweets`, `${String(context.counts.conversationTweets)} conversation tweets`, `${String(context.counts.conversationsScanned)} conversations`, ].join(" ยท "); } export function cleanProfileHandle(value: string) { return value.trim().replace(/^@/, ""); }