/** * lib/perplexity.ts * Gemeinsamer Perplexity-Sonar-Wrapper für alle Agenten. * Wird von verifier.ts und verify-article.ts genutzt. */ const PRICING = { sonar: { inputPerM: 1, outputPerM: 1 }, "sonar-pro": { inputPerM: 3, outputPerM: 15 }, } as const; const SEARCH_COST_PER_CALL = 0.005; export type PerplexitySource = { url: string; title: string | undefined; snippet: string | undefined; }; export type PerplexityResult = { summary: string; sources: PerplexitySource[]; model: string; promptTokens: number; completionTokens: number; searchQueries: number; estimatedCostUSD: number; }; type PerplexityApiResponse = { model?: string; citations?: string[]; search_results?: Array<{ url?: string; title?: string; snippet?: string }>; choices?: Array<{ message?: { content?: string } }>; usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; search_queries?: number; }; }; class RetryableError extends Error {} async function sleep(ms: number) { await new Promise((r) => setTimeout(r, ms)); } function estimateCost(model: string, promptTokens: number, completionTokens: number, searchQueries: number): number { const p = PRICING[model as keyof typeof PRICING] ?? PRICING["sonar"]; return (promptTokens / 1_000_000) * p.inputPerM + (completionTokens / 1_000_000) * p.outputPerM + searchQueries * SEARCH_COST_PER_CALL; } function parseSources(data: PerplexityApiResponse): PerplexitySource[] { const seen = new Set(); const fromSearch = data.search_results ?.filter((r) => !!r?.url) .map((r) => ({ url: r.url!, title: r.title, snippet: r.snippet })) .filter((s) => !seen.has(s.url) && seen.add(s.url)) ?? []; if (fromSearch.length > 0) return fromSearch.slice(0, 8); return (data.citations ?? []) .filter((u): u is string => typeof u === "string" && /^https?:\/\//.test(u) && !seen.has(u) && !!seen.add(u)) .slice(0, 8) .map((url) => ({ url, title: undefined, snippet: undefined })); } /** * Ruft die Perplexity Sonar API auf und gibt ein normiertes Ergebnis zurück. * Wirft einen Error wenn PERPLEXITY_API_KEY fehlt oder der Aufruf fehlschlägt. */ export async function searchPerplexity( query: string, options?: { mode?: "fast" | "deep"; recency?: string; signal?: AbortSignal; maxTokens?: number; } ): Promise { const apiKey = process.env.PERPLEXITY_API_KEY; if (!apiKey) throw new Error("PERPLEXITY_API_KEY ist nicht gesetzt"); const mode = options?.mode ?? "fast"; const model: "sonar" | "sonar-pro" = mode === "deep" ? "sonar-pro" : "sonar"; const contextSize = mode === "deep" ? "high" : "low"; const maxTokens = options?.maxTokens ?? (mode === "deep" ? 600 : 350); const body: Record = { model, messages: [ { role: "system", content: "Du bist ein Recherche-Tool für Fact-Checking. " + "Recherchiere präzise und faktisch. " + "Setze Inline-Zitierungen [1][2][3] direkt nach jedem belegten Satz. " + "Fokussiere auf überprüfbare Fakten und Primärquellen.", }, { role: "user", content: `Recherchefrage zum Fact-Checking:\n\n${query}`, }, ], max_tokens: maxTokens, temperature: 0.1, web_search_options: { search_context_size: contextSize }, }; if (options?.recency) body.search_recency_filter = options.recency; let lastError: unknown; for (let attempt = 1; attempt <= 3; attempt++) { try { const resp = await fetch("https://api.perplexity.ai/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: options?.signal, }); if (!resp.ok) { const text = await resp.text().catch(() => ""); if (resp.status === 429 || resp.status >= 500) throw new RetryableError(`Perplexity ${resp.status}: ${text}`); throw new Error(`Perplexity ${resp.status}: ${text}`); } const data = (await resp.json()) as PerplexityApiResponse; const summary = data.choices?.[0]?.message?.content?.trim() ?? ""; if (!summary) throw new RetryableError("Leere Antwort von Perplexity"); const sources = parseSources(data); const usage = data.usage ?? {}; const promptTokens = usage.prompt_tokens ?? 0; const completionTokens = usage.completion_tokens ?? 0; const searchQueries = usage.search_queries ?? 1; const finalModel = data.model ?? model; return { summary, sources, model: finalModel, promptTokens, completionTokens, searchQueries, estimatedCostUSD: estimateCost(finalModel, promptTokens, completionTokens, searchQueries), }; } catch (err) { lastError = err; if (err instanceof RetryableError && attempt < 3) { await sleep(400 * 2 ** (attempt - 1)); } else { break; } } } throw lastError instanceof Error ? lastError : new Error("Perplexity-Fehler"); } /** Formatiert Quellen als kompakte Inline-Liste für Prompts */ export function formatSourcesForPrompt(sources: PerplexitySource[], maxSnippetLen = 250): string { return sources .map((s, i) => { const title = s.title ?? s.url; const snippet = s.snippet ? `\n "${s.snippet.slice(0, maxSnippetLen)}${s.snippet.length > maxSnippetLen ? "…" : ""}"` : ""; return `[${i + 1}] ${title} (${s.url})${snippet}`; }) .join("\n"); }