/** * ollama-claim-extractor.ts * Pi-Extension + CLI: Einzelbehauptungen aus Texten extrahieren via lokalem Ollama * * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ollama-claim-extractor.ts * Nach Änderungen in Pi: /reload * * Als CLI: * npx tsx agenten/ollama-claim-extractor.ts "Textinhalt..." * npx tsx agenten/ollama-claim-extractor.ts --only-checkable "Textinhalt..." * npx tsx agenten/ollama-claim-extractor.ts --model qwen3.5:27b "Textinhalt..." * npx tsx agenten/ollama-claim-extractor.ts --json "Textinhalt..." (nur JSON-Ausgabe) * * Modell-Empfehlung: qwen3.5:9b (6.6GB, 1 GPU, fast gleiche Präzision wie 27B, 2× schneller) */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { fileURLToPath } from "node:url"; import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; // --------------------------------------------------------------------------- // Typen // --------------------------------------------------------------------------- export type ClaimType = "fact" | "causal" | "statistical" | "quote" | "prediction" | "opinion"; export type Checkability = "checkable" | "partly_checkable" | "not_checkable"; export type Claim = { claim_id: string; text: string; claim_type: ClaimType; checkability: Checkability; needs_citation: boolean; entities: string[]; time_scope: string | null; source_sentence: string; }; export type ClaimSet = { schema_version: "1.0.0"; text_language: string; extraction_notes: string; total_claims: number; claims: Claim[]; }; type OllamaResponse = { message?: { content?: string }; done?: boolean; eval_count?: number; prompt_eval_count?: number; }; // --------------------------------------------------------------------------- // Konfiguration // --------------------------------------------------------------------------- const DEFAULT_MODEL = "qwen3.5:9b"; const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434"; const DEFAULT_MAX_CLAIMS = 40; const TEMPERATURE = 0.1; const NUM_CTX = 8192; // Texte über diesem Schwellenwert werden in Chunks aufgeteilt (Zeichen) // 8192 Tokens Kontext: ~3000 Zeichen Input + ~1000 Prompt-Overhead + ~3200 Tokens Output (40 Claims) const CHUNK_THRESHOLD = 4000; const CHUNK_SIZE = 3000; // --------------------------------------------------------------------------- // JSON-Schema für Ollama structured output // (Teilmenge von claim.schema.json — ohne Pattern-Constraint, da Ollama // reguläre Ausdrücke im format-Parameter nicht immer unterstützt) // --------------------------------------------------------------------------- export const CLAIM_OLLAMA_SCHEMA = { type: "object", additionalProperties: false, properties: { schema_version: { type: "string" }, text_language: { type: "string" }, extraction_notes: { type: "string" }, total_claims: { type: "integer" }, claims: { type: "array", items: { type: "object", additionalProperties: false, properties: { claim_id: { type: "string" }, text: { type: "string" }, claim_type: { type: "string", enum: ["fact", "causal", "statistical", "quote", "prediction", "opinion"], }, checkability: { type: "string", enum: ["checkable", "partly_checkable", "not_checkable"], }, needs_citation: { type: "boolean" }, entities: { type: "array", items: { type: "string" } }, time_scope: { type: ["string", "null"] }, source_sentence: { type: "string" }, }, required: [ "claim_id", "text", "claim_type", "checkability", "needs_citation", "entities", "time_scope", "source_sentence", ], }, }, }, required: ["schema_version", "text_language", "extraction_notes", "total_claims", "claims"], }; // --------------------------------------------------------------------------- // System-Prompt // --------------------------------------------------------------------------- function buildSystemPrompt(maxClaims: number): string { return `Du bist ein Experte für Faktenextraktion und Fact-Checking-Vorbereitung. Deine Aufgabe: Analysiere den Text und extrahiere alle Behauptungen als diskrete, einzeln prüfbare Einheiten. Extrahiere maximal ${maxClaims} Behauptungen. Bei sehr langen Texten priorisiere die wichtigsten und prüfbarsten. REGELN für die Extraktion: - Formuliere jede Behauptung als eigenständigen, vollständigen Satz (nicht als Fragment) - Behalte den Sinn der Originalformulierung bei, mache Behauptungen aber selbstständig lesbar - claim_id: fortlaufend "c001", "c002", "c003", ... CLAIM TYPES: - fact: Konkrete Tatsachenbehauptung ("X ist Y", "X hat Z getan") - causal: Kausalbehauptung ("X hat zu Y geführt", "wegen X passiert Y") - statistical: Zahlen, Prozentwerte, Statistiken, Rankings - quote: Wörtliches oder indirektes Zitat einer Person - prediction: Prognose, Vorhersage, Erwartung über Zukunftsereignisse - opinion: Wertung, Meinung, normative Aussage (gut/schlecht/sollte) CHECKABILITY: - checkable: Empirisch überprüfbar durch Primärquellen, Datenbanken, offizielle Stellen - partly_checkable: Nur teilweise prüfbar (z.B. enthält sowohl Fakt als auch Wertung) - not_checkable: Reine Meinung, reine Prognose, Werturteil ohne Tatsachenkern NEEDS_CITATION: true wenn Zahlen, spezifische Fakten, Zitate oder Studienergebnisse vorhanden ENTITIES: Alle benannten Entitäten: Personen, Organisationen, Länder, Institutionen, Produkte, konkrete Daten TIME_SCOPE: Zeitrahmen wenn angegeben (z.B. "2024", "Q1 2025", "seit 1990"), sonst null SOURCE_SENTENCE: Der originale Satz aus dem Quelltext (wörtlich, max. 200 Zeichen) DUPLIKATE: Extrahiere jeden Sachverhalt nur einmal. Wenn derselbe Fakt im Text mehrfach vorkommt (z.B. als Einleitung und später als Detail), erstelle nur einen Claim dafür. Antworte NUR mit dem JSON-Objekt gemäß Schema. Kein Freitext davor oder danach.`; } // --------------------------------------------------------------------------- // Text-Chunking für lange Texte // --------------------------------------------------------------------------- /** * Teilt langen Text an Absatzgrenzen in Stücke von max. CHUNK_SIZE Zeichen. * Absätze werden nicht aufgetrennt — bei Absätzen > CHUNK_SIZE werden sie allein übergeben. */ function splitIntoChunks(text: string): string[] { const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 0); const chunks: string[] = []; let current = ""; for (const para of paragraphs) { if (current.length + para.length + 2 > CHUNK_SIZE && current.length > 0) { chunks.push(current.trim()); current = para; } else { current = current ? current + "\n\n" + para : para; } } if (current.trim()) chunks.push(current.trim()); return chunks; } /** * Entfernt doppelte Claims (gleicher text-Inhalt nach Normalisierung). */ function deduplicateClaims(claims: Claim[]): Claim[] { const seen = new Set(); return claims.filter((c) => { const key = c.text.toLowerCase().replace(/\s+/g, " ").trim(); if (seen.has(key)) return false; seen.add(key); return true; }); } // --------------------------------------------------------------------------- // Ollama-Aufruf // --------------------------------------------------------------------------- export async function callOllamaClaimExtract( text: string, model: string, maxClaims: number, signal?: AbortSignal, logger?: Logger ): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { const log = logger ?? nullLogger; // Langen Text in Chunks aufteilen if (text.length > CHUNK_THRESHOLD) { log.info("Text zu lang für Single-Pass — Chunking aktiv", { textLength: text.length, threshold: CHUNK_THRESHOLD }); return callOllamaClaimExtractChunked(text, model, maxClaims, signal, log); } log.debug("Single-Pass Extraktion", { textLength: text.length, model, maxClaims }); return callOllamaClaimExtractSingle(text, model, maxClaims, signal, log); } async function callOllamaClaimExtractChunked( text: string, model: string, maxClaims: number, signal?: AbortSignal, logger?: Logger ): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { const log = logger ?? nullLogger; const t0 = Date.now(); const chunks = splitIntoChunks(text); const claimsPerChunk = Math.ceil(maxClaims / chunks.length); log.info(`Text in ${chunks.length} Chunks aufgeteilt`, { chunks: chunks.length, claimsPerChunk, chunkLengths: chunks.map((c) => c.length), }); let totalIn = 0; let totalOut = 0; const allClaims: Claim[] = []; let language = "de"; const notes: string[] = []; for (let i = 0; i < chunks.length; i++) { log.info(`Chunk ${i + 1}/${chunks.length} extrahieren...`, { chunkLength: chunks[i].length, claimsPerChunk }); const result = await callOllamaClaimExtractSingle(chunks[i], model, claimsPerChunk, signal, log); log.info(`Chunk ${i + 1}/${chunks.length} fertig`, { claims: result.claimSet.claims.length, tokensIn: result.tokensIn, tokensOut: result.tokensOut, latencyMs: result.latencyMs, }); allClaims.push(...result.claimSet.claims); totalIn += result.tokensIn; totalOut += result.tokensOut; language = result.claimSet.text_language; if (result.claimSet.extraction_notes) notes.push(result.claimSet.extraction_notes); } // Deduplizieren und neu nummerieren const beforeDedup = allClaims.length; const unique = deduplicateClaims(allClaims).slice(0, maxClaims); const renumbered: Claim[] = unique.map((c, i) => ({ ...c, claim_id: `c${String(i + 1).padStart(3, "0")}`, })); log.info("Chunking abgeschlossen", { totalBeforeDedup: beforeDedup, afterDedup: renumbered.length, totalTokensIn: totalIn, totalTokensOut: totalOut, totalLatencyMs: Date.now() - t0, }); return { claimSet: { schema_version: "1.0.0", text_language: language, extraction_notes: `Text in ${chunks.length} Abschnitte aufgeteilt. ${notes.filter(Boolean).join(" ")}`, total_claims: renumbered.length, claims: renumbered, }, tokensIn: totalIn, tokensOut: totalOut, latencyMs: Date.now() - t0, }; } async function callOllamaClaimExtractSingle( text: string, model: string, maxClaims: number, signal?: AbortSignal, logger?: Logger ): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { const log = logger ?? nullLogger; const t0 = Date.now(); const body = { model, messages: [ { role: "system", content: buildSystemPrompt(maxClaims), }, { role: "user", content: `Extrahiere alle Behauptungen aus folgendem Text:\n\n---\n${text}\n---`, }, ], format: CLAIM_OLLAMA_SCHEMA, stream: false, options: { temperature: TEMPERATURE, num_ctx: NUM_CTX, }, }; log.debug("Ollama-Aufruf gestartet", { model, textLength: text.length, num_ctx: NUM_CTX }); // Retry bei temporären Verbindungsfehlern (Ollama startet kurz neu oder ist kurz ausgelastet) const MAX_RETRIES = 3; const RETRY_DELAY_MS = 15_000; // 15s Pause vor Retry let lastError: unknown; let resp: Response | null = null; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { resp = await fetch(`${OLLAMA_HOST}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, }); break; // Verbindung erfolgreich } catch (err) { lastError = err; const isLast = attempt === MAX_RETRIES; log.warn(`Ollama 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}`); // Warten bevor Retry — Ollama könnte kurz neu starten await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); } } if (!resp!.ok) { const errorText = await resp!.text().catch(() => ""); log.error("Ollama API Fehler", { status: resp!.status, body: errorText.slice(0, 200) }); throw new Error(`Ollama API Fehler ${resp!.status}: ${errorText}`); } const data = (await resp!.json()) as OllamaResponse; const raw = data.message?.content ?? ""; log.debug("Ollama-Antwort empfangen", { promptTokens: data.prompt_eval_count, outputTokens: data.eval_count, rawLength: raw.length, }); if (!raw.trim()) { log.error("Leere Ollama-Antwort", { promptTokens: data.prompt_eval_count }); throw new Error("Leere Antwort von Ollama erhalten"); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { log.error("JSON-Parse-Fehler", { rawPreview: raw.slice(0, 200) }); throw new Error(`Ollama-Ausgabe ist kein gültiges JSON: ${raw.slice(0, 200)}`); } // Grundlegende Strukturprüfung (kein vollständiger Schema-Validator) const p = parsed as Record; if (!Array.isArray(p.claims)) { log.error("Ungültige Struktur: claims fehlt", { keys: Object.keys(p) }); throw new Error(`Ungültige Struktur: 'claims' fehlt oder ist kein Array`); } if ((p.claims as unknown[]).length === 0) { // Leere Claims deuten auf Kontext-Overflow oder Modell-Fehler hin const usedCtx = data.prompt_eval_count ?? 0; log.warn("0 Claims extrahiert", { promptTokens: usedCtx, num_ctx: NUM_CTX, textLength: text.length }); throw new Error( `Ollama hat 0 Claims extrahiert (prompt_tokens=${usedCtx}). ` + `Text zu lang für num_ctx=${NUM_CTX} oder Modell-Fehler.` ); } const claimSet: ClaimSet = { schema_version: "1.0.0", text_language: typeof p.text_language === "string" ? p.text_language : "unknown", extraction_notes: typeof p.extraction_notes === "string" ? p.extraction_notes : "", total_claims: typeof p.total_claims === "number" ? p.total_claims : (p.claims as unknown[]).length, claims: p.claims as Claim[], }; return { claimSet, tokensIn: data.prompt_eval_count ?? 0, tokensOut: data.eval_count ?? 0, latencyMs: Date.now() - t0, }; } // --------------------------------------------------------------------------- // Formatierung (Pi-Ausgabe + CLI-Ausgabe) // --------------------------------------------------------------------------- const TYPE_LABEL: Record = { fact: "FAKT", causal: "KAUSAL", statistical: "STATISTIK", quote: "ZITAT", prediction: "PROGNOSE", opinion: "MEINUNG", }; const CHECK_ICON: Record = { checkable: "✓", partly_checkable: "~", not_checkable: "✗", }; function formatClaimSet( claimSet: ClaimSet, onlyCheckable: boolean, model: string, tokensIn: number, tokensOut: number, latencyMs: number ): string { const filtered = onlyCheckable ? claimSet.claims.filter((c) => c.checkability === "checkable") : claimSet.claims; const checkable = filtered.filter((c) => c.checkability === "checkable"); const partlyCheckable = filtered.filter((c) => c.checkability === "partly_checkable"); const notCheckable = filtered.filter((c) => c.checkability === "not_checkable"); const lines: string[] = []; lines.push( `## Claim-Extraktion: ${claimSet.total_claims} Behauptung${claimSet.total_claims !== 1 ? "en" : ""} gefunden` + (onlyCheckable && filtered.length < claimSet.total_claims ? ` (${filtered.length} prüfbar angezeigt)` : "") ); lines.push(`Sprache: ${claimSet.text_language}`); if (claimSet.extraction_notes) { lines.push(`Hinweis: ${claimSet.extraction_notes}`); } lines.push(""); function renderClaims(claims: Claim[], sectionTitle: string) { if (claims.length === 0) return; lines.push(`**${sectionTitle} (${claims.length}):**`); for (const c of claims) { const icon = CHECK_ICON[c.checkability]; const type = TYPE_LABEL[c.claim_type]; lines.push(`\`${c.claim_id}\` ${icon} [${type}] ${c.text}`); const meta: string[] = []; if (c.entities.length > 0) meta.push(`Entitäten: ${c.entities.join(", ")}`); if (c.time_scope) meta.push(`Zeit: ${c.time_scope}`); if (c.needs_citation) meta.push(`Zitat nötig: ja`); if (meta.length > 0) { lines.push(` ${meta.join(" | ")}`); } lines.push(""); } } renderClaims(checkable, "✓ Prüfbar"); if (!onlyCheckable) { renderClaims(partlyCheckable, "~ Teilweise prüfbar"); renderClaims(notCheckable, "✗ Nicht prüfbar"); } const latSec = (latencyMs / 1000).toFixed(1); const tokenInfo = tokensIn || tokensOut ? ` · ${tokensIn}+${tokensOut} Tokens` : ""; lines.push(`_[Ollama: ${model}${tokenInfo} · ${latSec}s]_`); return lines.join("\n"); } // --------------------------------------------------------------------------- // Pi-Extension-Parameters (TypeBox) // --------------------------------------------------------------------------- const PARAMS = Type.Object({ text: Type.String({ description: "Der zu analysierende Text. Kann ein Artikel, Blogeintrag, Nachrichtentext oder beliebiger Fließtext sein.", }), onlyCheckable: Type.Optional( Type.Boolean({ description: "Wenn true: nur empirisch prüfbare Claims ausgeben (checkable). Standard: false.", }) ), maxClaims: Type.Optional( Type.Number({ description: `Maximale Anzahl Claims pro Aufruf. Standard: ${DEFAULT_MAX_CLAIMS}.`, }) ), model: Type.Optional( Type.String({ description: `Ollama-Modell für die Extraktion. Standard: ${DEFAULT_MODEL}. Empfohlene Alternative: qwen3.5:27b für maximale Präzision.`, }) ), }); // --------------------------------------------------------------------------- // Pi-Extension: Default Export // --------------------------------------------------------------------------- export default function claimExtractorExtension(pi: ExtensionAPI) { pi.registerTool({ name: "extract_claims", label: "Claim-Extraktion", description: "Zerlegt einen Text in einzelne, diskrete Behauptungen (Claims) als Vorbereitung für Fact-Checking. " + "Nutze dieses Tool wenn: ein Artikel auf Fakten geprüft werden soll, Behauptungen aus einem Text " + "identifiziert und klassifiziert werden sollen, oder ein Verifikations-Workflow gestartet werden soll. " + "Läuft lokal via Ollama — keine API-Kosten.", promptGuidelines: [ "Use extract_claims when the user wants to fact-check an article, blog post, or any text.", "Use extract_claims before calling verify or research_web on specific claims.", "Pass the full text as the 'text' parameter — do not summarize or shorten it first.", "If the user only wants checkable claims, set onlyCheckable=true.", "After extraction, ask the user which claims they want to verify, or offer to run the verifier on all checkable claims.", "The claim_ids (c001, c002, ...) can be referenced in follow-up tool calls to the verifier.", "Always show the full formatted output to the user, including the [Ollama: ...] cost line.", ], parameters: PARAMS, async execute(_toolCallId, params, signal) { const model = params.model ?? DEFAULT_MODEL; const maxClaims = Math.min(params.maxClaims ?? DEFAULT_MAX_CLAIMS, 60); const onlyCheckable = params.onlyCheckable ?? false; try { const { claimSet, tokensIn, tokensOut, latencyMs } = await callOllamaClaimExtract( params.text, model, maxClaims, signal ); const text = formatClaimSet( claimSet, onlyCheckable, model, tokensIn, tokensOut, latencyMs ); return { content: [{ type: "text", text }], details: { model, totalClaims: claimSet.total_claims, checkableClaims: claimSet.claims.filter((c) => c.checkability === "checkable").length, textLanguage: claimSet.text_language, tokensIn: tokensIn || null, tokensOut: tokensOut || null, latencyMs, }, }; } catch (err) { const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; return { content: [{ type: "text", text: `Fehler bei Claim-Extraktion: ${msg}` }], }; } }, }); } // --------------------------------------------------------------------------- // CLI-Modus // --------------------------------------------------------------------------- function parseCliArgs(args: string[]): { text: string; model: string; maxClaims: number; onlyCheckable: boolean; jsonOutput: boolean; verbose: boolean; } { let model = DEFAULT_MODEL; let maxClaims = DEFAULT_MAX_CLAIMS; let onlyCheckable = false; let jsonOutput = false; let verbose = false; const textParts: string[] = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--model" && args[i + 1]) { model = args[++i]; } else if (arg === "--max-claims" && args[i + 1]) { maxClaims = parseInt(args[++i], 10); } else if (arg === "--only-checkable") { onlyCheckable = true; } else if (arg === "--json") { jsonOutput = true; } else if (arg === "--verbose" || arg === "-v") { verbose = true; } else if (!arg.startsWith("--")) { textParts.push(arg); } } const text = textParts.join(" ").trim(); return { text, model, maxClaims, onlyCheckable, jsonOutput, verbose }; } async function runCli() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { console.log(` Claim-Extraktor (Ollama) — Behauptungen aus Text extrahieren Verwendung: npx tsx agenten/ollama-claim-extractor.ts [Optionen] "Text..." Optionen: --model Ollama-Modell (Standard: ${DEFAULT_MODEL}) --max-claims Maximale Claims (Standard: ${DEFAULT_MAX_CLAIMS}) --only-checkable Nur prüfbare Claims anzeigen --json Ausgabe als reines JSON (ClaimSet) --verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/ --help Diese Hilfe Beispiele: npx tsx agenten/ollama-claim-extractor.ts "Die Erde hat 8 Milliarden Einwohner." npx tsx agenten/ollama-claim-extractor.ts --only-checkable "$(cat artikel.txt)" npx tsx agenten/ollama-claim-extractor.ts --verbose "$(cat langer-artikel.txt)" npx tsx agenten/ollama-claim-extractor.ts --model deepseek-r1:32b "..." npx tsx agenten/ollama-claim-extractor.ts --json "..." > claims.json `); process.exit(0); } const { text, model, maxClaims, onlyCheckable, jsonOutput, verbose } = parseCliArgs(args); if (!text) { console.error("Fehler: Kein Text übergeben. Nutze --help für Hinweise."); process.exit(1); } if (!jsonOutput) { console.error( `\nOllama-Modell: ${model} | Max. Claims: ${maxClaims} | Nur prüfbar: ${onlyCheckable}\n` ); } const log = createLogger({ verbose }); try { const { claimSet, tokensIn, tokensOut, latencyMs } = await callOllamaClaimExtract( text, model, maxClaims, undefined, log ); if (jsonOutput) { console.log(JSON.stringify(claimSet, null, 2)); } else { console.log( formatClaimSet(claimSet, onlyCheckable, model, tokensIn, tokensOut, latencyMs) ); } } catch (err) { console.error("Fehler:", err instanceof Error ? err.message : err); process.exit(1); } } // Einstiegspunkt für CLI — wird ignoriert wenn als Pi-Extension geladen const __filename = fileURLToPath(import.meta.url); if (process.argv[1] === __filename) { runCli(); }