/** * llama-verifier.ts * Pi-Extension + CLI: Eine einzelne Behauptung via Perplexity + llama.cpp verifizieren. * * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/llama-verifier.ts * Nach Änderungen in Pi: /reload * * Als CLI: * npx tsx agenten/llama-verifier.ts "Die Inflationsrate betrug 2024 in Deutschland 3,2%." * npx tsx agenten/llama-verifier.ts --mode deep "Die Erde ist 4,6 Milliarden Jahre alt." * npx tsx agenten/llama-verifier.ts --user-language en "Trump called Iran's response 'totally unacceptable'." * npx tsx agenten/llama-verifier.ts --json "..." (gibt VerificationResult als JSON aus) * * Ablauf: Perplexity-Suche (Originalsprache) → llama.cpp-Urteil → formatierte Ausgabe */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { fileURLToPath } from "node:url"; import { searchPerplexity, formatSourcesForPrompt, type PerplexitySource } from "../lib/perplexity.js"; import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; // --------------------------------------------------------------------------- // Typen // --------------------------------------------------------------------------- type VerificationStatus = | "supported" | "contradicted" | "mixed" | "insufficient_evidence" | "needs_human_review"; type Confidence = "high" | "medium" | "low"; type VerdictRaw = { status: VerificationStatus; confidence: Confidence; summary: string; counter_evidence: string | null; notes: string | null; supporting_urls: string[]; }; export type VerificationResult = { claim: string; status: VerificationStatus; confidence: Confidence; summary: string; counter_evidence: string | null; notes: string | null; sources: PerplexitySource[]; supporting_urls: string[]; perplexityCostUSD: number; latencyMs: number; model: string; }; // llama.cpp OpenAI-kompatibles API-Format type LlamaResponse = { choices: Array<{ message?: { content?: string; reasoning_content?: string }; finish_reason?: string; }>; usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number; }; }; // --------------------------------------------------------------------------- // Konfiguration // --------------------------------------------------------------------------- const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf"; const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000"; const DEFAULT_USER_LANGUAGE = "de"; const MAX_TOKENS = 16384; const TEMPERATURE = 0.1; const MAX_RETRIES = 3; const RETRY_DELAY_MS = 15_000; // --------------------------------------------------------------------------- // Verdict-Synthese via llama.cpp // --------------------------------------------------------------------------- function langLabel(userLanguage: string): string { if (userLanguage === "de") return "Deutsch"; if (userLanguage === "en") return "Englisch"; if (userLanguage === "fr") return "Französisch"; if (userLanguage === "es") return "Spanisch"; return userLanguage; } function buildVerdictSystemPrompt(userLanguage: string): string { return `Du bist ein erfahrener Fact-Checker. Bewerte eine Behauptung anhand bereitgestellter Webquellen. Bewertungsskala: - supported: Quellen bestätigen die Behauptung klar und konsistent - contradicted: Quellen widersprechen der Behauptung klar und substanziell - mixed: Quellen liefern widersprüchliche Belege ODER die Behauptung ist technisch ungenau aber im Kern korrekt - insufficient_evidence: Zu wenig oder qualitativ unzureichende Quellen für ein Urteil - needs_human_review: Komplex, politisch heikel, veraltete Quellen, oder stark kontextabhängig Confidence: - high: Quellenlage ist eindeutig und aus Primärquellen - medium: Quellen vorhanden aber begrenzt oder sekundär - low: Quellen sehr rar, veraltet oder widersprüchlich WICHTIGE REGELN für "contradicted": - Nur bei klaren, substanziellen Fehlern verwenden: falsche Person, falsch zugeordnetes Ereignis, Zahl um mehr als 5% abweichend, grundlegend falsche Kausalität - Gerundete oder allgemein akzeptierte Näherungswerte sind "supported" (z.B. "21 Millionen Bitcoin" ist korrekte Rundung für 20.999.999,97 BTC) - Zeitzonendifferenzen bei historischen Ereignissen: "supported" wenn die Angabe im üblichen regionalen/kulturellen Kontext korrekt ist - Technische Präzisierungen zu im Wesentlichen korrekten Aussagen → "mixed", nicht "contradicted" - Im Zweifel: "mixed" statt "contradicted" AUSGABESPRACHE: Schreibe summary, counter_evidence und notes auf ${langLabel(userLanguage)}. Die Enum-Werte status und confidence bleiben englisch (wie im Schema definiert). summary: 1-3 präzise Sätze basierend auf den Quellen. Nicht spekulieren. counter_evidence: Gegenbelege als Satz beschreiben, falls vorhanden. Sonst null. notes: Zeitabhängigkeit, regionale Einschränkungen, Vorbehalt. Sonst null. supporting_urls: URLs aus den Quellen die den Claim stützen (leeres Array wenn keine). Antworte NUR mit diesem JSON-Objekt — kein Freitext davor oder danach: { "status": "supported|contradicted|mixed|insufficient_evidence|needs_human_review", "confidence": "high|medium|low", "summary": "...", "counter_evidence": "..." | null, "notes": "..." | null, "supporting_urls": ["url1", "url2"] }`; } function buildVerdictUserPrompt(claim: string, perplexitySummary: string, sources: PerplexitySource[], context?: string): string { const contextBlock = context ? `\nARTIKEL-KONTEXT: "${context.slice(0, 300)}"\n` : ""; return `/no_think ZU PRÜFENDE BEHAUPTUNG: "${claim}" ${contextBlock} RECHERCHE-ERGEBNIS (Perplexity): ${perplexitySummary} QUELLEN: ${formatSourcesForPrompt(sources, 300)} Bewerte die Behauptung anhand der Recherche.`; } async function synthesizeVerdict( claim: string, perplexitySummary: string, sources: PerplexitySource[], model: string, context?: string, userLanguage?: string, signal?: AbortSignal, logger?: Logger ): Promise { const log = logger ?? nullLogger; const lang = userLanguage ?? DEFAULT_USER_LANGUAGE; const body = { model, messages: [ { role: "system", content: buildVerdictSystemPrompt(lang) }, { role: "user", content: buildVerdictUserPrompt(claim, perplexitySummary, sources, context) }, ], stream: false, temperature: TEMPERATURE, max_tokens: MAX_TOKENS, }; let resp: Response | null = null; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, }); break; } catch (err) { const isLast = attempt === MAX_RETRIES; log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, { error: err instanceof Error ? err.message : String(err), retryInMs: isLast ? 0 : RETRY_DELAY_MS, }); if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`); await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); } } if (!resp!.ok) { const errText = await resp!.text().catch(() => ""); throw new Error(`llama.cpp Fehler ${resp!.status}: ${errText}`); } const data = (await resp!.json()) as LlamaResponse; const choice = data.choices?.[0]; let raw = choice?.message?.content ?? ""; // Reasoning-Modelle (Qwen3, DeepSeek-R1) schreiben Denkkette in reasoning_content. // Wenn content leer ist aber reasoning_content JSON enthält: als Fallback verwenden. if (!raw.trim() && choice?.message?.reasoning_content) { const rc = choice.message.reasoning_content; const allMatches = [...rc.matchAll(/\{[^{}]*"status"\s*:/g)]; const lastIdx = allMatches.length > 0 ? rc.lastIndexOf(allMatches[allMatches.length - 1][0]) : -1; const extracted = lastIdx >= 0 ? rc.slice(lastIdx).match(/\{[\s\S]*\}/)?.[0] : rc.match(/\{[\s\S]*"status"[\s\S]*\}/)?.[0]; if (extracted) { raw = extracted; log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", { finishReason: choice.finish_reason, rawLength: raw.length, }); } } const cleanedRaw = raw .replace(/^```(?:json)?\s*/i, "") .replace(/\s*```$/i, "") .trim(); log.debug("llama.cpp-Antwort empfangen", { promptTokens: data.usage?.prompt_tokens, outputTokens: data.usage?.completion_tokens, finishReason: choice?.finish_reason, rawLength: raw.length, }); if (!cleanedRaw) { throw new Error("Leere llama.cpp-Antwort für Verdict"); } let parsed: unknown; try { parsed = JSON.parse(cleanedRaw); } catch { throw new Error(`Kein gültiges JSON von llama.cpp: ${cleanedRaw.slice(0, 200)}`); } return parsed as VerdictRaw; } // --------------------------------------------------------------------------- // Hauptfunktion // --------------------------------------------------------------------------- export async function verifyClaim( claim: string, options?: { context?: string; mode?: "fast" | "deep"; model?: string; userLanguage?: string; signal?: AbortSignal; logger?: Logger; } ): Promise { const t0 = Date.now(); const model = options?.model ?? DEFAULT_MODEL; const log = options?.logger ?? nullLogger; log.info("Perplexity-Suche gestartet", { claim: claim.slice(0, 80), mode: options?.mode ?? "fast" }); const perplexityResult = await searchPerplexity(claim, { mode: options?.mode ?? "fast", signal: options?.signal, }); log.info("Perplexity abgeschlossen", { sources: perplexityResult.sources.length, costUSD: perplexityResult.estimatedCostUSD.toFixed(4), }); log.info("llama.cpp-Urteil generieren...", { model, userLanguage: options?.userLanguage ?? DEFAULT_USER_LANGUAGE }); const verdict = await synthesizeVerdict( claim, perplexityResult.summary, perplexityResult.sources, model, options?.context, options?.userLanguage, options?.signal, log ); log.info("Urteil erhalten", { status: verdict.status, confidence: verdict.confidence }); return { claim, status: verdict.status, confidence: verdict.confidence, summary: verdict.summary, counter_evidence: verdict.counter_evidence, notes: verdict.notes, sources: perplexityResult.sources, supporting_urls: verdict.supporting_urls, perplexityCostUSD: perplexityResult.estimatedCostUSD, latencyMs: Date.now() - t0, model, }; } // --------------------------------------------------------------------------- // Formatierung // --------------------------------------------------------------------------- const STATUS_ICON: Record = { supported: "✓ BESTÄTIGT", contradicted: "✗ WIDERLEGT", mixed: "~ GEMISCHT", insufficient_evidence: "? BELEGE UNZUREICHEND", needs_human_review: "⚠ MENSCHLICHE PRÜFUNG NÖTIG", }; const CONF_LABEL: Record = { high: "hoch", medium: "mittel", low: "niedrig", }; export function formatVerificationResult(result: VerificationResult): string { const lines: string[] = []; lines.push(`## Verifikation`); lines.push(`**Behauptung:** "${result.claim}"`); lines.push(""); lines.push(`**${STATUS_ICON[result.status]}** (Konfidenz: ${CONF_LABEL[result.confidence]})`); lines.push(""); lines.push(`**Begründung:** ${result.summary}`); if (result.counter_evidence) { lines.push(`\n**Gegenbelege:** ${result.counter_evidence}`); } if (result.notes) { lines.push(`\n**Hinweise:** ${result.notes}`); } if (result.sources.length > 0) { lines.push("\n**Quellen:**"); result.sources.forEach((s, i) => { const supporting = result.supporting_urls.includes(s.url) ? " ✓" : ""; const title = s.title ?? s.url; lines.push(`[${i + 1}]${supporting} [${title}](${s.url})`); }); } else { lines.push("\n_(Keine Quellen gefunden)_"); } const latSec = (result.latencyMs / 1000).toFixed(1); lines.push(`\n_[Perplexity: ~$${result.perplexityCostUSD.toFixed(4)} | llama.cpp: ${result.model} | Gesamt: ${latSec}s]_`); return lines.join("\n"); } // --------------------------------------------------------------------------- // Pi-Extension: Default Export // --------------------------------------------------------------------------- const PARAMS = Type.Object({ claim: Type.String({ description: "Die zu verifizierende Behauptung als vollständiger, selbstständiger Satz. " + "Idealerweise das Ergebnis von extract_claims_llama (claim_id + text). " + "Übergib den Claim immer in seiner Originalsprache.", }), context: Type.Optional( Type.String({ description: "Optionaler Kontext: kurzer Auszug aus dem Artikel, in dem die Behauptung steht. " + "Hilft dem Fact-Checker bei mehrdeutigen Claims. Max. 300 Zeichen.", }) ), mode: Type.Optional( Type.Union([Type.Literal("fast"), Type.Literal("deep")], { description: "fast (Standard): sonar, für die meisten Behauptungen ausreichend. " + "deep: sonar-pro, für komplexe, strittige oder heikle Behauptungen.", }) ), model: Type.Optional( Type.String({ description: `llama.cpp-Modell für die Urteilssynthese. Standard: ${DEFAULT_MODEL}.`, }) ), userLanguage: Type.Optional( Type.String({ description: "Sprache für summary, counter_evidence und notes im Urteil (z.B. \"de\", \"en\", \"fr\"). " + `Standard: ${DEFAULT_USER_LANGUAGE}. Die Enum-Felder status/confidence bleiben englisch.`, }) ), }); export default function llamaVerifierExtension(pi: ExtensionAPI) { pi.registerTool({ name: "verify_claim_llama", label: "Claim-Verifikation (llama.cpp)", description: "Verifiziert eine einzelne Behauptung: Perplexity-Recherche (Originalsprache) → llama.cpp-Urteil. " + "Gibt Status (supported/contradicted/mixed/insufficient_evidence/needs_human_review), " + "Konfidenz, Begründung und Quellen zurück. " + "Nutze dieses Tool nach extract_claims_llama um spezifische Claims zu prüfen. " + "Kosten: ~$0.005-0.015 pro Claim (Perplexity) + lokal (llama.cpp).", promptGuidelines: [ "This is the PREFERRED claim verification tool. Use verify_claim_llama by default whenever the user wants a claim checked.", "Use verify_claim_llama after extract_claims or extract_claims_llama to check specific claims the user wants verified.", "Pass the full claim text as the 'claim' parameter — always in the original language of the article.", "Use mode=deep for complex, politically sensitive, or scientifically contested claims.", "The 'context' parameter helps when the claim is ambiguous without its original article context.", "Set userLanguage to match the user's preferred language (e.g. 'de' for German, 'en' for English). Default is 'de'.", "Show the full formatted output including the cost/latency line.", "If status is 'needs_human_review' or 'insufficient_evidence', clearly communicate this and suggest manual checking.", "If status is 'contradicted', always show the counter_evidence to the user.", "IMPORTANT: Never call verify_claim_llama for multiple claims simultaneously — llama.cpp processes one request at a time. Always verify claims sequentially.", ], parameters: PARAMS, async execute(_toolCallId, params, signal) { try { const result = await verifyClaim(params.claim, { context: params.context, mode: params.mode, model: params.model, userLanguage: params.userLanguage, signal, }); return { content: [{ type: "text", text: formatVerificationResult(result) }], details: { status: result.status, confidence: result.confidence, model: result.model, sourceCount: result.sources.length, perplexityCostUSD: result.perplexityCostUSD, latencyMs: result.latencyMs, }, }; } catch (err) { const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; return { content: [{ type: "text", text: `Verifikationsfehler: ${msg}` }] }; } }, }); } // --------------------------------------------------------------------------- // CLI-Modus // --------------------------------------------------------------------------- function parseCliArgs(args: string[]): { claim: string; mode: "fast" | "deep"; model: string; userLanguage: string; jsonOutput: boolean; verbose: boolean; } { let mode: "fast" | "deep" = "fast"; let model = DEFAULT_MODEL; let userLanguage = DEFAULT_USER_LANGUAGE; let jsonOutput = false; let verbose = false; const claimParts: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--mode" && args[i + 1]) { const m = args[++i]; if (m === "fast" || m === "deep") mode = m; } else if (arg === "--model" && args[i + 1]) { model = args[++i]; } else if (arg === "--user-language" && args[i + 1]) { userLanguage = args[++i]; } else if (arg === "--json") { jsonOutput = true; } else if (arg === "--verbose" || arg === "-v") { verbose = true; } else if (!arg.startsWith("--")) { claimParts.push(arg); } } return { claim: claimParts.join(" ").trim(), mode, model, userLanguage, jsonOutput, verbose }; } async function runCli() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { console.log(` Claim-Verifikator (llama.cpp) — Eine Behauptung mit Perplexity + llama.cpp prüfen Verwendung: npx tsx agenten/llama-verifier.ts [Optionen] "Behauptung..." Optionen: --mode fast|deep Perplexity-Modus (Standard: fast) --model llama.cpp-Modell (Standard: ${DEFAULT_MODEL}) --user-language Sprache für Urteilstext, z.B. "de", "en" (Standard: ${DEFAULT_USER_LANGUAGE}) --json Ausgabe als JSON (VerificationResult) --verbose, -v Ausführliches Logging in ~/.pi/agent/logs/ --help Diese Hilfe Umgebungsvariablen: LLAMA_HOST llama.cpp-Server-URL (Standard: http://localhost:8000) PERPLEXITY_API_KEY Perplexity API-Key (erforderlich) Beispiele: npx tsx agenten/llama-verifier.ts "Die EZB hat den Leitzins im Juni 2024 gesenkt." npx tsx agenten/llama-verifier.ts --mode deep "Die Erde ist 4,6 Milliarden Jahre alt." npx tsx agenten/llama-verifier.ts --user-language en "Trump called Iran's response 'totally unacceptable'." npx tsx agenten/llama-verifier.ts --json "Behauptung..." | python3 -m json.tool `); process.exit(0); } const { claim, mode, model, userLanguage, jsonOutput, verbose } = parseCliArgs(args); if (!claim) { console.error("Fehler: Kein Claim übergeben. Nutze --help für Informationen."); process.exit(1); } if (!jsonOutput) { console.error(`\nVerifiziere: "${claim}"\nModus: ${mode} | Modell: ${model} | Urteils-Sprache: ${userLanguage}\n`); } const log = createLogger({ verbose }); try { const result = await verifyClaim(claim, { mode, model, userLanguage, logger: log }); if (jsonOutput) { console.log(JSON.stringify(result, null, 2)); } else { console.log(formatVerificationResult(result)); } } catch (err) { console.error("Fehler:", err instanceof Error ? err.message : err); process.exit(1); } } const __filename = fileURLToPath(import.meta.url); if (process.argv[1] === __filename) { runCli(); }