2026-05-12 04:21:48 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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<string, number>;
|
|
|
|
|
|
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":
|
2026-05-12 04:52:12 +02:00
|
|
|
|
- Nur bei klar substanziellen Fehlern: falsche Person, Zahl >5% abweichend, falsch zugeordnetes Ereignis
|
2026-05-12 04:21:48 +02:00
|
|
|
|
- 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<VerdictItem[]> {
|
|
|
|
|
|
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<T>(
|
|
|
|
|
|
tasks: Array<() => Promise<T>>,
|
|
|
|
|
|
limit: number
|
|
|
|
|
|
): Promise<T[]> {
|
|
|
|
|
|
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<VerificationReport> {
|
|
|
|
|
|
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<ClaimSet>(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<PerplexityResult>(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<PerplexityResult>(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<string, number> = {
|
|
|
|
|
|
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<VerificationStatus, string> = {
|
|
|
|
|
|
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 <name> Ollama-Modell (Standard: ${DEFAULT_MODEL})
|
|
|
|
|
|
--max-claims <n> Max. Claims (Standard: ${DEFAULT_MAX_CLAIMS})
|
|
|
|
|
|
--job-id <slug> Job-Speicher aktivieren: Zwischenergebnisse nach ~/.pi/agent/jobs/<datum>_<slug>/
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|