/** * llama-claim-extractor.ts * Pi-Extension + CLI: Einzelbehauptungen aus Texten extrahieren via lokalem llama.cpp * * Als Pi-Extension: ~/.pi/agent/extensions/llama-claim-extractor.ts * Nach Änderungen in Pi: /reload * * Als CLI: * npx tsx agenten/llama-claim-extractor.ts "Textinhalt..." * npx tsx agenten/llama-claim-extractor.ts --file artikel.txt * npx tsx agenten/llama-claim-extractor.ts --only-checkable --file artikel.txt * npx tsx agenten/llama-claim-extractor.ts --json "..." (nur JSON-Ausgabe) * * llama.cpp-Server starten: * llama-server --model --host 0.0.0.0 --port 8000 -c 8192 * * Hinweis: llama.cpp verwendet das OpenAI-kompatible API-Format (/v1/chat/completions). */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { fileURLToPath } from "node:url"; import { readFile } from "node:fs/promises"; 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; text_translated?: string; // Übersetzung für Lesbarkeit — NIE für Faktencheck verwenden 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[]; }; // llama.cpp OpenAI-kompatibles API-Format // reasoning_content: Qwen3/DeepSeek-R1-Reasoning-Modelle schreiben Denkkette hierhin 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_MAX_CLAIMS = 40; const TEMPERATURE = 0.1; // Reasoning-Modelle brauchen mehr Tokens: Denkkette + JSON-Output // Mit Übersetzung noch mehr: base 16384, mit Translation 32768 const MAX_TOKENS_BASE = 16384; const MAX_TOKENS_WITH_TRANSLATION = 32768; const CHUNK_THRESHOLD = 4000; const CHUNK_SIZE = 3000; // --------------------------------------------------------------------------- // JSON-Schema für strukturierten Output // --------------------------------------------------------------------------- export const CLAIM_JSON_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" }, text_translated: { 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, translateTo?: string): 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. SPRACHE DES OUTPUTS (ZWINGEND): - "text" und "source_sentence" IMMER in der Originalsprache des Artikels belassen — niemals übersetzen - Wörtliche Zitate (claim_type="quote") wortwörtlich aus dem Text übernehmen - Übersetzungen verfälschen den späteren Faktencheck und sind in diesen Feldern verboten ` + (translateTo ? "\nÜBERSETZUNG (zusätzlich):\n" + "- Füge für jeden Claim das Feld text_translated hinzu\n" + "- text_translated enthält die Übersetzung von text ins " + (translateTo === "de" ? "Deutsche" : translateTo === "en" ? "Englische" : translateTo) + "\n" + "- Nur zur Lesbarkeit — nicht für den Faktencheck\n" : "") + ` ANTWORTFORMAT: Antworte NUR mit einem JSON-Objekt — kein Freitext davor oder danach. Das JSON muss folgende Felder enthalten: - schema_version: "1.0.0" - text_language: Sprache des Textes als ISO 639-1 Code (z.B. "de", "en", "fr") - extraction_notes: Kurze Notiz zur Extraktion - total_claims: Anzahl der Claims - claims: Array von Claim-Objekten mit den Feldern: - claim_id: "c001", "c002", etc. - text: Die Behauptung als vollständiger Satz (ORIGINALSPRACHE!) ` + (translateTo ? "- text_translated: Übersetzung ins " + (translateTo === "de" ? "Deutsche" : translateTo === "en" ? "Englische" : translateTo) + "\n " : "") + `- claim_type: einer von [fact, causal, statistical, quote, prediction, opinion] - checkability: einer von [checkable, partly_checkable, not_checkable] - needs_citation: true/false - entities: Array von benannten Entitäten - time_scope: Zeitrahmen oder null - source_sentence: Originalsatz aus dem Text (ORIGINALSPRACHE!, max. 200 Zeichen)`; } // --------------------------------------------------------------------------- // Text-Chunking für lange Texte // --------------------------------------------------------------------------- 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; } 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; }); } // --------------------------------------------------------------------------- // llama.cpp-Aufruf // --------------------------------------------------------------------------- export async function callLlamaClaimExtract( text: string, model: string, maxClaims: number, signal?: AbortSignal, logger?: Logger, translateTo?: string ): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { const log = logger ?? nullLogger; if (text.length > CHUNK_THRESHOLD) { log.info("Text zu lang für Single-Pass — Chunking aktiv", { textLength: text.length, threshold: CHUNK_THRESHOLD }); return callLlamaClaimExtractChunked(text, model, maxClaims, signal, log, translateTo); } log.debug("Single-Pass Extraktion", { textLength: text.length, model, maxClaims }); return callLlamaClaimExtractSingle(text, model, maxClaims, signal, log, translateTo); } async function callLlamaClaimExtractChunked( text: string, model: string, maxClaims: number, signal?: AbortSignal, logger?: Logger, translateTo?: string ): 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 callLlamaClaimExtractSingle(chunks[i], model, claimsPerChunk, signal, log, translateTo); 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); } 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 callLlamaClaimExtractSingle( text: string, model: string, maxClaims: number, signal?: AbortSignal, logger?: Logger, translateTo?: string ): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { const log = logger ?? nullLogger; const t0 = Date.now(); const maxTokens = translateTo ? MAX_TOKENS_WITH_TRANSLATION : MAX_TOKENS_BASE; const body = { model, messages: [ { role: "system", content: buildSystemPrompt(maxClaims, translateTo), }, { role: "user", // /no_think deaktiviert den Thinking-Modus bei Qwen3/Qwopus-Reasoning-Modellen content: `/no_think\nExtrahiere alle Behauptungen aus folgendem Text:\n\n---\n${text}\n---`, }, ], stream: false, temperature: TEMPERATURE, max_tokens: maxTokens, }; log.debug("llama.cpp-Aufruf gestartet", { model, textLength: text.length, max_tokens: maxTokens, translateTo }); const MAX_RETRIES = 3; const RETRY_DELAY_MS = 15_000; 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 errorText = await resp!.text().catch(() => ""); log.error("llama.cpp API Fehler", { status: resp!.status, body: errorText.slice(0, 200) }); throw new Error(`llama.cpp API Fehler ${resp!.status}: ${errorText}`); } 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; // Letztes vollständiges JSON-Objekt mit "claims"-Array suchen (greedy, von hinten) const allMatches = [...rc.matchAll(/\{[^{}]*"claims"\s*:\s*\[[\s\S]*?\]\s*[^{}]*\}/g)]; const lastMatch = allMatches.length > 0 ? allMatches[allMatches.length - 1][0] : rc.match(/\{[\s\S]*"claims"[\s\S]*\}/)?.[0]; if (lastMatch) { raw = lastMatch; log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", { finishReason: choice.finish_reason, rawLength: raw.length, }); } } // llama.cpp wrappt JSON manchmal in Markdown-Codeblöcke (```json ... ```) 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, cleanedLength: cleanedRaw.length, }); if (!cleanedRaw) { log.error("Leere llama.cpp-Antwort", { promptTokens: data.usage?.prompt_tokens, finishReason: choice?.finish_reason, hasReasoningContent: !!choice?.message?.reasoning_content, }); throw new Error("Leere Antwort von llama.cpp erhalten"); } let parsed: unknown; try { parsed = JSON.parse(cleanedRaw); } catch { log.error("JSON-Parse-Fehler", { cleanedRawPreview: cleanedRaw.slice(0, 200) }); throw new Error(`llama.cpp-Ausgabe ist kein gültiges JSON: ${cleanedRaw.slice(0, 200)}`); } 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) { const usedCtx = data.usage?.prompt_tokens ?? 0; log.warn("0 Claims extrahiert", { promptTokens: usedCtx, max_tokens: maxTokens, textLength: text.length }); throw new Error( `llama.cpp hat 0 Claims extrahiert (prompt_tokens=${usedCtx}). ` + `Text zu lang für Kontext-Fenster 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.usage?.prompt_tokens ?? 0, tokensOut: data.usage?.completion_tokens ?? 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}`); if (c.text_translated) { lines.push(` → _${c.text_translated}_`); } 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(`_[llama.cpp: ${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: `llama.cpp-Modell für die Extraktion. Standard: ${DEFAULT_MODEL}.`, }) ), translateTo: Type.Optional( Type.String({ description: "Zielsprache für optionale Übersetzung der Claims (z.B. \"de\", \"en\"). " + "Das Feld `text` bleibt immer in der Originalsprache des Artikels. " + "Wenn gesetzt: jeder Claim erhält zusätzlich `text_translated`. Standard: keine Übersetzung.", }) ), }); // --------------------------------------------------------------------------- // Pi-Extension: Default Export // --------------------------------------------------------------------------- export default function llamaClaimExtractorExtension(pi: ExtensionAPI) { pi.registerTool({ name: "extract_claims_llama", label: "Claim-Extraktion (llama.cpp)", 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 llama.cpp — keine API-Kosten.", promptGuidelines: [ "Use extract_claims_llama when the user wants to fact-check an article, blog post, or any text.", "Use extract_claims_llama 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 [llama.cpp: ...] 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; const translateTo = params.translateTo; try { const { claimSet, tokensIn, tokensOut, latencyMs } = await callLlamaClaimExtract( params.text, model, maxClaims, signal, undefined, translateTo ); 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; file: string | null; model: string; maxClaims: number; onlyCheckable: boolean; jsonOutput: boolean; verbose: boolean; translateTo: string | undefined; } { let model = DEFAULT_MODEL; let maxClaims = DEFAULT_MAX_CLAIMS; let onlyCheckable = false; let jsonOutput = false; let verbose = false; let file: string | null = null; let translateTo: string | undefined; 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 === "--file" || arg === "-f") && args[i + 1]) { file = args[++i]; } else if (arg === "--translate-to" && args[i + 1]) { translateTo = args[++i]; } else if (!arg.startsWith("--")) { textParts.push(arg); } } const text = textParts.join(" ").trim(); return { text, file, model, maxClaims, onlyCheckable, jsonOutput, verbose, translateTo }; } async function runCli() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { console.log(` Claim-Extraktor — Behauptungen aus Text extrahieren (llama.cpp-Version) Verwendung: npx tsx agenten/llama-claim-extractor.ts [Optionen] "Text..." npx tsx agenten/llama-claim-extractor.ts --file [Optionen] Optionen: --file, -f Text aus Datei lesen (statt als Argument übergeben) --model llama.cpp-Modell (Standard: ${DEFAULT_MODEL}) --max-claims Maximale Claims (Standard: ${DEFAULT_MAX_CLAIMS}) --only-checkable Nur prüfbare Claims anzeigen --translate-to Übersetzung der Claims in Zielsprache (z.B. "de", "en") text bleibt in Originalsprache — text_translated enthält Übersetzung --json Ausgabe als reines JSON (ClaimSet) --verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/ --help Diese Hilfe Umgebungsvariablen: LLAMA_HOST llama.cpp-Server-URL (Standard: http://localhost:8000) Beispiele: npx tsx agenten/llama-claim-extractor.ts "Die Erde hat 8 Milliarden Einwohner." npx tsx agenten/llama-claim-extractor.ts --file Totally_unacceptable_article.txt npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --only-checkable npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --json > claims.json npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --verbose `); process.exit(0); } const { text: argText, file, model, maxClaims, onlyCheckable, jsonOutput, verbose, translateTo } = parseCliArgs(args); let text: string; if (file) { try { text = await readFile(file, "utf-8"); } catch (err) { console.error(`Fehler: Datei '${file}' konnte nicht gelesen werden: ${err instanceof Error ? err.message : err}`); process.exit(1); } } else { text = argText; } if (!text.trim()) { console.error("Fehler: Kein Text übergeben. Nutze --file oder übergib den Text direkt. --help für Details."); process.exit(1); } if (!jsonOutput) { const source = file ? `Datei: ${file}` : "Direkteingabe"; const transInfo = translateTo ? ` | Übersetzung: ${translateTo}` : ""; console.error(`\nllama.cpp-Modell: ${model} | Max. Claims: ${maxClaims} | Nur prüfbar: ${onlyCheckable} | ${source}${transInfo}\n`); } const log = createLogger({ verbose }); try { const { claimSet, tokensIn, tokensOut, latencyMs } = await callLlamaClaimExtract( text, model, maxClaims, undefined, log, translateTo ); 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(); }