/** * ollama-verify-article.ts * Pi-Extension + CLI: Vollständige Fact-Check-Pipeline für Artikel * * Ablauf: * 1. Claim-Extraktion via Ollama (lokal) * 2. Perplexity-Recherche für alle prüfbaren Claims (parallel) * 3. Batch-Urteilssynthese via Ollama (1 Aufruf für alle Claims) * 4. Verifikationsbericht formatieren * * Als Pi-Extension: ~/.pi/agent/extensions/ollama-verify-article.ts * Als CLI: * npx tsx agenten/ollama-verify-article.ts "$(cat artikel.txt)" * npx tsx agenten/ollama-verify-article.ts --mode deep --max-claims 15 "..." * npx tsx agenten/ollama-verify-article.ts --json "..." > report.json */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { fileURLToPath } from "node:url"; import { searchPerplexity, formatSourcesForPrompt, type PerplexitySource, type PerplexityResult, } from "../lib/perplexity.js"; import { CLAIM_OLLAMA_SCHEMA, callOllamaClaimExtract, type ClaimSet, } from "./ollama-claim-extractor.js"; import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; import { saveJobFile, loadJobFile, jobFileExists, updateJobMeta, getOrCreateJob, } from "../lib/jobs.js"; import { getCached, setCached } from "../lib/cache.js"; // --------------------------------------------------------------------------- // Typen // --------------------------------------------------------------------------- type VerificationStatus = | "supported" | "contradicted" | "mixed" | "insufficient_evidence" | "needs_human_review" | "not_checkable"; type Confidence = "high" | "medium" | "low"; type VerdictItem = { claim_id: string; status: VerificationStatus; confidence: Confidence; summary: string; counter_evidence: string | null; notes: string | null; supporting_urls: string[]; }; type BatchVerdictRaw = { verdicts: VerdictItem[] }; export type VerificationReport = { schema_version: "1.0.0"; verified_at: string; source_text_summary: string; summary: string; results: Array<{ claim_id: string; claim_text: string; status: VerificationStatus; confidence: Confidence; summary: string; sources: Array<{ url: string; title: string | null; supports_claim: boolean }>; counter_evidence: string | null; notes: string | null; }>; stats: Record; totalCostUSD: number; latencyMs: number; }; type OllamaResponse = { message?: { content?: string }; eval_count?: number; prompt_eval_count?: number; }; // --------------------------------------------------------------------------- // Konfiguration // --------------------------------------------------------------------------- const DEFAULT_MODEL = "qwen3.5:27b"; const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434"; const DEFAULT_MAX_CLAIMS = 15; const MAX_PARALLEL_PERPLEXITY = 5; // gleichzeitige Perplexity-Anfragen // --------------------------------------------------------------------------- // Batch-Urteilssynthese via Ollama // --------------------------------------------------------------------------- const BATCH_VERDICT_SCHEMA = { type: "object", additionalProperties: false, properties: { verdicts: { type: "array", items: { type: "object", additionalProperties: false, properties: { claim_id: { type: "string" }, status: { type: "string", enum: [ "supported", "contradicted", "mixed", "insufficient_evidence", "needs_human_review", ], }, confidence: { type: "string", enum: ["high", "medium", "low"] }, summary: { type: "string" }, counter_evidence: { type: ["string", "null"] }, notes: { type: ["string", "null"] }, supporting_urls: { type: "array", items: { type: "string" } }, }, required: [ "claim_id", "status", "confidence", "summary", "counter_evidence", "notes", "supporting_urls", ], }, }, }, required: ["verdicts"], }; function buildBatchVerdictPrompt( claims: Array<{ id: string; text: string; perplexity: PerplexityResult }> ): string { const systemPrompt = `Du bist ein erfahrener Fact-Checker. Bewerte jede Behauptung anhand der bereitgestellten Recherche-Ergebnisse. Status-Skala: - supported: Quellen bestätigen klar und konsistent - contradicted: Quellen widersprechen klar und SUBSTANZIELL - mixed: Widersprüchliche Quellenlage ODER Behauptung technisch ungenau aber im Kern korrekt - insufficient_evidence: Zu wenig oder schwache Quellen - needs_human_review: Komplex, politisch heikel, stark kontextabhängig Confidence: high (eindeutige Primärquellen), medium (begrenzte/sekundäre Quellen), low (sehr unklar) WICHTIGE REGELN für "contradicted": - Nur bei klar substanziellen Fehlern: falsche Person, Zahl >5% abweichend, falsch zugeordnetes Ereignis - Gerundete/allgemein akzeptierte Näherungswerte → "supported" (z.B. "21 Millionen Bitcoin" ist korrekte Rundung) - Zeitzonendifferenzen historischer Ereignisse → "supported" wenn im üblichen regionalen Kontext korrekt - Technische Präzisierungen zu korrekten Aussagen → "mixed", nicht "contradicted" - Im Zweifel immer "mixed" statt "contradicted" summary: 1-3 präzise Sätze. Nicht spekulieren. counter_evidence: Gegenbelege als Satz, sonst null. notes: Zeitabhängigkeit, Einschränkungen, sonst null. supporting_urls: URLs der stützenden Quellen. Antworte NUR mit dem JSON-Objekt.`; const claimsBlock = claims .map(({ id, text, perplexity }) => { const sourcesFormatted = formatSourcesForPrompt(perplexity.sources, 200); return `--- BEHAUPTUNG ${id}: "${text}" RECHERCHE: ${perplexity.summary} QUELLEN: ${sourcesFormatted || "(keine Quellen gefunden)"}`; }) .join("\n\n"); return `${systemPrompt}\n\n${claimsBlock}\n\nBewerte alle ${claims.length} Behauptungen.`; } async function synthesizeBatchVerdicts( claims: Array<{ id: string; text: string; perplexity: PerplexityResult }>, model: string, signal?: AbortSignal ): Promise { if (claims.length === 0) return []; const prompt = buildBatchVerdictPrompt(claims); const body = { model, messages: [{ role: "user", content: prompt }], format: BATCH_VERDICT_SCHEMA, stream: false, options: { temperature: 0.1, num_ctx: 16384, // Groß genug für viele Claims + Perplexity-Ergebnisse }, }; const resp = await fetch(`${OLLAMA_HOST}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, }); if (!resp.ok) { const text = await resp.text().catch(() => ""); throw new Error(`Ollama Batch-Verdict Fehler ${resp.status}: ${text}`); } const data = (await resp.json()) as OllamaResponse; const raw = data.message?.content ?? ""; if (!raw.trim()) throw new Error("Leere Ollama-Antwort für Batch-Verdicts"); let parsed: unknown; try { parsed = JSON.parse(raw); } catch { throw new Error(`Kein gültiges JSON von Ollama: ${raw.slice(0, 300)}`); } const { verdicts } = parsed as BatchVerdictRaw; return verdicts ?? []; } // --------------------------------------------------------------------------- // Parallel-Limiter für Perplexity // --------------------------------------------------------------------------- async function runWithConcurrencyLimit( tasks: Array<() => Promise>, limit: number ): Promise { const results: T[] = new Array(tasks.length); let index = 0; async function worker() { while (index < tasks.length) { const current = index++; results[current] = await tasks[current](); } } const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker); await Promise.all(workers); return results; } // --------------------------------------------------------------------------- // Hauptfunktion // --------------------------------------------------------------------------- export async function verifyArticle( text: string, options?: { maxClaims?: number; mode?: "fast" | "deep"; model?: string; signal?: AbortSignal; onProgress?: (msg: string) => void; logger?: Logger; jobDir?: string; // Wenn gesetzt: persistente Zwischenergebnisse in diesem Verzeichnis noCache?: boolean; // Cache für wiederholte Claims deaktivieren (Standard: Cache aktiv) } ): Promise { const t0 = Date.now(); const model = options?.model ?? DEFAULT_MODEL; const maxClaims = Math.min(options?.maxClaims ?? DEFAULT_MAX_CLAIMS, 20); const mode = options?.mode ?? "fast"; const log = options?.logger ?? nullLogger; const jobDir = options?.jobDir; const useCache = !(options?.noCache ?? false); const progress = (msg: string) => { options?.onProgress?.(msg); log.info(msg); }; log.info("ollama-verify-article gestartet", { textLength: text.length, model, maxClaims, mode, jobDir }); // Schritt 1: Claim-Extraktion (oder aus Job-Cache laden) let claimSet: ClaimSet; if (jobDir) { const cached = loadJobFile(jobDir, "claims.json"); if (cached) { claimSet = cached; const checkable = claimSet.claims.filter((c) => c.checkability === "checkable").length; progress(`Claims aus Job geladen (${claimSet.total_claims} total, ${checkable} prüfbar) — Extraktion übersprungen.`); log.info("Claims aus Cache geladen", { total: claimSet.total_claims }); } else { updateJobMeta(jobDir, { status: "extracting" }); progress("Claims extrahieren (Ollama)..."); const { claimSet: extracted, tokensIn, tokensOut, latencyMs: extractLatency } = await callOllamaClaimExtract( text, model, maxClaims, options?.signal, log ); claimSet = extracted; saveJobFile(jobDir, "claims.json", claimSet); updateJobMeta(jobDir, { status: "verifying", steps: { extract: { completedAt: new Date().toISOString(), totalClaims: claimSet.total_claims, checkableClaims: claimSet.claims.filter((c) => c.checkability === "checkable").length, latencyMs: extractLatency, }, }, }); log.info("Claims extrahiert + gespeichert", { total: claimSet.total_claims, tokensIn, tokensOut, latencyMs: extractLatency }); } } else { progress("Claims extrahieren (Ollama)..."); const { claimSet: extracted, tokensIn, tokensOut, latencyMs: extractLatency } = await callOllamaClaimExtract( text, model, maxClaims, options?.signal, log ); claimSet = extracted; log.info("Claims extrahiert", { total: claimSet.total_claims, tokensIn, tokensOut, latencyMs: extractLatency }); } const checkableClaims = claimSet.claims.filter((c) => c.checkability === "checkable"); const uncheckedClaims = claimSet.claims.filter((c) => c.checkability !== "checkable"); progress( `${claimSet.total_claims} Claims — ${checkableClaims.length} prüfbar, ` + `${uncheckedClaims.length} nicht prüfbar.` ); if (checkableClaims.length === 0) { progress("⚠ Keine prüfbaren Claims gefunden — Verifikation nicht möglich."); } // Schritt 2: Perplexity parallel (mit Limit) — mit Job-Cache let doneCount = 0; const total = checkableClaims.length; // Prüfe wie viele Claims schon gecacht sind if (jobDir && total > 0) { const cachedCount = checkableClaims.filter((c) => jobFileExists(jobDir, `perplexity/${c.claim_id}.json`) ).length; if (cachedCount > 0) { progress(`${cachedCount}/${total} Perplexity-Ergebnisse aus Job-Cache geladen.`); } } const perplexityTasks = checkableClaims.map((claim) => async () => { const short = claim.text.length > 55 ? claim.text.slice(0, 52) + "..." : claim.text; // Job-Cache: gecachtes Ergebnis verwenden wenn vorhanden if (jobDir) { const cached = loadJobFile(jobDir, `perplexity/${claim.claim_id}.json`); if (cached) { doneCount++; progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ (cached) "${short}"`); return { claim, result: cached, error: null }; } } // Globaler Claim-Cache (claim-text-basiert, TTL 7 Tage) if (useCache) { const globalCached = getCached(claim.text); if (globalCached) { doneCount++; progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ (cache) "${short}"`); log.debug("Aus globalem Cache geladen", { claimId: claim.claim_id }); return { claim, result: globalCached, error: null }; } } // Perplexity-Suche try { const result = await searchPerplexity(claim.text, { mode, signal: options?.signal }); doneCount++; // Globaler Cache speichern if (useCache) setCached(claim.text, result); // Im Job speichern bevor wir weitermachen if (jobDir) { saveJobFile(jobDir, `perplexity/${claim.claim_id}.json`, result); log.debug("Perplexity-Ergebnis gecacht", { claimId: claim.claim_id }); } progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ "${short}"`); return { claim, result, error: null }; } catch (err: unknown) { doneCount++; const errMsg = err instanceof Error ? err.message : "Perplexity-Fehler"; progress(`[${doneCount}/${total}] ${claim.claim_id} ✗ "${short}" — ${errMsg}`); return { claim, result: null as PerplexityResult | null, error: errMsg, }; } }); if (total > 0) progress(`Recherche läuft (${total} Claims, max. ${MAX_PARALLEL_PERPLEXITY} parallel)...`); const perplexityOutcomes = await runWithConcurrencyLimit(perplexityTasks, MAX_PARALLEL_PERPLEXITY); const successful = perplexityOutcomes.filter((o) => o.result !== null) as Array<{ claim: (typeof checkableClaims)[number]; result: PerplexityResult; error: null; }>; const failed = perplexityOutcomes.filter((o) => o.error !== null); const totalPerplexityCost = successful.reduce((sum, o) => sum + o.result.estimatedCostUSD, 0); log.info("Perplexity abgeschlossen", { successful: successful.length, failed: failed.length, totalCostUSD: totalPerplexityCost.toFixed(4), }); if (failed.length > 0) { for (const f of failed) { log.warn("Perplexity-Fehler", { claimId: f.claim.claim_id, error: f.error }); } } // Schritt 3: Batch-Urteilssynthese via Ollama progress(`Urteilssynthese (Ollama, ${successful.length} Claims)...`); const verdicts = await synthesizeBatchVerdicts( successful.map((o) => ({ id: o.claim.claim_id, text: o.claim.text, perplexity: o.result })), model, options?.signal ); // Schritt 4: Report zusammenbauen const verdictMap = new Map(verdicts.map((v) => [v.claim_id, v])); const results: VerificationReport["results"] = [ // Verifizierte Claims ...successful.map((o) => { const verdict = verdictMap.get(o.claim.claim_id); const sources = o.result.sources.map((s) => ({ url: s.url, title: s.title ?? null, supports_claim: verdict?.supporting_urls.includes(s.url) ?? false, })); return { claim_id: o.claim.claim_id, claim_text: o.claim.text, status: (verdict?.status ?? "insufficient_evidence") as VerificationStatus, confidence: (verdict?.confidence ?? "low") as Confidence, summary: verdict?.summary ?? "Keine Urteilssynthese verfügbar.", sources, counter_evidence: verdict?.counter_evidence ?? null, notes: verdict?.notes ?? null, }; }), // Fehlgeschlagene Perplexity-Suchen ...failed.map((o) => ({ claim_id: o.claim.claim_id, claim_text: o.claim.text, status: "insufficient_evidence" as VerificationStatus, confidence: "low" as Confidence, summary: `Recherche fehlgeschlagen: ${o.error}`, sources: [], counter_evidence: null, notes: null, })), // Nicht prüfbare Claims ...uncheckedClaims.map((c) => ({ claim_id: c.claim_id, claim_text: c.text, status: "not_checkable" as VerificationStatus, confidence: "high" as Confidence, summary: `Nicht empirisch prüfbar (${c.claim_type}).`, sources: [], counter_evidence: null, notes: null, })), ]; // Statistiken const stats: Record = { total: results.length, supported: 0, contradicted: 0, mixed: 0, insufficient_evidence: 0, needs_human_review: 0, not_checkable: 0, }; for (const r of results) stats[r.status] = (stats[r.status] ?? 0) + 1; // Zusammenfassung const checkedCount = successful.length; const summaryParts = [ `${claimSet.total_claims} Claims extrahiert, ${checkedCount} recherchiert.`, stats.supported > 0 ? `${stats.supported} bestätigt` : "", stats.contradicted > 0 ? `${stats.contradicted} widerlegt` : "", stats.mixed > 0 ? `${stats.mixed} gemischt` : "", stats.needs_human_review > 0 ? `${stats.needs_human_review} → Menschliche Prüfung nötig` : "", stats.insufficient_evidence > 0 ? `${stats.insufficient_evidence} ohne ausreichende Belege` : "", ] .filter(Boolean) .join(". "); const totalLatencyMs = Date.now() - t0; log.info("ollama-verify-article abgeschlossen", { ...stats, totalCostUSD: totalPerplexityCost.toFixed(4), latencyMs: totalLatencyMs, }); const report: VerificationReport = { schema_version: "1.0.0", verified_at: new Date().toISOString(), source_text_summary: text.slice(0, 200) + (text.length > 200 ? "…" : ""), summary: summaryParts, results, stats, totalCostUSD: totalPerplexityCost, latencyMs: totalLatencyMs, }; // Job: Report + Meta speichern if (jobDir) { saveJobFile(jobDir, "report.json", report); updateJobMeta(jobDir, { status: "completed", steps: { verify: { completedAt: new Date().toISOString(), claimsVerified: successful.length, totalCostUSD: totalPerplexityCost, latencyMs: totalLatencyMs, }, }, }); log.info("Report in Job gespeichert", { jobDir }); } return report; } // --------------------------------------------------------------------------- // Formatierung // --------------------------------------------------------------------------- const STATUS_ICON: Record = { supported: "✓ BESTÄTIGT", contradicted: "✗ WIDERLEGT", mixed: "~ GEMISCHT", insufficient_evidence: "? BELEGE UNZUREICHEND", needs_human_review: "⚠ MENSCHLICHE PRÜFUNG NÖTIG", not_checkable: "— NICHT PRÜFBAR", }; function formatReport(report: VerificationReport): string { const lines: string[] = []; const s = report.stats; lines.push(`## Verifikationsbericht`); lines.push(report.summary); lines.push(""); // Prüfbare Ergebnisse gruppieren const groups: VerificationStatus[] = [ "supported", "contradicted", "mixed", "needs_human_review", "insufficient_evidence", "not_checkable", ]; for (const status of groups) { const items = report.results.filter((r) => r.status === status); if (items.length === 0) continue; lines.push(`**${STATUS_ICON[status]} (${items.length}):**`); for (const item of items) { lines.push(`\`${item.claim_id}\` "${item.claim_text}"`); if (item.status !== "not_checkable") { lines.push(` → ${item.summary}`); if (item.counter_evidence) { lines.push(` ✗ Gegenbeleg: ${item.counter_evidence}`); } if (item.notes) { lines.push(` ℹ ${item.notes}`); } if (item.sources.length > 0) { const supporting = item.sources.filter((s) => s.supports_claim); if (supporting.length > 0) { lines.push(` Quellen: ${supporting.map((s) => `[${s.title ?? s.url}](${s.url})`).join(", ")}`); } } } lines.push(""); } } const latSec = (report.latencyMs / 1000).toFixed(0); lines.push( `_[Perplexity: ~$${report.totalCostUSD.toFixed(4)} | Gesamt: ${latSec}s]_` ); return lines.join("\n"); } // --------------------------------------------------------------------------- // Pi-Extension: Default Export // --------------------------------------------------------------------------- const PARAMS = Type.Object({ text: Type.String({ description: "Der vollständige Artikel- oder Blogtext, der auf Fakten geprüft werden soll. " + "Nicht kürzen — der Originaltext wird für die Claim-Extraktion benötigt.", }), maxClaims: Type.Optional( Type.Number({ description: `Maximale Anzahl zu prüfender Claims. Standard: ${DEFAULT_MAX_CLAIMS}. Max: 20.`, }) ), mode: Type.Optional( Type.Union([Type.Literal("fast"), Type.Literal("deep")], { description: "fast (Standard): sonar, kostengünstig, für normale Artikel. " + "deep: sonar-pro, für investigative oder wissenschaftliche Inhalte.", }) ), model: Type.Optional( Type.String({ description: `Ollama-Modell. Standard: ${DEFAULT_MODEL}.`, }) ), }); export default function verifyArticleExtension(pi: ExtensionAPI) { pi.registerTool({ name: "verify_article", label: "Artikel-Verifikation", description: "Vollständige Fact-Check-Pipeline für einen Artikel oder Blogtext: " + "Claims extrahieren → Perplexity-Recherche (parallel) → Ollama-Urteil (batch) → Bericht. " + "Effizienter als verify_claim für mehrere Claims. " + "Typische Kosten: $0.05–0.15 für einen Artikel mit 10–15 Claims.", promptGuidelines: [ "Use verify_article when the user wants to fact-check an entire article, blog post, or longer text.", "Use verify_claim instead when the user wants to check a single specific claim.", "Pass the FULL article text — do not summarize it first.", "Use mode=deep for scientific, medical, legal, or politically sensitive content.", "Always show the full formatted report including the cost/latency line.", "Highlight contradicted claims and claims needing human review prominently.", "If needs_human_review claims exist, explain to the user that they require manual fact-checking.", "After the report, offer to show full sources for specific claims if the user wants details.", ], parameters: PARAMS, async execute(_toolCallId, params, signal) { try { const report = await verifyArticle(params.text, { maxClaims: params.maxClaims, mode: params.mode, model: params.model, signal, }); return { content: [{ type: "text", text: formatReport(report) }], details: { totalClaims: report.stats.total, supported: report.stats.supported, contradicted: report.stats.contradicted, needsHumanReview: report.stats.needs_human_review, totalCostUSD: report.totalCostUSD, latencyMs: report.latencyMs, }, }; } catch (err) { const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; return { content: [{ type: "text", text: `Artikel-Verifikation fehlgeschlagen: ${msg}` }] }; } }, }); } // --------------------------------------------------------------------------- // CLI-Modus // --------------------------------------------------------------------------- async function runCli() { const args = process.argv.slice(2); if (args.length === 0 || args[0] === "--help") { console.log(` Artikel-Verifikator — Vollständige Fact-Check-Pipeline Verwendung: npx tsx agenten/ollama-verify-article.ts [Optionen] "Artikeltext..." npx tsx agenten/ollama-verify-article.ts [Optionen] "$(cat artikel.txt)" Optionen: --mode fast|deep Perplexity-Modus (Standard: fast) --model Ollama-Modell (Standard: ${DEFAULT_MODEL}) --max-claims Max. Claims (Standard: ${DEFAULT_MAX_CLAIMS}) --job-id Job-Speicher aktivieren: Zwischenergebnisse nach ~/.pi/agent/jobs/_/ Bei Unterbrechung: einfach erneut aufrufen — gecachte Ergebnisse werden wiederverwendet --json Ausgabe als JSON --no-cache Globalen Claim-Cache deaktivieren (erzwingt neue Perplexity-Anfragen) --verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/ --help Diese Hilfe Beispiele: # Erstlauf mit Job-Speicher: npx tsx agenten/ollama-verify-article.ts --job-id umerziehung "$(cat umerziehung.md)" # Bei Absturz: einfach nochmal aufrufen — gecachte Claims + Perplexity-Ergebnisse werden wiederverwendet: npx tsx agenten/ollama-verify-article.ts --job-id umerziehung "$(cat umerziehung.md)" # Report aus Job an Writer übergeben: npx tsx agenten/writer.ts --from-job umerziehung --style blog `); process.exit(0); } let mode: "fast" | "deep" = "fast"; let model = DEFAULT_MODEL; let maxClaims = DEFAULT_MAX_CLAIMS; let jobId: string | undefined; let jsonOutput = false; let verbose = false; let noCache = false; const textParts: 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 === "--max-claims" && args[i + 1]) { maxClaims = parseInt(args[++i], 10); } else if (arg === "--job-id" && args[i + 1]) { jobId = args[++i]; } else if (arg === "--json") { jsonOutput = true; } else if (arg === "--verbose" || arg === "-v") { verbose = true; } else if (arg === "--no-cache") { noCache = true; } else if (!arg.startsWith("--")) { textParts.push(arg); } } const text = textParts.join(" ").trim(); if (!text) { console.error("Fehler: Kein Text übergeben."); process.exit(1); } if (!jsonOutput) { console.error(`\nModus: ${mode} | Modell: ${model} | Max. Claims: ${maxClaims}${jobId ? ` | Job: ${jobId}` : ""}\n`); } const log = createLogger({ verbose, jobId }); const onProgress = jsonOutput ? undefined : (msg: string) => process.stderr.write(` ${msg}\n`); // Job-Verzeichnis anlegen oder vorhandenes wiederverwenden let jobDir: string | undefined; if (jobId) { const { jobDir: dir, isNew } = getOrCreateJob(jobId, model); jobDir = dir; // Originaltext speichern (nur beim ersten Mal, nicht überschreiben) if (isNew) { saveJobFile(jobDir, "input.txt", text); } if (!jsonOutput) { process.stderr.write(` Job: ${jobDir} (${isNew ? "neu" : "fortgesetzt"})\n\n`); } } try { const report = await verifyArticle(text, { maxClaims, mode, model, onProgress, logger: log, jobDir, noCache }); if (jsonOutput) { console.log(JSON.stringify(report, null, 2)); } else { console.log(formatReport(report)); } } catch (err) { if (jobDir) { updateJobMeta(jobDir, { status: "failed" }); } console.error("Fehler:", err instanceof Error ? err.message : err); process.exit(1); } } const __filename = fileURLToPath(import.meta.url); if (process.argv[1] === __filename) { runCli(); }