Text_Agent/agenten/ollama-verify-article.ts

809 lines
26 KiB
TypeScript
Raw Normal View History

/**
* 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();
}