feat: Pi Text-Agent — initialer Commit (sauberes Repo)

Vollständiges Multi-Agenten-System für Fact-Checking, Artikelschreiben
und Argumentationsanalyse. Zwei Backends: llama.cpp (★ bevorzugt) und Ollama.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-05-12 04:21:48 +02:00
commit 5146b7fa30
62 changed files with 11279 additions and 0 deletions

175
lib/perplexity.ts Normal file
View file

@ -0,0 +1,175 @@
/**
* 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<string>();
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<PerplexityResult> {
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<string, unknown> = {
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");
}