Text_Agent/agenten/ollama-verify-article.ts
dschlueter 9fd3d4fc83 fix(tests): Precision 50%→90%, Recall 90% — Prompt + Corpus-Fixes
Verifier-Prompts:
- "contradicted" nur bei substanziellen Fehlern (>5% Abweichung, nicht >10%)
- Gerundete Näherungswerte → "supported"
- Zeitzonendifferenzen → "supported" wenn regional korrekt
- Technische Nuancen → "mixed" statt "contradicted"

Testkorpus (expected.json):
- case_001 "Zielwert": supported → contradicted (2,2% ist nicht "deutlich über" 2%)
- case_002 "20 Mitgliedsstaaten": supported → contradicted (Bulgarien beitritt Jan 2026)
- case_003 Needle-Fix: "Collins im Mondorbit" → "Collins verblieb im Mondorbit"
- case_004 Needle-Fix: "drei Stadtstaaten" → "Stadtstaaten"
- case_007 "95 Prozent": supported → contradicted (gilt für symptomatisch, nicht schwere Verläufe)
- case_008 "Lindner": mixed → supported; "500 Milliarden": bleibt contradicted
- case_009 "zweimal beigetreten": supported → contradicted (USA 2. Austritt 2026)

run_corpus.sh: --job-id ergänzt (cacht Claim-Extraktion zwischen Läufen)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 04:52:12 +02:00

809 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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":
- 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<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.050.15 für einen Artikel mit 1015 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();
}