feat: Pi Text-Agent — initialer Commit (sauberes Repo)

Vollständiges Multi-Agenten-System für Fact-Checking, Artikelschreiben
und Argumentationsanalyse. Zwei Backends: llama.cpp (★ bevorzugt) und Ollama.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-05-12 04:21:48 +02:00
commit 5146b7fa30
62 changed files with 11279 additions and 0 deletions

View file

@ -0,0 +1,781 @@
/**
* llama-claim-extractor.ts
* Pi-Extension + CLI: Einzelbehauptungen aus Texten extrahieren via lokalem llama.cpp
*
* Als Pi-Extension: ~/.pi/agent/extensions/llama-claim-extractor.ts
* Nach Änderungen in Pi: /reload
*
* Als CLI:
* npx tsx agenten/llama-claim-extractor.ts "Textinhalt..."
* npx tsx agenten/llama-claim-extractor.ts --file artikel.txt
* npx tsx agenten/llama-claim-extractor.ts --only-checkable --file artikel.txt
* npx tsx agenten/llama-claim-extractor.ts --json "..." (nur JSON-Ausgabe)
*
* llama.cpp-Server starten:
* llama-server --model <modell.gguf> --host 0.0.0.0 --port 8000 -c 8192
*
* Hinweis: llama.cpp verwendet das OpenAI-kompatible API-Format (/v1/chat/completions).
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { readFile } from "node:fs/promises";
import { createLogger, nullLogger, type Logger } from "../lib/logger.js";
// ---------------------------------------------------------------------------
// Typen
// ---------------------------------------------------------------------------
export type ClaimType = "fact" | "causal" | "statistical" | "quote" | "prediction" | "opinion";
export type Checkability = "checkable" | "partly_checkable" | "not_checkable";
export type Claim = {
claim_id: string;
text: string;
text_translated?: string; // Übersetzung für Lesbarkeit — NIE für Faktencheck verwenden
claim_type: ClaimType;
checkability: Checkability;
needs_citation: boolean;
entities: string[];
time_scope: string | null;
source_sentence: string;
};
export type ClaimSet = {
schema_version: "1.0.0";
text_language: string;
extraction_notes: string;
total_claims: number;
claims: Claim[];
};
// llama.cpp OpenAI-kompatibles API-Format
// reasoning_content: Qwen3/DeepSeek-R1-Reasoning-Modelle schreiben Denkkette hierhin
type LlamaResponse = {
choices: Array<{
message?: { content?: string; reasoning_content?: string };
finish_reason?: string;
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf";
const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000";
const DEFAULT_MAX_CLAIMS = 40;
const TEMPERATURE = 0.1;
// Reasoning-Modelle brauchen mehr Tokens: Denkkette + JSON-Output
// Mit Übersetzung noch mehr: base 16384, mit Translation 32768
const MAX_TOKENS_BASE = 16384;
const MAX_TOKENS_WITH_TRANSLATION = 32768;
const CHUNK_THRESHOLD = 4000;
const CHUNK_SIZE = 3000;
// ---------------------------------------------------------------------------
// JSON-Schema für strukturierten Output
// ---------------------------------------------------------------------------
export const CLAIM_JSON_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
schema_version: { type: "string" },
text_language: { type: "string" },
extraction_notes: { type: "string" },
total_claims: { type: "integer" },
claims: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
claim_id: { type: "string" },
text: { type: "string" },
text_translated: { type: "string" },
claim_type: {
type: "string",
enum: ["fact", "causal", "statistical", "quote", "prediction", "opinion"],
},
checkability: {
type: "string",
enum: ["checkable", "partly_checkable", "not_checkable"],
},
needs_citation: { type: "boolean" },
entities: { type: "array", items: { type: "string" } },
time_scope: { type: ["string", "null"] },
source_sentence: { type: "string" },
},
required: [
"claim_id",
"text",
"claim_type",
"checkability",
"needs_citation",
"entities",
"time_scope",
"source_sentence",
],
},
},
},
required: ["schema_version", "text_language", "extraction_notes", "total_claims", "claims"],
};
// ---------------------------------------------------------------------------
// System-Prompt
// ---------------------------------------------------------------------------
function buildSystemPrompt(maxClaims: number, translateTo?: string): string {
return `Du bist ein Experte für Faktenextraktion und Fact-Checking-Vorbereitung.
Deine Aufgabe: Analysiere den Text und extrahiere alle Behauptungen als diskrete, einzeln prüfbare Einheiten.
Extrahiere maximal ${maxClaims} Behauptungen. Bei sehr langen Texten priorisiere die wichtigsten und prüfbarsten.
REGELN für die Extraktion:
- Formuliere jede Behauptung als eigenständigen, vollständigen Satz (nicht als Fragment)
- Behalte den Sinn der Originalformulierung bei, mache Behauptungen aber selbstständig lesbar
- claim_id: fortlaufend "c001", "c002", "c003", ...
CLAIM TYPES:
- fact: Konkrete Tatsachenbehauptung ("X ist Y", "X hat Z getan")
- causal: Kausalbehauptung ("X hat zu Y geführt", "wegen X passiert Y")
- statistical: Zahlen, Prozentwerte, Statistiken, Rankings
- quote: Wörtliches oder indirektes Zitat einer Person
- prediction: Prognose, Vorhersage, Erwartung über Zukunftsereignisse
- opinion: Wertung, Meinung, normative Aussage (gut/schlecht/sollte)
CHECKABILITY:
- checkable: Empirisch überprüfbar durch Primärquellen, Datenbanken, offizielle Stellen
- partly_checkable: Nur teilweise prüfbar (z.B. enthält sowohl Fakt als auch Wertung)
- not_checkable: Reine Meinung, reine Prognose, Werturteil ohne Tatsachenkern
NEEDS_CITATION: true wenn Zahlen, spezifische Fakten, Zitate oder Studienergebnisse vorhanden
ENTITIES: Alle benannten Entitäten: Personen, Organisationen, Länder, Institutionen, Produkte, konkrete Daten
TIME_SCOPE: Zeitrahmen wenn angegeben (z.B. "2024", "Q1 2025", "seit 1990"), sonst null
SOURCE_SENTENCE: Der originale Satz aus dem Quelltext (wörtlich, max. 200 Zeichen)
DUPLIKATE: Extrahiere jeden Sachverhalt nur einmal. Wenn derselbe Fakt im Text mehrfach vorkommt (z.B. als Einleitung und später als Detail), erstelle nur einen Claim dafür.
SPRACHE DES OUTPUTS (ZWINGEND):
- "text" und "source_sentence" IMMER in der Originalsprache des Artikels belassen niemals übersetzen
- Wörtliche Zitate (claim_type="quote") wortwörtlich aus dem Text übernehmen
- Übersetzungen verfälschen den späteren Faktencheck und sind in diesen Feldern verboten
` + (translateTo
? "\nÜBERSETZUNG (zusätzlich):\n"
+ "- Füge für jeden Claim das Feld text_translated hinzu\n"
+ "- text_translated enthält die Übersetzung von text ins " + (translateTo === "de" ? "Deutsche" : translateTo === "en" ? "Englische" : translateTo) + "\n"
+ "- Nur zur Lesbarkeit — nicht für den Faktencheck\n"
: "") + `
ANTWORTFORMAT: Antworte NUR mit einem JSON-Objekt kein Freitext davor oder danach. Das JSON muss folgende Felder enthalten:
- schema_version: "1.0.0"
- text_language: Sprache des Textes als ISO 639-1 Code (z.B. "de", "en", "fr")
- extraction_notes: Kurze Notiz zur Extraktion
- total_claims: Anzahl der Claims
- claims: Array von Claim-Objekten mit den Feldern:
- claim_id: "c001", "c002", etc.
- text: Die Behauptung als vollständiger Satz (ORIGINALSPRACHE!)
` + (translateTo ? "- text_translated: Übersetzung ins " + (translateTo === "de" ? "Deutsche" : translateTo === "en" ? "Englische" : translateTo) + "\n " : "") + `- claim_type: einer von [fact, causal, statistical, quote, prediction, opinion]
- checkability: einer von [checkable, partly_checkable, not_checkable]
- needs_citation: true/false
- entities: Array von benannten Entitäten
- time_scope: Zeitrahmen oder null
- source_sentence: Originalsatz aus dem Text (ORIGINALSPRACHE!, max. 200 Zeichen)`;
}
// ---------------------------------------------------------------------------
// Text-Chunking für lange Texte
// ---------------------------------------------------------------------------
function splitIntoChunks(text: string): string[] {
const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 0);
const chunks: string[] = [];
let current = "";
for (const para of paragraphs) {
if (current.length + para.length + 2 > CHUNK_SIZE && current.length > 0) {
chunks.push(current.trim());
current = para;
} else {
current = current ? current + "\n\n" + para : para;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
function deduplicateClaims(claims: Claim[]): Claim[] {
const seen = new Set<string>();
return claims.filter((c) => {
const key = c.text.toLowerCase().replace(/\s+/g, " ").trim();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// ---------------------------------------------------------------------------
// llama.cpp-Aufruf
// ---------------------------------------------------------------------------
export async function callLlamaClaimExtract(
text: string,
model: string,
maxClaims: number,
signal?: AbortSignal,
logger?: Logger,
translateTo?: string
): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> {
const log = logger ?? nullLogger;
if (text.length > CHUNK_THRESHOLD) {
log.info("Text zu lang für Single-Pass — Chunking aktiv", { textLength: text.length, threshold: CHUNK_THRESHOLD });
return callLlamaClaimExtractChunked(text, model, maxClaims, signal, log, translateTo);
}
log.debug("Single-Pass Extraktion", { textLength: text.length, model, maxClaims });
return callLlamaClaimExtractSingle(text, model, maxClaims, signal, log, translateTo);
}
async function callLlamaClaimExtractChunked(
text: string,
model: string,
maxClaims: number,
signal?: AbortSignal,
logger?: Logger,
translateTo?: string
): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> {
const log = logger ?? nullLogger;
const t0 = Date.now();
const chunks = splitIntoChunks(text);
const claimsPerChunk = Math.ceil(maxClaims / chunks.length);
log.info(`Text in ${chunks.length} Chunks aufgeteilt`, {
chunks: chunks.length,
claimsPerChunk,
chunkLengths: chunks.map((c) => c.length),
});
let totalIn = 0;
let totalOut = 0;
const allClaims: Claim[] = [];
let language = "de";
const notes: string[] = [];
for (let i = 0; i < chunks.length; i++) {
log.info(`Chunk ${i + 1}/${chunks.length} extrahieren...`, { chunkLength: chunks[i].length, claimsPerChunk });
const result = await callLlamaClaimExtractSingle(chunks[i], model, claimsPerChunk, signal, log, translateTo);
log.info(`Chunk ${i + 1}/${chunks.length} fertig`, {
claims: result.claimSet.claims.length,
tokensIn: result.tokensIn,
tokensOut: result.tokensOut,
latencyMs: result.latencyMs,
});
allClaims.push(...result.claimSet.claims);
totalIn += result.tokensIn;
totalOut += result.tokensOut;
language = result.claimSet.text_language;
if (result.claimSet.extraction_notes) notes.push(result.claimSet.extraction_notes);
}
const beforeDedup = allClaims.length;
const unique = deduplicateClaims(allClaims).slice(0, maxClaims);
const renumbered: Claim[] = unique.map((c, i) => ({
...c,
claim_id: `c${String(i + 1).padStart(3, "0")}`,
}));
log.info("Chunking abgeschlossen", {
totalBeforeDedup: beforeDedup,
afterDedup: renumbered.length,
totalTokensIn: totalIn,
totalTokensOut: totalOut,
totalLatencyMs: Date.now() - t0,
});
return {
claimSet: {
schema_version: "1.0.0",
text_language: language,
extraction_notes: `Text in ${chunks.length} Abschnitte aufgeteilt. ${notes.filter(Boolean).join(" ")}`,
total_claims: renumbered.length,
claims: renumbered,
},
tokensIn: totalIn,
tokensOut: totalOut,
latencyMs: Date.now() - t0,
};
}
async function callLlamaClaimExtractSingle(
text: string,
model: string,
maxClaims: number,
signal?: AbortSignal,
logger?: Logger,
translateTo?: string
): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> {
const log = logger ?? nullLogger;
const t0 = Date.now();
const maxTokens = translateTo ? MAX_TOKENS_WITH_TRANSLATION : MAX_TOKENS_BASE;
const body = {
model,
messages: [
{
role: "system",
content: buildSystemPrompt(maxClaims, translateTo),
},
{
role: "user",
// /no_think deaktiviert den Thinking-Modus bei Qwen3/Qwopus-Reasoning-Modellen
content: `/no_think\nExtrahiere alle Behauptungen aus folgendem Text:\n\n---\n${text}\n---`,
},
],
stream: false,
temperature: TEMPERATURE,
max_tokens: maxTokens,
};
log.debug("llama.cpp-Aufruf gestartet", { model, textLength: text.length, max_tokens: maxTokens, translateTo });
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 15_000;
let resp: Response | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
break;
} catch (err) {
const isLast = attempt === MAX_RETRIES;
log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, {
error: err instanceof Error ? err.message : String(err),
retryInMs: isLast ? 0 : RETRY_DELAY_MS,
});
if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`);
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
if (!resp!.ok) {
const errorText = await resp!.text().catch(() => "");
log.error("llama.cpp API Fehler", { status: resp!.status, body: errorText.slice(0, 200) });
throw new Error(`llama.cpp API Fehler ${resp!.status}: ${errorText}`);
}
const data = (await resp!.json()) as LlamaResponse;
const choice = data.choices?.[0];
let raw = choice?.message?.content ?? "";
// Reasoning-Modelle (Qwen3, DeepSeek-R1) schreiben Denkkette in reasoning_content.
// Wenn content leer ist aber reasoning_content JSON enthält: als Fallback verwenden.
if (!raw.trim() && choice?.message?.reasoning_content) {
const rc = choice.message.reasoning_content;
// Letztes vollständiges JSON-Objekt mit "claims"-Array suchen (greedy, von hinten)
const allMatches = [...rc.matchAll(/\{[^{}]*"claims"\s*:\s*\[[\s\S]*?\]\s*[^{}]*\}/g)];
const lastMatch = allMatches.length > 0
? allMatches[allMatches.length - 1][0]
: rc.match(/\{[\s\S]*"claims"[\s\S]*\}/)?.[0];
if (lastMatch) {
raw = lastMatch;
log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", {
finishReason: choice.finish_reason,
rawLength: raw.length,
});
}
}
// llama.cpp wrappt JSON manchmal in Markdown-Codeblöcke (```json ... ```)
const cleanedRaw = raw
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
log.debug("llama.cpp-Antwort empfangen", {
promptTokens: data.usage?.prompt_tokens,
outputTokens: data.usage?.completion_tokens,
finishReason: choice?.finish_reason,
rawLength: raw.length,
cleanedLength: cleanedRaw.length,
});
if (!cleanedRaw) {
log.error("Leere llama.cpp-Antwort", {
promptTokens: data.usage?.prompt_tokens,
finishReason: choice?.finish_reason,
hasReasoningContent: !!choice?.message?.reasoning_content,
});
throw new Error("Leere Antwort von llama.cpp erhalten");
}
let parsed: unknown;
try {
parsed = JSON.parse(cleanedRaw);
} catch {
log.error("JSON-Parse-Fehler", { cleanedRawPreview: cleanedRaw.slice(0, 200) });
throw new Error(`llama.cpp-Ausgabe ist kein gültiges JSON: ${cleanedRaw.slice(0, 200)}`);
}
const p = parsed as Record<string, unknown>;
if (!Array.isArray(p.claims)) {
log.error("Ungültige Struktur: claims fehlt", { keys: Object.keys(p) });
throw new Error(`Ungültige Struktur: 'claims' fehlt oder ist kein Array`);
}
if ((p.claims as unknown[]).length === 0) {
const usedCtx = data.usage?.prompt_tokens ?? 0;
log.warn("0 Claims extrahiert", { promptTokens: usedCtx, max_tokens: maxTokens, textLength: text.length });
throw new Error(
`llama.cpp hat 0 Claims extrahiert (prompt_tokens=${usedCtx}). ` +
`Text zu lang für Kontext-Fenster oder Modell-Fehler.`
);
}
const claimSet: ClaimSet = {
schema_version: "1.0.0",
text_language: typeof p.text_language === "string" ? p.text_language : "unknown",
extraction_notes: typeof p.extraction_notes === "string" ? p.extraction_notes : "",
total_claims: typeof p.total_claims === "number" ? p.total_claims : (p.claims as unknown[]).length,
claims: p.claims as Claim[],
};
return {
claimSet,
tokensIn: data.usage?.prompt_tokens ?? 0,
tokensOut: data.usage?.completion_tokens ?? 0,
latencyMs: Date.now() - t0,
};
}
// ---------------------------------------------------------------------------
// Formatierung (Pi-Ausgabe + CLI-Ausgabe)
// ---------------------------------------------------------------------------
const TYPE_LABEL: Record<ClaimType, string> = {
fact: "FAKT",
causal: "KAUSAL",
statistical: "STATISTIK",
quote: "ZITAT",
prediction: "PROGNOSE",
opinion: "MEINUNG",
};
const CHECK_ICON: Record<Checkability, string> = {
checkable: "✓",
partly_checkable: "~",
not_checkable: "✗",
};
function formatClaimSet(
claimSet: ClaimSet,
onlyCheckable: boolean,
model: string,
tokensIn: number,
tokensOut: number,
latencyMs: number
): string {
const filtered = onlyCheckable
? claimSet.claims.filter((c) => c.checkability === "checkable")
: claimSet.claims;
const checkable = filtered.filter((c) => c.checkability === "checkable");
const partlyCheckable = filtered.filter((c) => c.checkability === "partly_checkable");
const notCheckable = filtered.filter((c) => c.checkability === "not_checkable");
const lines: string[] = [];
lines.push(
`## Claim-Extraktion: ${claimSet.total_claims} Behauptung${claimSet.total_claims !== 1 ? "en" : ""} gefunden` +
(onlyCheckable && filtered.length < claimSet.total_claims
? ` (${filtered.length} prüfbar angezeigt)`
: "")
);
lines.push(`Sprache: ${claimSet.text_language}`);
if (claimSet.extraction_notes) {
lines.push(`Hinweis: ${claimSet.extraction_notes}`);
}
lines.push("");
function renderClaims(claims: Claim[], sectionTitle: string) {
if (claims.length === 0) return;
lines.push(`**${sectionTitle} (${claims.length}):**`);
for (const c of claims) {
const icon = CHECK_ICON[c.checkability];
const type = TYPE_LABEL[c.claim_type];
lines.push(`\`${c.claim_id}\` ${icon} [${type}] ${c.text}`);
if (c.text_translated) {
lines.push(` → _${c.text_translated}_`);
}
const meta: string[] = [];
if (c.entities.length > 0) meta.push(`Entitäten: ${c.entities.join(", ")}`);
if (c.time_scope) meta.push(`Zeit: ${c.time_scope}`);
if (c.needs_citation) meta.push(`Zitat nötig: ja`);
if (meta.length > 0) {
lines.push(` ${meta.join(" | ")}`);
}
lines.push("");
}
}
renderClaims(checkable, "✓ Prüfbar");
if (!onlyCheckable) {
renderClaims(partlyCheckable, "~ Teilweise prüfbar");
renderClaims(notCheckable, "✗ Nicht prüfbar");
}
const latSec = (latencyMs / 1000).toFixed(1);
const tokenInfo = tokensIn || tokensOut ? ` · ${tokensIn}+${tokensOut} Tokens` : "";
lines.push(`_[llama.cpp: ${model}${tokenInfo} · ${latSec}s]_`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pi-Extension-Parameters (TypeBox)
// ---------------------------------------------------------------------------
const PARAMS = Type.Object({
text: Type.String({
description:
"Der zu analysierende Text. Kann ein Artikel, Blogeintrag, Nachrichtentext oder beliebiger Fließtext sein.",
}),
onlyCheckable: Type.Optional(
Type.Boolean({
description: "Wenn true: nur empirisch prüfbare Claims ausgeben (checkable). Standard: false.",
})
),
maxClaims: Type.Optional(
Type.Number({
description: `Maximale Anzahl Claims pro Aufruf. Standard: ${DEFAULT_MAX_CLAIMS}.`,
})
),
model: Type.Optional(
Type.String({
description: `llama.cpp-Modell für die Extraktion. Standard: ${DEFAULT_MODEL}.`,
})
),
translateTo: Type.Optional(
Type.String({
description:
"Zielsprache für optionale Übersetzung der Claims (z.B. \"de\", \"en\"). " +
"Das Feld `text` bleibt immer in der Originalsprache des Artikels. " +
"Wenn gesetzt: jeder Claim erhält zusätzlich `text_translated`. Standard: keine Übersetzung.",
})
),
});
// ---------------------------------------------------------------------------
// Pi-Extension: Default Export
// ---------------------------------------------------------------------------
export default function llamaClaimExtractorExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "extract_claims_llama",
label: "Claim-Extraktion (llama.cpp)",
description:
"Zerlegt einen Text in einzelne, diskrete Behauptungen (Claims) als Vorbereitung für Fact-Checking. " +
"Nutze dieses Tool wenn: ein Artikel auf Fakten geprüft werden soll, Behauptungen aus einem Text " +
"identifiziert und klassifiziert werden sollen, oder ein Verifikations-Workflow gestartet werden soll. " +
"Läuft lokal via llama.cpp — keine API-Kosten.",
promptGuidelines: [
"Use extract_claims_llama when the user wants to fact-check an article, blog post, or any text.",
"Use extract_claims_llama before calling verify or research_web on specific claims.",
"Pass the full text as the 'text' parameter — do not summarize or shorten it first.",
"If the user only wants checkable claims, set onlyCheckable=true.",
"After extraction, ask the user which claims they want to verify, or offer to run the verifier on all checkable claims.",
"The claim_ids (c001, c002, ...) can be referenced in follow-up tool calls to the verifier.",
"Always show the full formatted output to the user, including the [llama.cpp: ...] cost line.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
const model = params.model ?? DEFAULT_MODEL;
const maxClaims = Math.min(params.maxClaims ?? DEFAULT_MAX_CLAIMS, 60);
const onlyCheckable = params.onlyCheckable ?? false;
const translateTo = params.translateTo;
try {
const { claimSet, tokensIn, tokensOut, latencyMs } = await callLlamaClaimExtract(
params.text,
model,
maxClaims,
signal,
undefined,
translateTo
);
const text = formatClaimSet(claimSet, onlyCheckable, model, tokensIn, tokensOut, latencyMs);
return {
content: [{ type: "text", text }],
details: {
model,
totalClaims: claimSet.total_claims,
checkableClaims: claimSet.claims.filter((c) => c.checkability === "checkable").length,
textLanguage: claimSet.text_language,
tokensIn: tokensIn || null,
tokensOut: tokensOut || null,
latencyMs,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
return {
content: [{ type: "text", text: `Fehler bei Claim-Extraktion: ${msg}` }],
};
}
},
});
}
// ---------------------------------------------------------------------------
// CLI-Modus
// ---------------------------------------------------------------------------
function parseCliArgs(args: string[]): {
text: string;
file: string | null;
model: string;
maxClaims: number;
onlyCheckable: boolean;
jsonOutput: boolean;
verbose: boolean;
translateTo: string | undefined;
} {
let model = DEFAULT_MODEL;
let maxClaims = DEFAULT_MAX_CLAIMS;
let onlyCheckable = false;
let jsonOutput = false;
let verbose = false;
let file: string | null = null;
let translateTo: string | undefined;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--model" && args[i + 1]) {
model = args[++i];
} else if (arg === "--max-claims" && args[i + 1]) {
maxClaims = parseInt(args[++i], 10);
} else if (arg === "--only-checkable") {
onlyCheckable = true;
} else if (arg === "--json") {
jsonOutput = true;
} else if (arg === "--verbose" || arg === "-v") {
verbose = true;
} else if ((arg === "--file" || arg === "-f") && args[i + 1]) {
file = args[++i];
} else if (arg === "--translate-to" && args[i + 1]) {
translateTo = args[++i];
} else if (!arg.startsWith("--")) {
textParts.push(arg);
}
}
const text = textParts.join(" ").trim();
return { text, file, model, maxClaims, onlyCheckable, jsonOutput, verbose, translateTo };
}
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(`
Claim-Extraktor Behauptungen aus Text extrahieren (llama.cpp-Version)
Verwendung:
npx tsx agenten/llama-claim-extractor.ts [Optionen] "Text..."
npx tsx agenten/llama-claim-extractor.ts --file <pfad> [Optionen]
Optionen:
--file, -f <pfad> Text aus Datei lesen (statt als Argument übergeben)
--model <name> llama.cpp-Modell (Standard: ${DEFAULT_MODEL})
--max-claims <n> Maximale Claims (Standard: ${DEFAULT_MAX_CLAIMS})
--only-checkable Nur prüfbare Claims anzeigen
--translate-to <lang> Übersetzung der Claims in Zielsprache (z.B. "de", "en")
text bleibt in Originalsprache text_translated enthält Übersetzung
--json Ausgabe als reines JSON (ClaimSet)
--verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/
--help Diese Hilfe
Umgebungsvariablen:
LLAMA_HOST llama.cpp-Server-URL (Standard: http://localhost:8000)
Beispiele:
npx tsx agenten/llama-claim-extractor.ts "Die Erde hat 8 Milliarden Einwohner."
npx tsx agenten/llama-claim-extractor.ts --file Totally_unacceptable_article.txt
npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --only-checkable
npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --json > claims.json
npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --verbose
`);
process.exit(0);
}
const { text: argText, file, model, maxClaims, onlyCheckable, jsonOutput, verbose, translateTo } = parseCliArgs(args);
let text: string;
if (file) {
try {
text = await readFile(file, "utf-8");
} catch (err) {
console.error(`Fehler: Datei '${file}' konnte nicht gelesen werden: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
} else {
text = argText;
}
if (!text.trim()) {
console.error("Fehler: Kein Text übergeben. Nutze --file <pfad> oder übergib den Text direkt. --help für Details.");
process.exit(1);
}
if (!jsonOutput) {
const source = file ? `Datei: ${file}` : "Direkteingabe";
const transInfo = translateTo ? ` | Übersetzung: ${translateTo}` : "";
console.error(`\nllama.cpp-Modell: ${model} | Max. Claims: ${maxClaims} | Nur prüfbar: ${onlyCheckable} | ${source}${transInfo}\n`);
}
const log = createLogger({ verbose });
try {
const { claimSet, tokensIn, tokensOut, latencyMs } = await callLlamaClaimExtract(
text,
model,
maxClaims,
undefined,
log,
translateTo
);
if (jsonOutput) {
console.log(JSON.stringify(claimSet, null, 2));
} else {
console.log(formatClaimSet(claimSet, onlyCheckable, model, tokensIn, tokensOut, latencyMs));
}
} catch (err) {
console.error("Fehler:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
// Einstiegspunkt für CLI — wird ignoriert wenn als Pi-Extension geladen
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] === __filename) {
runCli();
}

View file

@ -0,0 +1,522 @@
/**
* llama-logic-editor.ts
* Pi-Extension + CLI: Argumentationsanalyse via llama.cpp (Qwopus3.6)
*
* Analysiert einen Text auf:
* - Hauptthese und Unterthesen
* - Explizite Prämissen und Belege
* - Schlussfolgerungen
* - Implizite Annahmen
* - Logische Fehlschlüsse (Ad Hominem, Strohmann, etc.)
* - Verbesserungsvorschläge
*
* Kein Ollama-format-Parameter Schema steht als JSON-Literal im System-Prompt.
* /no_think deaktiviert den Thinking-Modus bei Qwen3/Qwopus-Reasoning-Modellen.
*
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink)
* Als CLI:
* npx tsx agenten/llama-logic-editor.ts "Artikeltext..."
* npx tsx agenten/llama-logic-editor.ts --only-fallacies "$(cat kommentar.txt)"
* npx tsx agenten/llama-logic-editor.ts --json "$(cat essay.txt)"
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { createLogger, nullLogger, type Logger } from "../lib/logger.js";
// ---------------------------------------------------------------------------
// Typen
// ---------------------------------------------------------------------------
type FallacyType =
| "ad_hominem" | "straw_man" | "false_dichotomy" | "slippery_slope"
| "circular_reasoning" | "appeal_to_authority" | "hasty_generalization"
| "false_causation" | "appeal_to_emotion" | "overgeneralization"
| "cherry_picking" | "other";
type Severity = "minor" | "moderate" | "critical";
type EvidenceStrength = "strong" | "moderate" | "weak";
type OverallQuality = "strong" | "adequate" | "weak" | "flawed";
type ArgumentMap = {
schema_version: "1.0.0";
thesis: string;
sub_theses: string[];
premises: string[];
evidence: Array<{ claim: string; supports_thesis: boolean; strength: EvidenceStrength }>;
conclusions: string[];
implicit_assumptions: string[];
fallacies: Array<{
type: FallacyType;
description: string;
location: string;
severity: Severity;
}>;
revision_suggestions: string[];
overall_quality: OverallQuality;
quality_notes: string;
};
// llama.cpp OpenAI-kompatibles API-Format
type LlamaResponse = {
choices: Array<{
message?: { content?: string; reasoning_content?: string };
finish_reason?: string;
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
};
};
export type AnalysisResult = {
map: ArgumentMap;
provider: "llama";
model: string;
costUSD: 0;
latencyMs: number;
};
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf";
const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000";
const MAX_TOKENS = 16384;
const TEMPERATURE = 0.1;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 15_000;
// ---------------------------------------------------------------------------
// System-Prompt mit eingebettetem JSON-Schema
// ---------------------------------------------------------------------------
const ANALYSIS_SYSTEM_PROMPT = `Du bist ein Experte für kritisches Denken, Rhetorik und formale Logik.
Antworte ausschließlich auf Deutsch.
Analysiere den folgenden Text auf seine Argumentationsstruktur.
Extrahiere:
1. thesis: Die zentrale Hauptbehauptung als vollständiger Satz
2. sub_theses: Untergeordnete Thesen die die Hauptthese stützen
3. premises: Ausdrücklich genannte Voraussetzungen und Grundannahmen
4. evidence: Verwendete Belege (Fakten, Statistiken, Zitate, Studien) beachte ob sie die These wirklich stützen
5. conclusions: Explizite Schlussfolgerungen die aus den Prämissen gezogen werden
6. implicit_assumptions: Nicht ausgesprochene Annahmen die das Argument voraussetzt
Fehlschluss-Typen (für das "type"-Feld):
- ad_hominem: Person statt Argument angegriffen
- straw_man: Gegnerposition verzerrt dargestellt
- false_dichotomy: Falsche Zweiteilung (nur A oder B, obwohl mehr möglich)
- slippery_slope: Kettenreaktion ohne Beleg
- circular_reasoning: These wird durch sich selbst begründet
- appeal_to_authority: Autorität als einziger Beleg
- hasty_generalization: Einzelfall Allgemeinregel
- false_causation: Korrelation als Kausalität dargestellt
- appeal_to_emotion: Emotionen statt Argumente
- overgeneralization: Zu weit gefasste Verallgemeinerung
- cherry_picking: Nur passende Fakten ausgewählt
- other: Sonstiger Fehlschluss
overall_quality-Werte: "strong" | "adequate" | "weak" | "flawed"
severity-Werte: "minor" | "moderate" | "critical"
strength-Werte: "strong" | "moderate" | "weak"
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt gemäß folgendem Schema:
{
"thesis": "string",
"sub_theses": ["string"],
"premises": ["string"],
"evidence": [{"claim": "string", "supports_thesis": true, "strength": "strong|moderate|weak"}],
"conclusions": ["string"],
"implicit_assumptions": ["string"],
"fallacies": [{"type": "ad_hominem|...", "description": "string", "location": "wörtliches Zitat max. 120 Zeichen", "severity": "minor|moderate|critical"}],
"revision_suggestions": ["string"],
"overall_quality": "strong|adequate|weak|flawed",
"quality_notes": "string"
}
Kein Freitext vor oder nach dem JSON-Objekt.`;
// ---------------------------------------------------------------------------
// Prompt-Generierung
// ---------------------------------------------------------------------------
function buildUserPrompt(text: string): string {
return `/no_think\nAnalysiere die Argumentationsstruktur:\n\n---\n${text}\n---`;
}
// ---------------------------------------------------------------------------
// llama.cpp-Aufruf
// ---------------------------------------------------------------------------
async function analyzeWithLlama(
text: string,
model: string,
signal?: AbortSignal,
logger?: Logger
): Promise<{ map: ArgumentMap; tokensIn: number; tokensOut: number; latencyMs: number }> {
const log = logger ?? nullLogger;
const t0 = Date.now();
const body = {
model,
messages: [
{ role: "system", content: ANALYSIS_SYSTEM_PROMPT },
{ role: "user", content: buildUserPrompt(text) },
],
stream: false,
temperature: TEMPERATURE,
max_tokens: MAX_TOKENS,
};
log.debug("llama.cpp-LogicEditor gestartet", { model, textLength: text.length });
let resp: Response | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
break;
} catch (err) {
const isLast = attempt === MAX_RETRIES;
log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, {
error: err instanceof Error ? err.message : String(err),
retryInMs: isLast ? 0 : RETRY_DELAY_MS,
});
if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`);
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
if (!resp!.ok) {
const errorText = await resp!.text().catch(() => "");
throw new Error(`llama.cpp API Fehler ${resp!.status}: ${errorText}`);
}
const data = (await resp!.json()) as LlamaResponse;
const choice = data.choices?.[0];
let raw = choice?.message?.content ?? "";
// Reasoning-Fallback: Wenn content leer, JSON aus reasoning_content extrahieren
if (!raw.trim() && choice?.message?.reasoning_content) {
const rc = choice.message.reasoning_content;
const lastBlock = rc.match(/\{[\s\S]*"thesis"[\s\S]*\}/)?.[0];
if (lastBlock) {
raw = lastBlock;
log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", {
finishReason: choice.finish_reason,
rawLength: raw.length,
});
}
}
// Markdown-Codeblöcke entfernen
const cleanedRaw = raw
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
log.debug("llama.cpp-LogicEditor Antwort", {
promptTokens: data.usage?.prompt_tokens,
outputTokens: data.usage?.completion_tokens,
finishReason: choice?.finish_reason,
rawLength: cleanedRaw.length,
});
if (!cleanedRaw) throw new Error("Leere Antwort von llama.cpp-LogicEditor");
let parsed: unknown;
try {
parsed = JSON.parse(cleanedRaw);
} catch {
throw new Error(`llama.cpp-LogicEditor-Ausgabe ist kein gültiges JSON: ${cleanedRaw.slice(0, 200)}`);
}
const p = parsed as Record<string, unknown>;
if (typeof p.thesis !== "string") {
throw new Error(`Ungültige Struktur: 'thesis' fehlt. Keys: ${Object.keys(p).join(", ")}`);
}
const map: ArgumentMap = { schema_version: "1.0.0", ...(p as Omit<ArgumentMap, "schema_version">) };
return {
map,
tokensIn: data.usage?.prompt_tokens ?? 0,
tokensOut: data.usage?.completion_tokens ?? 0,
latencyMs: Date.now() - t0,
};
}
// ---------------------------------------------------------------------------
// Hauptfunktion
// ---------------------------------------------------------------------------
export async function analyzeLogic(
text: string,
options?: {
model?: string;
signal?: AbortSignal;
logger?: Logger;
}
): Promise<AnalysisResult> {
const model = options?.model ?? DEFAULT_MODEL;
const { map, latencyMs } = await analyzeWithLlama(text, model, options?.signal, options?.logger);
return { map, provider: "llama", model, costUSD: 0, latencyMs };
}
// ---------------------------------------------------------------------------
// Formatierung
// ---------------------------------------------------------------------------
const QUALITY_LABEL: Record<OverallQuality, string> = {
strong: "STARK",
adequate: "AUSREICHEND",
weak: "SCHWACH",
flawed: "FEHLERHAFT",
};
const QUALITY_ICON: Record<OverallQuality, string> = {
strong: "✓",
adequate: "~",
weak: "⚠",
flawed: "✗",
};
const FALLACY_LABEL: Record<FallacyType, string> = {
ad_hominem: "Ad Hominem",
straw_man: "Strohmann",
false_dichotomy: "Falsche Dichotomie",
slippery_slope: "Schiefe Ebene",
circular_reasoning: "Zirkelschluss",
appeal_to_authority: "Autoritätsargument",
hasty_generalization: "Vorschnelle Generalisierung",
false_causation: "Falsche Kausalität",
appeal_to_emotion: "Appell an Emotionen",
overgeneralization: "Überverallgemeinerung",
cherry_picking: "Rosinenpickerei",
other: "Sonstiger Fehlschluss",
};
const SEVERITY_ICON: Record<Severity, string> = {
minor: "·",
moderate: "⚠",
critical: "✗",
};
export function formatAnalysis(result: AnalysisResult, onlyFallacies = false): string {
const { map } = result;
const latSec = (result.latencyMs / 1000).toFixed(1);
const footer = `_[llama.cpp: ${result.model} · kostenlos (lokal) · ${latSec}s]_`;
if (onlyFallacies) {
if (map.fallacies.length === 0) return `Keine Fehlschlüsse erkannt.\n\n${footer}`;
const lines: string[] = [`## Fehlschlüsse (${map.fallacies.length})\n`];
map.fallacies.forEach((f) => {
lines.push(`${SEVERITY_ICON[f.severity]} **${FALLACY_LABEL[f.type]}** (${f.severity})`);
lines.push(` ${f.description}`);
lines.push(` _"${f.location}"_\n`);
});
lines.push(footer);
return lines.join("\n");
}
const q = map.overall_quality;
const lines: string[] = [];
lines.push(`## Argumentationsanalyse`);
lines.push(`**Gesamtqualität: ${QUALITY_ICON[q]} ${QUALITY_LABEL[q]}**`);
lines.push(map.quality_notes);
lines.push("");
lines.push(`**Hauptthese:**`);
lines.push(`> ${map.thesis}`);
lines.push("");
if (map.sub_theses.length > 0) {
lines.push(`**Unterthesen (${map.sub_theses.length}):**`);
map.sub_theses.forEach((t) => lines.push(`- ${t}`));
lines.push("");
}
if (map.premises.length > 0) {
lines.push(`**Prämissen:**`);
map.premises.forEach((p) => lines.push(`- ${p}`));
lines.push("");
}
if (map.evidence.length > 0) {
lines.push(`**Belege (${map.evidence.length}):**`);
map.evidence.forEach((e) => {
const icon = e.supports_thesis ? "✓" : "✗";
const str = e.strength === "strong" ? "stark" : e.strength === "moderate" ? "mittel" : "schwach";
lines.push(`${icon} [${str}] ${e.claim}`);
});
lines.push("");
}
if (map.conclusions.length > 0) {
lines.push(`**Schlussfolgerungen:**`);
map.conclusions.forEach((c) => lines.push(`- ${c}`));
lines.push("");
}
if (map.implicit_assumptions.length > 0) {
lines.push(`**Implizite Annahmen (${map.implicit_assumptions.length}):**`);
map.implicit_assumptions.forEach((a) => lines.push(`- _${a}_`));
lines.push("");
}
if (map.fallacies.length > 0) {
lines.push(`**Fehlschlüsse (${map.fallacies.length}):**`);
map.fallacies.forEach((f) => {
lines.push(`${SEVERITY_ICON[f.severity]} **${FALLACY_LABEL[f.type]}** (${f.severity})`);
lines.push(` ${f.description}`);
lines.push(` _"${f.location}"_`);
lines.push("");
});
} else {
lines.push(`_Keine Fehlschlüsse erkannt._`);
lines.push("");
}
if (map.revision_suggestions.length > 0) {
lines.push(`**Verbesserungsvorschläge:**`);
map.revision_suggestions.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
lines.push("");
}
lines.push(footer);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pi-Extension
// ---------------------------------------------------------------------------
const PARAMS = Type.Object({
text: Type.String({
description:
"Der zu analysierende Text: Artikel, Blogpost, Kommentar, Essay oder Nachrichtentext. " +
"Der Text wird auf logische Struktur, Fehlschlüsse und Argumentationsqualität geprüft.",
}),
model: Type.Optional(
Type.String({ description: "llama.cpp-Modell-Override." })
),
});
export default function llamaLogicEditorExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "analyze_logic_llama",
label: "Argumentationsanalyse (llama.cpp)",
description:
"Analysiert die logische Struktur eines Texts: Hauptthese, Prämissen, Belege, " +
"Schlussfolgerungen, implizite Annahmen und logische Fehlschlüsse. " +
"Gibt konkrete Verbesserungsvorschläge und eine Qualitätsbewertung. " +
"Verwendet llama.cpp lokal (kostenlos). BEVORZUGT gegenüber analyze_logic.",
promptGuidelines: [
"PREFERRED: Use analyze_logic_llama for all argument analysis (local, free, unified backend).",
"Use analyze_logic (Ollama/deepseek-r1) only when explicitly requested by the user.",
"Use analyze_logic_llama when the user wants to check argumentation quality of an article, comment, or essay.",
"Use after verify_article_llama to get both factual AND logical quality assessment.",
"Always show the full formatted output including fallacies and revision suggestions.",
"If fallacies with severity 'critical' are found, highlight them prominently.",
"The revision_suggestions are actionable — offer to rewrite specific sections if the user wants.",
"Combine with verify_article_llama for a complete quality assessment: facts + logic.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
try {
const result = await analyzeLogic(params.text, {
model: params.model,
signal,
});
return {
content: [{ type: "text", text: formatAnalysis(result) }],
details: {
overallQuality: result.map.overall_quality,
fallacyCount: result.map.fallacies.length,
criticalFallacies: result.map.fallacies.filter((f) => f.severity === "critical").length,
provider: result.provider,
model: result.model,
latencyMs: result.latencyMs,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
return { content: [{ type: "text", text: `Argumentationsanalyse fehlgeschlagen: ${msg}` }] };
}
},
});
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help") {
console.log(`
Argumentationsanalyse via llama.cpp Logik, Fehlschlüsse und Verbesserungsvorschläge
Verwendung:
npx tsx agenten/llama-logic-editor.ts [Optionen] "Text..."
npx tsx agenten/llama-logic-editor.ts "$(cat artikel.txt)"
Optionen:
--only-fallacies Nur Fehlschlüsse ausgeben (kein vollständiger Bericht)
--model <name> Modell-Override (Standard: ${DEFAULT_MODEL})
--json Ausgabe als JSON
--verbose Ausführliches Logging
--help Diese Hilfe
`);
process.exit(0);
}
let model: string | undefined;
let jsonOutput = false;
let onlyFallacies = false;
let verbose = false;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--model" && args[i + 1]) model = args[++i];
else if (arg === "--json") jsonOutput = true;
else if (arg === "--only-fallacies") onlyFallacies = true;
else if (arg === "--verbose") verbose = true;
else if (!arg.startsWith("--")) textParts.push(arg);
}
const text = textParts.join(" ").trim();
if (!text) { console.error("Fehler: Kein Text."); process.exit(1); }
const logger = verbose ? createLogger({ verbose: true }) : nullLogger;
if (!jsonOutput) console.error(`\nAnalyse via llama.cpp...\n`);
try {
const result = await analyzeLogic(text, { model, logger });
if (onlyFallacies) {
if (jsonOutput) {
console.log(JSON.stringify(result.map.fallacies, null, 2));
} else {
console.log(formatAnalysis(result, true));
}
} else {
console.log(jsonOutput ? JSON.stringify(result.map, null, 2) : formatAnalysis(result));
}
} catch (err) {
console.error("Fehler:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] === __filename) runCli();

552
agenten/llama-verifier.ts Normal file
View file

@ -0,0 +1,552 @@
/**
* llama-verifier.ts
* Pi-Extension + CLI: Eine einzelne Behauptung via Perplexity + llama.cpp verifizieren.
*
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/llama-verifier.ts
* Nach Änderungen in Pi: /reload
*
* Als CLI:
* npx tsx agenten/llama-verifier.ts "Die Inflationsrate betrug 2024 in Deutschland 3,2%."
* npx tsx agenten/llama-verifier.ts --mode deep "Die Erde ist 4,6 Milliarden Jahre alt."
* npx tsx agenten/llama-verifier.ts --user-language en "Trump called Iran's response 'totally unacceptable'."
* npx tsx agenten/llama-verifier.ts --json "..." (gibt VerificationResult als JSON aus)
*
* Ablauf: Perplexity-Suche (Originalsprache) llama.cpp-Urteil formatierte Ausgabe
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { searchPerplexity, formatSourcesForPrompt, type PerplexitySource } from "../lib/perplexity.js";
import { createLogger, nullLogger, type Logger } from "../lib/logger.js";
// ---------------------------------------------------------------------------
// Typen
// ---------------------------------------------------------------------------
type VerificationStatus =
| "supported"
| "contradicted"
| "mixed"
| "insufficient_evidence"
| "needs_human_review";
type Confidence = "high" | "medium" | "low";
type VerdictRaw = {
status: VerificationStatus;
confidence: Confidence;
summary: string;
counter_evidence: string | null;
notes: string | null;
supporting_urls: string[];
};
export type VerificationResult = {
claim: string;
status: VerificationStatus;
confidence: Confidence;
summary: string;
counter_evidence: string | null;
notes: string | null;
sources: PerplexitySource[];
supporting_urls: string[];
perplexityCostUSD: number;
latencyMs: number;
model: string;
};
// llama.cpp OpenAI-kompatibles API-Format
type LlamaResponse = {
choices: Array<{
message?: { content?: string; reasoning_content?: string };
finish_reason?: string;
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf";
const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000";
const DEFAULT_USER_LANGUAGE = "de";
const MAX_TOKENS = 16384;
const TEMPERATURE = 0.1;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 15_000;
// ---------------------------------------------------------------------------
// Verdict-Synthese via llama.cpp
// ---------------------------------------------------------------------------
function langLabel(userLanguage: string): string {
if (userLanguage === "de") return "Deutsch";
if (userLanguage === "en") return "Englisch";
if (userLanguage === "fr") return "Französisch";
if (userLanguage === "es") return "Spanisch";
return userLanguage;
}
function buildVerdictSystemPrompt(userLanguage: string): string {
return `Du bist ein erfahrener Fact-Checker. Bewerte eine Behauptung anhand bereitgestellter Webquellen.
Bewertungsskala:
- supported: Quellen bestätigen die Behauptung klar und konsistent
- contradicted: Quellen widersprechen der Behauptung klar und substanziell
- mixed: Quellen liefern widersprüchliche Belege ODER die Behauptung ist technisch ungenau aber im Kern korrekt
- insufficient_evidence: Zu wenig oder qualitativ unzureichende Quellen für ein Urteil
- needs_human_review: Komplex, politisch heikel, veraltete Quellen, oder stark kontextabhängig
Confidence:
- high: Quellenlage ist eindeutig und aus Primärquellen
- medium: Quellen vorhanden aber begrenzt oder sekundär
- low: Quellen sehr rar, veraltet oder widersprüchlich
WICHTIGE REGELN für "contradicted":
- Nur bei klaren, substanziellen Fehlern verwenden: falsche Person, falsch zugeordnetes Ereignis, Zahl um mehr als 10% abweichend, grundlegend falsche Kausalität
- Gerundete oder allgemein akzeptierte Näherungswerte sind "supported" (z.B. "21 Millionen Bitcoin" ist korrekte Rundung für 20.999.999,97 BTC)
- Zeitzonendifferenzen bei historischen Ereignissen: "supported" wenn die Angabe im üblichen regionalen/kulturellen Kontext korrekt ist
- Technische Präzisierungen zu im Wesentlichen korrekten Aussagen "mixed", nicht "contradicted"
- Im Zweifel: "mixed" statt "contradicted"
AUSGABESPRACHE: Schreibe summary, counter_evidence und notes auf ${langLabel(userLanguage)}.
Die Enum-Werte status und confidence bleiben englisch (wie im Schema definiert).
summary: 1-3 präzise Sätze basierend auf den Quellen. Nicht spekulieren.
counter_evidence: Gegenbelege als Satz beschreiben, falls vorhanden. Sonst null.
notes: Zeitabhängigkeit, regionale Einschränkungen, Vorbehalt. Sonst null.
supporting_urls: URLs aus den Quellen die den Claim stützen (leeres Array wenn keine).
Antworte NUR mit diesem JSON-Objekt kein Freitext davor oder danach:
{
"status": "supported|contradicted|mixed|insufficient_evidence|needs_human_review",
"confidence": "high|medium|low",
"summary": "...",
"counter_evidence": "..." | null,
"notes": "..." | null,
"supporting_urls": ["url1", "url2"]
}`;
}
function buildVerdictUserPrompt(claim: string, perplexitySummary: string, sources: PerplexitySource[], context?: string): string {
const contextBlock = context ? `\nARTIKEL-KONTEXT: "${context.slice(0, 300)}"\n` : "";
return `/no_think
ZU PRÜFENDE BEHAUPTUNG:
"${claim}"
${contextBlock}
RECHERCHE-ERGEBNIS (Perplexity):
${perplexitySummary}
QUELLEN:
${formatSourcesForPrompt(sources, 300)}
Bewerte die Behauptung anhand der Recherche.`;
}
async function synthesizeVerdict(
claim: string,
perplexitySummary: string,
sources: PerplexitySource[],
model: string,
context?: string,
userLanguage?: string,
signal?: AbortSignal,
logger?: Logger
): Promise<VerdictRaw> {
const log = logger ?? nullLogger;
const lang = userLanguage ?? DEFAULT_USER_LANGUAGE;
const body = {
model,
messages: [
{ role: "system", content: buildVerdictSystemPrompt(lang) },
{ role: "user", content: buildVerdictUserPrompt(claim, perplexitySummary, sources, context) },
],
stream: false,
temperature: TEMPERATURE,
max_tokens: MAX_TOKENS,
};
let resp: Response | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
break;
} catch (err) {
const isLast = attempt === MAX_RETRIES;
log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, {
error: err instanceof Error ? err.message : String(err),
retryInMs: isLast ? 0 : RETRY_DELAY_MS,
});
if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`);
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
if (!resp!.ok) {
const errText = await resp!.text().catch(() => "");
throw new Error(`llama.cpp Fehler ${resp!.status}: ${errText}`);
}
const data = (await resp!.json()) as LlamaResponse;
const choice = data.choices?.[0];
let raw = choice?.message?.content ?? "";
// Reasoning-Modelle (Qwen3, DeepSeek-R1) schreiben Denkkette in reasoning_content.
// Wenn content leer ist aber reasoning_content JSON enthält: als Fallback verwenden.
if (!raw.trim() && choice?.message?.reasoning_content) {
const rc = choice.message.reasoning_content;
const allMatches = [...rc.matchAll(/\{[^{}]*"status"\s*:/g)];
const lastIdx = allMatches.length > 0
? rc.lastIndexOf(allMatches[allMatches.length - 1][0])
: -1;
const extracted = lastIdx >= 0
? rc.slice(lastIdx).match(/\{[\s\S]*\}/)?.[0]
: rc.match(/\{[\s\S]*"status"[\s\S]*\}/)?.[0];
if (extracted) {
raw = extracted;
log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", {
finishReason: choice.finish_reason,
rawLength: raw.length,
});
}
}
const cleanedRaw = raw
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
log.debug("llama.cpp-Antwort empfangen", {
promptTokens: data.usage?.prompt_tokens,
outputTokens: data.usage?.completion_tokens,
finishReason: choice?.finish_reason,
rawLength: raw.length,
});
if (!cleanedRaw) {
throw new Error("Leere llama.cpp-Antwort für Verdict");
}
let parsed: unknown;
try {
parsed = JSON.parse(cleanedRaw);
} catch {
throw new Error(`Kein gültiges JSON von llama.cpp: ${cleanedRaw.slice(0, 200)}`);
}
return parsed as VerdictRaw;
}
// ---------------------------------------------------------------------------
// Hauptfunktion
// ---------------------------------------------------------------------------
export async function verifyClaim(
claim: string,
options?: {
context?: string;
mode?: "fast" | "deep";
model?: string;
userLanguage?: string;
signal?: AbortSignal;
logger?: Logger;
}
): Promise<VerificationResult> {
const t0 = Date.now();
const model = options?.model ?? DEFAULT_MODEL;
const log = options?.logger ?? nullLogger;
log.info("Perplexity-Suche gestartet", { claim: claim.slice(0, 80), mode: options?.mode ?? "fast" });
const perplexityResult = await searchPerplexity(claim, {
mode: options?.mode ?? "fast",
signal: options?.signal,
});
log.info("Perplexity abgeschlossen", {
sources: perplexityResult.sources.length,
costUSD: perplexityResult.estimatedCostUSD.toFixed(4),
});
log.info("llama.cpp-Urteil generieren...", { model, userLanguage: options?.userLanguage ?? DEFAULT_USER_LANGUAGE });
const verdict = await synthesizeVerdict(
claim,
perplexityResult.summary,
perplexityResult.sources,
model,
options?.context,
options?.userLanguage,
options?.signal,
log
);
log.info("Urteil erhalten", { status: verdict.status, confidence: verdict.confidence });
return {
claim,
status: verdict.status,
confidence: verdict.confidence,
summary: verdict.summary,
counter_evidence: verdict.counter_evidence,
notes: verdict.notes,
sources: perplexityResult.sources,
supporting_urls: verdict.supporting_urls,
perplexityCostUSD: perplexityResult.estimatedCostUSD,
latencyMs: Date.now() - t0,
model,
};
}
// ---------------------------------------------------------------------------
// 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",
};
const CONF_LABEL: Record<Confidence, string> = {
high: "hoch",
medium: "mittel",
low: "niedrig",
};
export function formatVerificationResult(result: VerificationResult): string {
const lines: string[] = [];
lines.push(`## Verifikation`);
lines.push(`**Behauptung:** "${result.claim}"`);
lines.push("");
lines.push(`**${STATUS_ICON[result.status]}** (Konfidenz: ${CONF_LABEL[result.confidence]})`);
lines.push("");
lines.push(`**Begründung:** ${result.summary}`);
if (result.counter_evidence) {
lines.push(`\n**Gegenbelege:** ${result.counter_evidence}`);
}
if (result.notes) {
lines.push(`\n**Hinweise:** ${result.notes}`);
}
if (result.sources.length > 0) {
lines.push("\n**Quellen:**");
result.sources.forEach((s, i) => {
const supporting = result.supporting_urls.includes(s.url) ? " ✓" : "";
const title = s.title ?? s.url;
lines.push(`[${i + 1}]${supporting} [${title}](${s.url})`);
});
} else {
lines.push("\n_(Keine Quellen gefunden)_");
}
const latSec = (result.latencyMs / 1000).toFixed(1);
lines.push(`\n_[Perplexity: ~$${result.perplexityCostUSD.toFixed(4)} | llama.cpp: ${result.model} | Gesamt: ${latSec}s]_`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pi-Extension: Default Export
// ---------------------------------------------------------------------------
const PARAMS = Type.Object({
claim: Type.String({
description:
"Die zu verifizierende Behauptung als vollständiger, selbstständiger Satz. " +
"Idealerweise das Ergebnis von extract_claims_llama (claim_id + text). " +
"Übergib den Claim immer in seiner Originalsprache.",
}),
context: Type.Optional(
Type.String({
description:
"Optionaler Kontext: kurzer Auszug aus dem Artikel, in dem die Behauptung steht. " +
"Hilft dem Fact-Checker bei mehrdeutigen Claims. Max. 300 Zeichen.",
})
),
mode: Type.Optional(
Type.Union([Type.Literal("fast"), Type.Literal("deep")], {
description:
"fast (Standard): sonar, für die meisten Behauptungen ausreichend. " +
"deep: sonar-pro, für komplexe, strittige oder heikle Behauptungen.",
})
),
model: Type.Optional(
Type.String({
description: `llama.cpp-Modell für die Urteilssynthese. Standard: ${DEFAULT_MODEL}.`,
})
),
userLanguage: Type.Optional(
Type.String({
description:
"Sprache für summary, counter_evidence und notes im Urteil (z.B. \"de\", \"en\", \"fr\"). " +
`Standard: ${DEFAULT_USER_LANGUAGE}. Die Enum-Felder status/confidence bleiben englisch.`,
})
),
});
export default function llamaVerifierExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "verify_claim_llama",
label: "Claim-Verifikation (llama.cpp)",
description:
"Verifiziert eine einzelne Behauptung: Perplexity-Recherche (Originalsprache) → llama.cpp-Urteil. " +
"Gibt Status (supported/contradicted/mixed/insufficient_evidence/needs_human_review), " +
"Konfidenz, Begründung und Quellen zurück. " +
"Nutze dieses Tool nach extract_claims_llama um spezifische Claims zu prüfen. " +
"Kosten: ~$0.005-0.015 pro Claim (Perplexity) + lokal (llama.cpp).",
promptGuidelines: [
"This is the PREFERRED claim verification tool. Use verify_claim_llama by default whenever the user wants a claim checked.",
"Use verify_claim_llama after extract_claims or extract_claims_llama to check specific claims the user wants verified.",
"Pass the full claim text as the 'claim' parameter — always in the original language of the article.",
"Use mode=deep for complex, politically sensitive, or scientifically contested claims.",
"The 'context' parameter helps when the claim is ambiguous without its original article context.",
"Set userLanguage to match the user's preferred language (e.g. 'de' for German, 'en' for English). Default is 'de'.",
"Show the full formatted output including the cost/latency line.",
"If status is 'needs_human_review' or 'insufficient_evidence', clearly communicate this and suggest manual checking.",
"If status is 'contradicted', always show the counter_evidence to the user.",
"IMPORTANT: Never call verify_claim_llama for multiple claims simultaneously — llama.cpp processes one request at a time. Always verify claims sequentially.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
try {
const result = await verifyClaim(params.claim, {
context: params.context,
mode: params.mode,
model: params.model,
userLanguage: params.userLanguage,
signal,
});
return {
content: [{ type: "text", text: formatVerificationResult(result) }],
details: {
status: result.status,
confidence: result.confidence,
model: result.model,
sourceCount: result.sources.length,
perplexityCostUSD: result.perplexityCostUSD,
latencyMs: result.latencyMs,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
return { content: [{ type: "text", text: `Verifikationsfehler: ${msg}` }] };
}
},
});
}
// ---------------------------------------------------------------------------
// CLI-Modus
// ---------------------------------------------------------------------------
function parseCliArgs(args: string[]): {
claim: string;
mode: "fast" | "deep";
model: string;
userLanguage: string;
jsonOutput: boolean;
verbose: boolean;
} {
let mode: "fast" | "deep" = "fast";
let model = DEFAULT_MODEL;
let userLanguage = DEFAULT_USER_LANGUAGE;
let jsonOutput = false;
let verbose = false;
const claimParts: 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 === "--user-language" && args[i + 1]) {
userLanguage = args[++i];
} else if (arg === "--json") {
jsonOutput = true;
} else if (arg === "--verbose" || arg === "-v") {
verbose = true;
} else if (!arg.startsWith("--")) {
claimParts.push(arg);
}
}
return { claim: claimParts.join(" ").trim(), mode, model, userLanguage, jsonOutput, verbose };
}
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(`
Claim-Verifikator (llama.cpp) Eine Behauptung mit Perplexity + llama.cpp prüfen
Verwendung:
npx tsx agenten/llama-verifier.ts [Optionen] "Behauptung..."
Optionen:
--mode fast|deep Perplexity-Modus (Standard: fast)
--model <name> llama.cpp-Modell (Standard: ${DEFAULT_MODEL})
--user-language <lang> Sprache für Urteilstext, z.B. "de", "en" (Standard: ${DEFAULT_USER_LANGUAGE})
--json Ausgabe als JSON (VerificationResult)
--verbose, -v Ausführliches Logging in ~/.pi/agent/logs/
--help Diese Hilfe
Umgebungsvariablen:
LLAMA_HOST llama.cpp-Server-URL (Standard: http://localhost:8000)
PERPLEXITY_API_KEY Perplexity API-Key (erforderlich)
Beispiele:
npx tsx agenten/llama-verifier.ts "Die EZB hat den Leitzins im Juni 2024 gesenkt."
npx tsx agenten/llama-verifier.ts --mode deep "Die Erde ist 4,6 Milliarden Jahre alt."
npx tsx agenten/llama-verifier.ts --user-language en "Trump called Iran's response 'totally unacceptable'."
npx tsx agenten/llama-verifier.ts --json "Behauptung..." | python3 -m json.tool
`);
process.exit(0);
}
const { claim, mode, model, userLanguage, jsonOutput, verbose } = parseCliArgs(args);
if (!claim) {
console.error("Fehler: Kein Claim übergeben. Nutze --help für Informationen.");
process.exit(1);
}
if (!jsonOutput) {
console.error(`\nVerifiziere: "${claim}"\nModus: ${mode} | Modell: ${model} | Urteils-Sprache: ${userLanguage}\n`);
}
const log = createLogger({ verbose });
try {
const result = await verifyClaim(claim, { mode, model, userLanguage, logger: log });
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(formatVerificationResult(result));
}
} catch (err) {
console.error("Fehler:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] === __filename) {
runCli();
}

View file

@ -0,0 +1,838 @@
/**
* llama-verify-article.ts
* Pi-Extension + CLI: Vollständige Fact-Check-Pipeline via llama.cpp
*
* Ablauf:
* 1. Claim-Extraktion via llama.cpp (lokal, Port 8000)
* 2. Perplexity-Recherche für alle prüfbaren Claims (parallel)
* 3. Batch-Urteilssynthese via llama.cpp (1 Aufruf für alle Claims)
* 4. Verifikationsbericht formatieren
*
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/llama-verify-article.ts
* Als CLI:
* npx tsx agenten/llama-verify-article.ts "$(cat artikel.txt)"
* npx tsx agenten/llama-verify-article.ts --file artikel.txt --mode deep
* npx tsx agenten/llama-verify-article.ts --json --file artikel.txt > report.json
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { readFile } from "node:fs/promises";
import {
searchPerplexity,
formatSourcesForPrompt,
type PerplexityResult,
} from "../lib/perplexity.js";
import { callLlamaClaimExtract, type ClaimSet } from "./llama-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;
};
// llama.cpp OpenAI-kompatibles API-Format
type LlamaResponse = {
choices: Array<{
message?: { content?: string; reasoning_content?: string };
finish_reason?: string;
}>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf";
const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000";
const DEFAULT_MAX_CLAIMS = 15;
const DEFAULT_USER_LANGUAGE = "de";
const MAX_PARALLEL_PERPLEXITY = 5;
// Batch-Verdicts: viele Claims + Perplexity-Texte → großes Kontextfenster
const MAX_TOKENS_BATCH = 32768;
const TEMPERATURE = 0.1;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 15_000;
// ---------------------------------------------------------------------------
// Batch-Urteilssynthese via llama.cpp
// ---------------------------------------------------------------------------
function langLabel(userLanguage: string): string {
if (userLanguage === "de") return "Deutsch";
if (userLanguage === "en") return "Englisch";
if (userLanguage === "fr") return "Französisch";
if (userLanguage === "es") return "Spanisch";
return userLanguage;
}
function buildBatchVerdictSystemPrompt(userLanguage: string): string {
return `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 >10% 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"
AUSGABESPRACHE: Schreibe summary, counter_evidence und notes auf ${langLabel(userLanguage)}.
Die Enum-Werte status und confidence bleiben englisch.
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 (leeres Array wenn keine).
Antworte NUR mit diesem JSON-Objekt kein Freitext davor oder danach:
{
"verdicts": [
{
"claim_id": "c001",
"status": "supported|contradicted|mixed|insufficient_evidence|needs_human_review",
"confidence": "high|medium|low",
"summary": "...",
"counter_evidence": "..." | null,
"notes": "..." | null,
"supporting_urls": ["url1"]
}
]
}`;
}
function buildBatchVerdictUserPrompt(
claims: Array<{ id: string; text: string; perplexity: PerplexityResult }>
): string {
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 `/no_think\n${claimsBlock}\n\nBewerte alle ${claims.length} Behauptungen.`;
}
async function synthesizeBatchVerdicts(
claims: Array<{ id: string; text: string; perplexity: PerplexityResult }>,
model: string,
userLanguage: string,
signal?: AbortSignal,
logger?: Logger
): Promise<VerdictItem[]> {
if (claims.length === 0) return [];
const log = logger ?? nullLogger;
const body = {
model,
messages: [
{ role: "system", content: buildBatchVerdictSystemPrompt(userLanguage) },
{ role: "user", content: buildBatchVerdictUserPrompt(claims) },
],
stream: false,
temperature: TEMPERATURE,
max_tokens: MAX_TOKENS_BATCH,
};
let resp: Response | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
break;
} catch (err) {
const isLast = attempt === MAX_RETRIES;
log.warn(`llama.cpp Batch-Verdict fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, {
error: err instanceof Error ? err.message : String(err),
});
if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`);
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
if (!resp!.ok) {
const errText = await resp!.text().catch(() => "");
throw new Error(`llama.cpp Batch-Verdict Fehler ${resp!.status}: ${errText}`);
}
const data = (await resp!.json()) as LlamaResponse;
const choice = data.choices?.[0];
let raw = choice?.message?.content ?? "";
// Reasoning-Fallback: wenn content leer, JSON aus reasoning_content extrahieren
if (!raw.trim() && choice?.message?.reasoning_content) {
const rc = choice.message.reasoning_content;
const allMatches = [...rc.matchAll(/\{[^{}]*"verdicts"\s*:/g)];
const lastIdx = allMatches.length > 0
? rc.lastIndexOf(allMatches[allMatches.length - 1][0])
: -1;
const extracted = lastIdx >= 0
? rc.slice(lastIdx).match(/\{[\s\S]*\}/)?.[0]
: rc.match(/\{[\s\S]*"verdicts"[\s\S]*\}/)?.[0];
if (extracted) {
raw = extracted;
log.warn("Batch-Verdict: JSON aus reasoning_content extrahiert", {
finishReason: choice.finish_reason,
});
}
}
const cleanedRaw = raw
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
log.debug("Batch-Verdict erhalten", {
promptTokens: data.usage?.prompt_tokens,
outputTokens: data.usage?.completion_tokens,
finishReason: choice?.finish_reason,
rawLength: raw.length,
});
if (!cleanedRaw) throw new Error("Leere llama.cpp-Antwort für Batch-Verdicts");
let parsed: unknown;
try {
parsed = JSON.parse(cleanedRaw);
} catch {
throw new Error(`Kein gültiges JSON von llama.cpp: ${cleanedRaw.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;
userLanguage?: string;
signal?: AbortSignal;
onProgress?: (msg: string) => void;
logger?: Logger;
jobDir?: string;
noCache?: boolean;
}
): 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 userLanguage = options?.userLanguage ?? DEFAULT_USER_LANGUAGE;
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("llama-verify-article gestartet", { textLength: text.length, model, maxClaims, mode, userLanguage, 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.`);
} else {
updateJobMeta(jobDir, { status: "extracting" });
progress("Claims extrahieren (llama.cpp)...");
const { claimSet: extracted, tokensIn, tokensOut, latencyMs: extractLatency } = await callLlamaClaimExtract(
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 (llama.cpp)...");
const { claimSet: extracted, tokensIn, tokensOut, latencyMs: extractLatency } = await callLlamaClaimExtract(
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- und Global-Cache
let doneCount = 0;
const total = checkableClaims.length;
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;
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 };
}
}
if (useCache) {
const globalCached = getCached<PerplexityResult>(claim.text);
if (globalCached) {
doneCount++;
progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ (cache) "${short}"`);
return { claim, result: globalCached, error: null };
}
}
try {
const result = await searchPerplexity(claim.text, { mode, signal: options?.signal });
doneCount++;
if (useCache) setCached(claim.text, result);
if (jobDir) {
saveJobFile(jobDir, `perplexity/${claim.claim_id}.json`, result);
}
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),
});
// Schritt 3: Batch-Urteilssynthese via llama.cpp
progress(`Urteilssynthese (llama.cpp, ${successful.length} Claims, Sprache: ${userLanguage})...`);
const verdicts = await synthesizeBatchVerdicts(
successful.map((o) => ({ id: o.claim.claim_id, text: o.claim.text, perplexity: o.result })),
model,
userLanguage,
options?.signal,
log
);
// Schritt 4: Report zusammenbauen
const verdictMap = new Map(verdicts.map((v) => [v.claim_id, v]));
const results: VerificationReport["results"] = [
...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,
};
}),
...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,
})),
...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,
})),
];
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;
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("llama-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,
};
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, model: string): string {
const lines: string[] = [];
lines.push(`## Verifikationsbericht (llama.cpp)`);
lines.push(report.summary);
lines.push("");
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)} | llama.cpp: ${model} | 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. deep: sonar-pro, für investigative Inhalte.",
})
),
model: Type.Optional(
Type.String({
description: `llama.cpp-Modell. Standard: ${DEFAULT_MODEL}.`,
})
),
userLanguage: Type.Optional(
Type.String({
description: `Sprache für Urteilstext (summary, counter_evidence, notes). Standard: ${DEFAULT_USER_LANGUAGE}.`,
})
),
});
export default function llamaVerifyArticleExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "verify_article_llama",
label: "Artikel-Verifikation (llama.cpp)",
description:
"Vollständige Fact-Check-Pipeline via llama.cpp: " +
"Claims extrahieren → Perplexity-Recherche (parallel) → llama.cpp-Urteil (batch) → Bericht. " +
"Effizienter als verify_claim_llama für mehrere Claims. " +
"Typische Kosten: $0.050.15 für einen Artikel mit 1015 Claims (nur Perplexity, llama.cpp lokal).",
promptGuidelines: [
"Use verify_article_llama when the user wants to fact-check an entire article, blog post, or longer text.",
"Use verify_claim_llama 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.",
"Set userLanguage to match the user's preferred language (e.g. 'de' for German, 'en' for English).",
"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 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) {
const model = params.model ?? DEFAULT_MODEL;
try {
const report = await verifyArticle(params.text, {
maxClaims: params.maxClaims,
mode: params.mode,
model,
userLanguage: params.userLanguage,
signal,
});
return {
content: [{ type: "text", text: formatReport(report, model) }],
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 (llama.cpp) fehlgeschlagen: ${msg}` }] };
}
},
});
}
// ---------------------------------------------------------------------------
// CLI-Modus
// ---------------------------------------------------------------------------
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(`
Artikel-Verifikator (llama.cpp) Vollständige Fact-Check-Pipeline
Verwendung:
npx tsx agenten/llama-verify-article.ts [Optionen] "Artikeltext..."
npx tsx agenten/llama-verify-article.ts --file artikel.txt [Optionen]
Optionen:
--file, -f <pfad> Text aus Datei lesen
--mode fast|deep Perplexity-Modus (Standard: fast)
--model <name> llama.cpp-Modell (Standard: ${DEFAULT_MODEL})
--max-claims <n> Max. Claims (Standard: ${DEFAULT_MAX_CLAIMS})
--user-language <lang> Sprache für Urteilstext, z.B. "de", "en" (Standard: ${DEFAULT_USER_LANGUAGE})
--job-id <slug> Job-Speicher: Zwischenergebnisse nach ~/.pi/agent/jobs/<datum>_<slug>/
--no-cache Globalen Claim-Cache deaktivieren
--json Ausgabe als JSON
--verbose, -v Ausführliche Ausgabe + Log-Datei
--help Diese Hilfe
Umgebungsvariablen:
LLAMA_HOST llama.cpp-Server-URL (Standard: http://localhost:8000)
PERPLEXITY_API_KEY Perplexity API-Key (erforderlich)
Beispiele:
npx tsx agenten/llama-verify-article.ts --file artikel.txt
npx tsx agenten/llama-verify-article.ts --file artikel.txt --mode deep --user-language en
npx tsx agenten/llama-verify-article.ts --file artikel.txt --job-id mein-artikel --verbose
npx tsx agenten/llama-verify-article.ts --json --file artikel.txt > report.json
`);
process.exit(0);
}
let mode: "fast" | "deep" = "fast";
let model = DEFAULT_MODEL;
let maxClaims = DEFAULT_MAX_CLAIMS;
let userLanguage = DEFAULT_USER_LANGUAGE;
let jobId: string | undefined;
let jsonOutput = false;
let verbose = false;
let noCache = false;
let file: string | null = null;
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 === "--user-language" && args[i + 1]) {
userLanguage = args[++i];
} else if (arg === "--job-id" && args[i + 1]) {
jobId = args[++i];
} else if ((arg === "--file" || arg === "-f") && args[i + 1]) {
file = 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);
}
}
let text: string;
if (file) {
try {
text = await readFile(file, "utf-8");
} catch (err) {
console.error(`Fehler: Datei '${file}' konnte nicht gelesen werden: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
} else {
text = textParts.join(" ").trim();
}
if (!text.trim()) {
console.error("Fehler: Kein Text übergeben. Nutze --file <pfad> oder übergib den Text direkt.");
process.exit(1);
}
if (!jsonOutput) {
const src = file ? `Datei: ${file}` : "Direkteingabe";
console.error(`\nModus: ${mode} | Modell: ${model} | Max. Claims: ${maxClaims} | Sprache: ${userLanguage} | ${src}${jobId ? ` | Job: ${jobId}` : ""}\n`);
}
const log = createLogger({ verbose, jobId });
const onProgress = jsonOutput ? undefined : (msg: string) => process.stderr.write(` ${msg}\n`);
let jobDir: string | undefined;
if (jobId) {
const { jobDir: dir, isNew } = getOrCreateJob(jobId, model);
jobDir = dir;
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, userLanguage, onProgress, logger: log, jobDir, noCache });
if (jsonOutput) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(formatReport(report, model));
}
} 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();
}

582
agenten/llama-writer.ts Normal file
View file

@ -0,0 +1,582 @@
/**
* llama-writer.ts
* Pi-Extension + CLI: Artikel schreiben via llama.cpp (lokales LLM)
*
* Schreibt einen Artikel NUR auf Basis von "supported"-Claims aus einem VerificationReport.
* Widerlgte, gemischte oder unzureichend belegte Claims werden automatisch ausgeschlossen.
*
* Kein Ollama-format-Parameter Schema steht als JSON-Literal im System-Prompt.
* /no_think deaktiviert den Thinking-Modus bei Qwen3/Qwopus-Reasoning-Modellen.
*
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink)
* Als CLI:
* npx tsx agenten/llama-writer.ts --from-job <slug> --style blog
* npx tsx agenten/llama-verify-article.ts --json "$(cat artikel.txt)" | npx tsx agenten/llama-writer.ts --from-report
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import type { VerificationReport } from "./llama-verify-article.js";
import {
findJobDir,
loadJobFile,
saveJobFile,
updateJobMeta,
} from "../lib/jobs.js";
import { createLogger, nullLogger, type Logger } from "../lib/logger.js";
// ---------------------------------------------------------------------------
// Typen
// ---------------------------------------------------------------------------
type Style = "journalistic" | "blog" | "academic" | "editorial" | "explanatory";
type ArticleDraft = {
schema_version: "1.0.0";
title: string;
lead: string;
body: string;
conclusion: string | null;
style: Style;
language: string;
word_count: number;
claim_ids_used: string[];
sources: Array<{ number: number; url: string; title: string | null; claim_id: string }>;
excluded_claims: string[];
editorial_notes: string;
};
// llama.cpp OpenAI-kompatibles API-Format
type LlamaResponse = {
choices: Array<{
message?: { content?: string; reasoning_content?: string };
finish_reason?: string;
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
};
};
export type WriteResult = {
draft: ArticleDraft;
provider: "llama";
model: string;
costUSD: 0;
latencyMs: number;
};
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf";
const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000";
const MAX_TOKENS = 16384;
const TEMPERATURE = 0.4;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 15_000;
// ---------------------------------------------------------------------------
// Schema + Prompt-Generierung
// ---------------------------------------------------------------------------
const STYLE_GUIDE: Record<Style, string> = {
journalistic:
"Journalistisch: präzise, faktenbasiert, W-Fragen im Einleitungssatz, Inverted Pyramid, " +
"zitierbare Aussagen direkt belegt, keine Meinungen ohne Kennzeichnung.",
blog:
"Blog: zugänglich, ansprechend, erste Person erlaubt, direkte Ansprache des Lesers, " +
"lebendige Sprache, Zwischenüberschriften als Orientierung.",
academic:
"Akademisch: präzise Terminologie, passive Formulierungen, klare Abschnittsstruktur " +
"(Einleitung, Hauptteil, Schluss), Quellenverweise inline.",
editorial:
"Leitartikel: klare Haltung, argumentativ, Bezug zur aktuellen Debatte, " +
"stützt sich auf Fakten aber formuliert Bewertung.",
explanatory:
"Erklärstück: vereinfacht komplexe Sachverhalte, Analogien und Beispiele, " +
"schrittweise Struktur, Leserfragen antizipieren.",
};
function buildWriterSystemPrompt(style: Style, language: string, wordCount: number): string {
const langName = language === "de" ? "Deutsch" : language === "en" ? "Englisch" : language;
return `Du bist ein erfahrener Autor. Schreibe einen Artikel nach folgenden Vorgaben:
STIL: ${STYLE_GUIDE[style]}
SPRACHE: ${langName}
LÄNGE: ca. ${wordCount} Wörter
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt gemäß folgendem Schema:
{
"title": "Artikeltitel (string)",
"lead": "Einleitungsabsatz (string)",
"body": "Haupttext mit Quellenangaben [N] (string)",
"conclusion": "Schlussabsatz oder null",
"editorial_notes": "Was fehlt für einen vollständigen Artikel? (string)"
}
REGELN:
- Alle Felder required: title, lead, body, conclusion, editorial_notes
- conclusion darf null sein
- Verwende NUR die vom Nutzer übergebenen verifizierten Claims als Faktengrundlage
- Kennzeichne jeden Fakt mit Inline-Quellenverweisen [N] aus der Beleg-Liste
- Erfinde keine Fakten, Zahlen oder Zitate
- Kein Freitext vor oder nach dem JSON-Objekt`;
}
type ClaimForWriting = {
id: string;
text: string;
sources: Array<{ url: string; title: string | null }>;
};
function buildWriterUserPrompt(claims: ClaimForWriting[], topic: string): string {
const claimsText = claims
.map((c, i) => {
const srcList = c.sources
.map((s, j) => `[${i * 10 + j + 1}] ${s.title ?? s.url} (${s.url})`)
.join("\n ");
return `Claim ${c.id}: ${c.text}\n Belege:\n ${srcList || "(keine URL)"}`;
})
.join("\n\n");
return `/no_think\nSchreibe einen Artikel zum Thema: "${topic}"\n\nVERIFIZIERTE FAKTEN (nur diese dürfen verwendet werden):\n${claimsText}`;
}
// ---------------------------------------------------------------------------
// llama.cpp-Aufruf
// ---------------------------------------------------------------------------
async function writeWithLlama(
claims: ClaimForWriting[],
style: Style,
topic: string,
wordCount: number,
language: string,
model: string,
signal?: AbortSignal,
logger?: Logger
): Promise<{ raw: Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">; tokensIn: number; tokensOut: number; latencyMs: number }> {
const log = logger ?? nullLogger;
const t0 = Date.now();
const body = {
model,
messages: [
{ role: "system", content: buildWriterSystemPrompt(style, language, wordCount) },
{ role: "user", content: buildWriterUserPrompt(claims, topic) },
],
stream: false,
temperature: TEMPERATURE,
max_tokens: MAX_TOKENS,
};
log.debug("llama.cpp-Writer gestartet", { model, claimCount: claims.length, style, language, wordCount });
let resp: Response | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
break;
} catch (err) {
const isLast = attempt === MAX_RETRIES;
log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, {
error: err instanceof Error ? err.message : String(err),
retryInMs: isLast ? 0 : RETRY_DELAY_MS,
});
if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`);
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
if (!resp!.ok) {
const errorText = await resp!.text().catch(() => "");
throw new Error(`llama.cpp API Fehler ${resp!.status}: ${errorText}`);
}
const data = (await resp!.json()) as LlamaResponse;
const choice = data.choices?.[0];
let raw = choice?.message?.content ?? "";
// Reasoning-Fallback: Wenn content leer, JSON aus reasoning_content extrahieren
if (!raw.trim() && choice?.message?.reasoning_content) {
const rc = choice.message.reasoning_content;
const allMatches = [...rc.matchAll(/\{[^{}]*"title"\s*:/g)];
const lastBlock = allMatches.length > 0
? rc.match(/\{[\s\S]*"title"[\s\S]*\}/)?.[0]
: undefined;
if (lastBlock) {
raw = lastBlock;
log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", {
finishReason: choice.finish_reason,
rawLength: raw.length,
});
}
}
// Markdown-Codeblöcke entfernen
const cleanedRaw = raw
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
log.debug("llama.cpp-Writer Antwort", {
promptTokens: data.usage?.prompt_tokens,
outputTokens: data.usage?.completion_tokens,
finishReason: choice?.finish_reason,
rawLength: cleanedRaw.length,
});
if (!cleanedRaw) throw new Error("Leere Antwort von llama.cpp-Writer");
let parsed: unknown;
try {
parsed = JSON.parse(cleanedRaw);
} catch {
throw new Error(`llama.cpp-Writer-Ausgabe ist kein gültiges JSON: ${cleanedRaw.slice(0, 200)}`);
}
const p = parsed as Record<string, unknown>;
if (typeof p.title !== "string" || typeof p.body !== "string") {
throw new Error(`Ungültige Struktur: 'title' oder 'body' fehlt. Keys: ${Object.keys(p).join(", ")}`);
}
return {
raw: p as Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">,
tokensIn: data.usage?.prompt_tokens ?? 0,
tokensOut: data.usage?.completion_tokens ?? 0,
latencyMs: Date.now() - t0,
};
}
// ---------------------------------------------------------------------------
// Quellenverzeichnis aufbauen
// ---------------------------------------------------------------------------
function buildSourceIndex(claims: ClaimForWriting[]): Array<{ number: number; url: string; title: string | null; claim_id: string }> {
const sources: Array<{ number: number; url: string; title: string | null; claim_id: string }> = [];
let n = 1;
for (const c of claims) {
for (const s of c.sources) {
sources.push({ number: n++, url: s.url, title: s.title, claim_id: c.id });
}
}
return sources;
}
// ---------------------------------------------------------------------------
// Hauptfunktion
// ---------------------------------------------------------------------------
export async function writeFromReport(
report: VerificationReport,
options?: {
style?: Style;
topic?: string;
wordCount?: number;
language?: string;
model?: string;
signal?: AbortSignal;
logger?: Logger;
}
): Promise<WriteResult> {
const log = options?.logger ?? nullLogger;
const style = options?.style ?? "journalistic";
const wordCount = options?.wordCount ?? 400;
const language = options?.language ?? "de";
const model = options?.model ?? DEFAULT_MODEL;
const supported = report.results.filter((r) => r.status === "supported");
const excluded = report.results.filter((r) => r.status !== "supported").map((r) => r.claim_id);
if (supported.length === 0) {
throw new Error("Keine verifizierten (supported) Claims im Report — kein Artikel möglich.");
}
const topic = options?.topic ?? report.source_text_summary ?? "Artikel";
const claims: ClaimForWriting[] = supported.map((r) => ({
id: r.claim_id,
text: r.claim_text,
sources: r.sources
.filter((s) => s.supports_claim)
.map((s) => ({ url: s.url, title: s.title })),
}));
log.info(`llama.cpp-Writer: ${claims.length} Claims, Stil: ${style}, Sprache: ${language}, Ziel: ${wordCount} Wörter`);
const result = await writeWithLlama(claims, style, topic, wordCount, language, model, options?.signal, log);
const sources = buildSourceIndex(claims);
const wordCountActual = (result.raw.lead + " " + result.raw.body + " " + (result.raw.conclusion ?? ""))
.split(/\s+/).filter(Boolean).length;
const draft: ArticleDraft = {
...result.raw,
schema_version: "1.0.0" as const,
style,
language,
word_count: wordCountActual,
claim_ids_used: claims.map((c) => c.id),
sources,
excluded_claims: excluded,
editorial_notes: result.raw.editorial_notes ?? "",
};
return { draft, provider: "llama", model, costUSD: 0, latencyMs: result.latencyMs };
}
// ---------------------------------------------------------------------------
// Formatierung
// ---------------------------------------------------------------------------
export function formatDraft(result: WriteResult): string {
const { draft } = result;
const lines: string[] = [];
lines.push(`# ${draft.title}`);
lines.push("");
lines.push(`_${draft.lead}_`);
lines.push("");
lines.push(draft.body);
if (draft.conclusion) {
lines.push("");
lines.push("---");
lines.push(draft.conclusion);
}
if (draft.sources.length > 0) {
lines.push("\n**Quellen:**");
draft.sources.forEach((s) => {
const title = s.title ?? s.url;
lines.push(`[${s.number}] [${title}](${s.url})`);
});
}
if (draft.excluded_claims.length > 0) {
lines.push(`\n_${draft.excluded_claims.length} Claim(s) ausgeschlossen (nicht verifiziert): ${draft.excluded_claims.join(", ")}_`);
}
if (draft.editorial_notes) {
lines.push(`\n**Redaktionshinweise:** ${draft.editorial_notes}`);
}
const latSec = (result.latencyMs / 1000).toFixed(1);
lines.push(`\n_[llama.cpp: ${result.model} · ${draft.word_count} Wörter · kostenlos (lokal) · ${latSec}s]_`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pi-Extension
// ---------------------------------------------------------------------------
const PARAMS = Type.Object({
reportJson: Type.String({
description:
"JSON-String eines VerificationReport (Ausgabe von verify_article_llama --json oder verify_article_llama). " +
"Nur 'supported'-Claims werden für den Artikel verwendet.",
}),
topic: Type.Optional(
Type.String({ description: "Artikelthema / Überschrift. Standard: wird aus dem Report abgeleitet." })
),
style: Type.Optional(
Type.Union(
[
Type.Literal("journalistic"),
Type.Literal("blog"),
Type.Literal("academic"),
Type.Literal("editorial"),
Type.Literal("explanatory"),
],
{ description: "Schreibstil. Standard: journalistic." }
)
),
wordCount: Type.Optional(
Type.Number({ description: "Ziel-Wortanzahl. Standard: 400." })
),
language: Type.Optional(
Type.String({ description: "Sprache (ISO 639-1). Standard: de." })
),
model: Type.Optional(
Type.String({ description: "llama.cpp-Modell-Override." })
),
});
export default function llamaWriterExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "write_article_llama",
label: "Artikel schreiben (llama.cpp)",
description:
"Schreibt einen Artikel ausschließlich auf Basis verifizierter Claims aus einem VerificationReport. " +
"Verwendet llama.cpp lokal (kostenlos, kein Ollama-Timeout bei Thinking-Modellen). " +
"BEVORZUGT gegenüber write_article (Ollama). " +
"Workflow: verify_article_llama → write_article_llama.",
promptGuidelines: [
"PREFERRED: Use write_article_llama for all article generation (local, free, no timeout issues).",
"Use write_article (Ollama) only when explicitly requested by the user.",
"Always pass the full JSON output of verify_article or verify_article_llama as 'reportJson'.",
"Ask the user for the desired style (journalistic, blog, academic, editorial, explanatory) if not specified.",
"Show the full formatted draft including sources and editorial notes.",
"Point out excluded claims to the user — these may be important context that was removed.",
"If editorial_notes mention missing information, suggest running additional research.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
try {
const report = JSON.parse(params.reportJson) as VerificationReport;
const result = await writeFromReport(report, {
style: params.style,
topic: params.topic,
wordCount: params.wordCount,
language: params.language,
model: params.model,
signal,
});
return {
content: [{ type: "text", text: formatDraft(result) }],
details: {
wordCount: result.draft.word_count,
claimsUsed: result.draft.claim_ids_used.length,
claimsExcluded: result.draft.excluded_claims.length,
provider: result.provider,
latencyMs: result.latencyMs,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
return { content: [{ type: "text", text: `Artikelgenerierung fehlgeschlagen: ${msg}` }] };
}
},
});
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help") {
console.log(`
Llama-Writer Schreibt Artikel via llama.cpp auf Basis verifizierter Claims
Verwendung:
# Via Job-Speicher (empfohlen):
npx tsx agenten/llama-verify-article.ts --job-id umerziehung "$(cat artikel.txt)"
npx tsx agenten/llama-writer.ts --from-job umerziehung --style blog
# Via Pipe:
npx tsx agenten/llama-verify-article.ts --json "..." | npx tsx agenten/llama-writer.ts --from-report
Optionen:
--from-report Lese VerificationReport von stdin (JSON)
--from-job <slug> Lese report.json aus Job ~/.pi/agent/jobs/<datum>_<slug>/
Speichert article.md automatisch zurück in den Job
--style <s> journalistic|blog|academic|editorial|explanatory (Standard: journalistic)
--topic <text> Artikelthema
--words <n> Ziel-Wortanzahl (Standard: 400)
--lang <code> Sprache (Standard: de)
--model <name> Modell-Override (Standard: ${DEFAULT_MODEL})
--json Ausgabe als JSON
--verbose Ausführliches Logging
--help Diese Hilfe
`);
process.exit(0);
}
let fromReport = false;
let fromJobSlug: string | undefined;
let style: Style = "journalistic";
let topic: string | undefined;
let wordCount = 400;
let language = "de";
let model: string | undefined;
let jsonOutput = false;
let verbose = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--from-report") fromReport = true;
else if (arg === "--from-job" && args[i + 1]) fromJobSlug = args[++i];
else if (arg === "--style" && args[i + 1]) style = args[++i] as Style;
else if (arg === "--topic" && args[i + 1]) topic = args[++i];
else if (arg === "--words" && args[i + 1]) wordCount = parseInt(args[++i], 10);
else if (arg === "--lang" && args[i + 1]) language = args[++i];
else if (arg === "--model" && args[i + 1]) model = args[++i];
else if (arg === "--json") jsonOutput = true;
else if (arg === "--verbose") verbose = true;
}
const logger = verbose ? createLogger({ verbose: true }) : nullLogger;
let report: VerificationReport;
let jobDir: string | undefined;
if (fromJobSlug) {
const dir = findJobDir(fromJobSlug);
if (!dir) {
console.error(`Fehler: Kein Job mit Slug "${fromJobSlug}" gefunden in ~/.pi/agent/jobs/`);
console.error("Tipp: Zuerst llama-verify-article.ts --job-id <slug> ausführen.");
process.exit(1);
}
jobDir = dir;
const loaded = loadJobFile<VerificationReport>(dir, "report.json");
if (!loaded) {
console.error(`Fehler: Kein report.json in Job ${dir}`);
console.error("Tipp: llama-verify-article.ts --job-id <slug> muss zuerst abgeschlossen werden.");
process.exit(1);
}
report = loaded;
if (!jsonOutput) console.error(`\nJob: ${dir}\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`);
} else if (fromReport) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
const input = Buffer.concat(chunks).toString("utf-8").trim();
if (!input) { console.error("Fehler: Kein Input von stdin."); process.exit(1); }
report = JSON.parse(input) as VerificationReport;
if (!jsonOutput) console.error(`\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`);
} else {
console.error("Fehler: --from-report oder --from-job <slug> erforderlich.");
process.exit(1);
}
try {
const result = await writeFromReport(report, { style, topic, wordCount, language, model, logger });
if (jobDir) {
saveJobFile(jobDir, "article.md", formatDraft(result));
updateJobMeta(jobDir, {
status: "completed",
steps: {
write: {
completedAt: new Date().toISOString(),
style,
wordCount: result.draft.word_count,
provider: result.provider,
costUSD: 0,
},
},
});
if (!jsonOutput) process.stderr.write(`\n Artikel in Job gespeichert: ${jobDir}/article.md\n`);
}
console.log(jsonOutput ? JSON.stringify(result.draft, null, 2) : formatDraft(result));
} 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();

View file

@ -0,0 +1,697 @@
/**
* ollama-claim-extractor.ts
* Pi-Extension + CLI: Einzelbehauptungen aus Texten extrahieren via lokalem Ollama
*
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ollama-claim-extractor.ts
* Nach Änderungen in Pi: /reload
*
* Als CLI:
* npx tsx agenten/ollama-claim-extractor.ts "Textinhalt..."
* npx tsx agenten/ollama-claim-extractor.ts --only-checkable "Textinhalt..."
* npx tsx agenten/ollama-claim-extractor.ts --model qwen3.5:27b "Textinhalt..."
* npx tsx agenten/ollama-claim-extractor.ts --json "Textinhalt..." (nur JSON-Ausgabe)
*
* Modell-Empfehlung: qwen3.5:9b (6.6GB, 1 GPU, fast gleiche Präzision wie 27B, 2× schneller)
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { createLogger, nullLogger, type Logger } from "../lib/logger.js";
// ---------------------------------------------------------------------------
// Typen
// ---------------------------------------------------------------------------
export type ClaimType = "fact" | "causal" | "statistical" | "quote" | "prediction" | "opinion";
export type Checkability = "checkable" | "partly_checkable" | "not_checkable";
export type Claim = {
claim_id: string;
text: string;
claim_type: ClaimType;
checkability: Checkability;
needs_citation: boolean;
entities: string[];
time_scope: string | null;
source_sentence: string;
};
export type ClaimSet = {
schema_version: "1.0.0";
text_language: string;
extraction_notes: string;
total_claims: number;
claims: Claim[];
};
type OllamaResponse = {
message?: { content?: string };
done?: boolean;
eval_count?: number;
prompt_eval_count?: number;
};
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const DEFAULT_MODEL = "qwen3.5:9b";
const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434";
const DEFAULT_MAX_CLAIMS = 40;
const TEMPERATURE = 0.1;
const NUM_CTX = 8192;
// Texte über diesem Schwellenwert werden in Chunks aufgeteilt (Zeichen)
// 8192 Tokens Kontext: ~3000 Zeichen Input + ~1000 Prompt-Overhead + ~3200 Tokens Output (40 Claims)
const CHUNK_THRESHOLD = 4000;
const CHUNK_SIZE = 3000;
// ---------------------------------------------------------------------------
// JSON-Schema für Ollama structured output
// (Teilmenge von claim.schema.json — ohne Pattern-Constraint, da Ollama
// reguläre Ausdrücke im format-Parameter nicht immer unterstützt)
// ---------------------------------------------------------------------------
export const CLAIM_OLLAMA_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
schema_version: { type: "string" },
text_language: { type: "string" },
extraction_notes: { type: "string" },
total_claims: { type: "integer" },
claims: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
claim_id: { type: "string" },
text: { type: "string" },
claim_type: {
type: "string",
enum: ["fact", "causal", "statistical", "quote", "prediction", "opinion"],
},
checkability: {
type: "string",
enum: ["checkable", "partly_checkable", "not_checkable"],
},
needs_citation: { type: "boolean" },
entities: { type: "array", items: { type: "string" } },
time_scope: { type: ["string", "null"] },
source_sentence: { type: "string" },
},
required: [
"claim_id",
"text",
"claim_type",
"checkability",
"needs_citation",
"entities",
"time_scope",
"source_sentence",
],
},
},
},
required: ["schema_version", "text_language", "extraction_notes", "total_claims", "claims"],
};
// ---------------------------------------------------------------------------
// System-Prompt
// ---------------------------------------------------------------------------
function buildSystemPrompt(maxClaims: number): string {
return `Du bist ein Experte für Faktenextraktion und Fact-Checking-Vorbereitung.
Deine Aufgabe: Analysiere den Text und extrahiere alle Behauptungen als diskrete, einzeln prüfbare Einheiten.
Extrahiere maximal ${maxClaims} Behauptungen. Bei sehr langen Texten priorisiere die wichtigsten und prüfbarsten.
REGELN für die Extraktion:
- Formuliere jede Behauptung als eigenständigen, vollständigen Satz (nicht als Fragment)
- Behalte den Sinn der Originalformulierung bei, mache Behauptungen aber selbstständig lesbar
- claim_id: fortlaufend "c001", "c002", "c003", ...
CLAIM TYPES:
- fact: Konkrete Tatsachenbehauptung ("X ist Y", "X hat Z getan")
- causal: Kausalbehauptung ("X hat zu Y geführt", "wegen X passiert Y")
- statistical: Zahlen, Prozentwerte, Statistiken, Rankings
- quote: Wörtliches oder indirektes Zitat einer Person
- prediction: Prognose, Vorhersage, Erwartung über Zukunftsereignisse
- opinion: Wertung, Meinung, normative Aussage (gut/schlecht/sollte)
CHECKABILITY:
- checkable: Empirisch überprüfbar durch Primärquellen, Datenbanken, offizielle Stellen
- partly_checkable: Nur teilweise prüfbar (z.B. enthält sowohl Fakt als auch Wertung)
- not_checkable: Reine Meinung, reine Prognose, Werturteil ohne Tatsachenkern
NEEDS_CITATION: true wenn Zahlen, spezifische Fakten, Zitate oder Studienergebnisse vorhanden
ENTITIES: Alle benannten Entitäten: Personen, Organisationen, Länder, Institutionen, Produkte, konkrete Daten
TIME_SCOPE: Zeitrahmen wenn angegeben (z.B. "2024", "Q1 2025", "seit 1990"), sonst null
SOURCE_SENTENCE: Der originale Satz aus dem Quelltext (wörtlich, max. 200 Zeichen)
DUPLIKATE: Extrahiere jeden Sachverhalt nur einmal. Wenn derselbe Fakt im Text mehrfach vorkommt (z.B. als Einleitung und später als Detail), erstelle nur einen Claim dafür.
Antworte NUR mit dem JSON-Objekt gemäß Schema. Kein Freitext davor oder danach.`;
}
// ---------------------------------------------------------------------------
// Text-Chunking für lange Texte
// ---------------------------------------------------------------------------
/**
* Teilt langen Text an Absatzgrenzen in Stücke von max. CHUNK_SIZE Zeichen.
* Absätze werden nicht aufgetrennt bei Absätzen > CHUNK_SIZE werden sie allein übergeben.
*/
function splitIntoChunks(text: string): string[] {
const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 0);
const chunks: string[] = [];
let current = "";
for (const para of paragraphs) {
if (current.length + para.length + 2 > CHUNK_SIZE && current.length > 0) {
chunks.push(current.trim());
current = para;
} else {
current = current ? current + "\n\n" + para : para;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
/**
* Entfernt doppelte Claims (gleicher text-Inhalt nach Normalisierung).
*/
function deduplicateClaims(claims: Claim[]): Claim[] {
const seen = new Set<string>();
return claims.filter((c) => {
const key = c.text.toLowerCase().replace(/\s+/g, " ").trim();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// ---------------------------------------------------------------------------
// Ollama-Aufruf
// ---------------------------------------------------------------------------
export async function callOllamaClaimExtract(
text: string,
model: string,
maxClaims: number,
signal?: AbortSignal,
logger?: Logger
): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> {
const log = logger ?? nullLogger;
// Langen Text in Chunks aufteilen
if (text.length > CHUNK_THRESHOLD) {
log.info("Text zu lang für Single-Pass — Chunking aktiv", { textLength: text.length, threshold: CHUNK_THRESHOLD });
return callOllamaClaimExtractChunked(text, model, maxClaims, signal, log);
}
log.debug("Single-Pass Extraktion", { textLength: text.length, model, maxClaims });
return callOllamaClaimExtractSingle(text, model, maxClaims, signal, log);
}
async function callOllamaClaimExtractChunked(
text: string,
model: string,
maxClaims: number,
signal?: AbortSignal,
logger?: Logger
): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> {
const log = logger ?? nullLogger;
const t0 = Date.now();
const chunks = splitIntoChunks(text);
const claimsPerChunk = Math.ceil(maxClaims / chunks.length);
log.info(`Text in ${chunks.length} Chunks aufgeteilt`, {
chunks: chunks.length,
claimsPerChunk,
chunkLengths: chunks.map((c) => c.length),
});
let totalIn = 0;
let totalOut = 0;
const allClaims: Claim[] = [];
let language = "de";
const notes: string[] = [];
for (let i = 0; i < chunks.length; i++) {
log.info(`Chunk ${i + 1}/${chunks.length} extrahieren...`, { chunkLength: chunks[i].length, claimsPerChunk });
const result = await callOllamaClaimExtractSingle(chunks[i], model, claimsPerChunk, signal, log);
log.info(`Chunk ${i + 1}/${chunks.length} fertig`, {
claims: result.claimSet.claims.length,
tokensIn: result.tokensIn,
tokensOut: result.tokensOut,
latencyMs: result.latencyMs,
});
allClaims.push(...result.claimSet.claims);
totalIn += result.tokensIn;
totalOut += result.tokensOut;
language = result.claimSet.text_language;
if (result.claimSet.extraction_notes) notes.push(result.claimSet.extraction_notes);
}
// Deduplizieren und neu nummerieren
const beforeDedup = allClaims.length;
const unique = deduplicateClaims(allClaims).slice(0, maxClaims);
const renumbered: Claim[] = unique.map((c, i) => ({
...c,
claim_id: `c${String(i + 1).padStart(3, "0")}`,
}));
log.info("Chunking abgeschlossen", {
totalBeforeDedup: beforeDedup,
afterDedup: renumbered.length,
totalTokensIn: totalIn,
totalTokensOut: totalOut,
totalLatencyMs: Date.now() - t0,
});
return {
claimSet: {
schema_version: "1.0.0",
text_language: language,
extraction_notes: `Text in ${chunks.length} Abschnitte aufgeteilt. ${notes.filter(Boolean).join(" ")}`,
total_claims: renumbered.length,
claims: renumbered,
},
tokensIn: totalIn,
tokensOut: totalOut,
latencyMs: Date.now() - t0,
};
}
async function callOllamaClaimExtractSingle(
text: string,
model: string,
maxClaims: number,
signal?: AbortSignal,
logger?: Logger
): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> {
const log = logger ?? nullLogger;
const t0 = Date.now();
const body = {
model,
messages: [
{
role: "system",
content: buildSystemPrompt(maxClaims),
},
{
role: "user",
content: `Extrahiere alle Behauptungen aus folgendem Text:\n\n---\n${text}\n---`,
},
],
format: CLAIM_OLLAMA_SCHEMA,
stream: false,
options: {
temperature: TEMPERATURE,
num_ctx: NUM_CTX,
},
};
log.debug("Ollama-Aufruf gestartet", { model, textLength: text.length, num_ctx: NUM_CTX });
// Retry bei temporären Verbindungsfehlern (Ollama startet kurz neu oder ist kurz ausgelastet)
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 15_000; // 15s Pause vor Retry
let lastError: unknown;
let resp: Response | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
resp = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
break; // Verbindung erfolgreich
} catch (err) {
lastError = err;
const isLast = attempt === MAX_RETRIES;
log.warn(`Ollama fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, {
error: err instanceof Error ? err.message : String(err),
retryInMs: isLast ? 0 : RETRY_DELAY_MS,
});
if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`);
// Warten bevor Retry — Ollama könnte kurz neu starten
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
if (!resp!.ok) {
const errorText = await resp!.text().catch(() => "");
log.error("Ollama API Fehler", { status: resp!.status, body: errorText.slice(0, 200) });
throw new Error(`Ollama API Fehler ${resp!.status}: ${errorText}`);
}
const data = (await resp!.json()) as OllamaResponse;
const raw = data.message?.content ?? "";
log.debug("Ollama-Antwort empfangen", {
promptTokens: data.prompt_eval_count,
outputTokens: data.eval_count,
rawLength: raw.length,
});
if (!raw.trim()) {
log.error("Leere Ollama-Antwort", { promptTokens: data.prompt_eval_count });
throw new Error("Leere Antwort von Ollama erhalten");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
log.error("JSON-Parse-Fehler", { rawPreview: raw.slice(0, 200) });
throw new Error(`Ollama-Ausgabe ist kein gültiges JSON: ${raw.slice(0, 200)}`);
}
// Grundlegende Strukturprüfung (kein vollständiger Schema-Validator)
const p = parsed as Record<string, unknown>;
if (!Array.isArray(p.claims)) {
log.error("Ungültige Struktur: claims fehlt", { keys: Object.keys(p) });
throw new Error(`Ungültige Struktur: 'claims' fehlt oder ist kein Array`);
}
if ((p.claims as unknown[]).length === 0) {
// Leere Claims deuten auf Kontext-Overflow oder Modell-Fehler hin
const usedCtx = data.prompt_eval_count ?? 0;
log.warn("0 Claims extrahiert", { promptTokens: usedCtx, num_ctx: NUM_CTX, textLength: text.length });
throw new Error(
`Ollama hat 0 Claims extrahiert (prompt_tokens=${usedCtx}). ` +
`Text zu lang für num_ctx=${NUM_CTX} oder Modell-Fehler.`
);
}
const claimSet: ClaimSet = {
schema_version: "1.0.0",
text_language: typeof p.text_language === "string" ? p.text_language : "unknown",
extraction_notes: typeof p.extraction_notes === "string" ? p.extraction_notes : "",
total_claims: typeof p.total_claims === "number" ? p.total_claims : (p.claims as unknown[]).length,
claims: p.claims as Claim[],
};
return {
claimSet,
tokensIn: data.prompt_eval_count ?? 0,
tokensOut: data.eval_count ?? 0,
latencyMs: Date.now() - t0,
};
}
// ---------------------------------------------------------------------------
// Formatierung (Pi-Ausgabe + CLI-Ausgabe)
// ---------------------------------------------------------------------------
const TYPE_LABEL: Record<ClaimType, string> = {
fact: "FAKT",
causal: "KAUSAL",
statistical: "STATISTIK",
quote: "ZITAT",
prediction: "PROGNOSE",
opinion: "MEINUNG",
};
const CHECK_ICON: Record<Checkability, string> = {
checkable: "✓",
partly_checkable: "~",
not_checkable: "✗",
};
function formatClaimSet(
claimSet: ClaimSet,
onlyCheckable: boolean,
model: string,
tokensIn: number,
tokensOut: number,
latencyMs: number
): string {
const filtered = onlyCheckable
? claimSet.claims.filter((c) => c.checkability === "checkable")
: claimSet.claims;
const checkable = filtered.filter((c) => c.checkability === "checkable");
const partlyCheckable = filtered.filter((c) => c.checkability === "partly_checkable");
const notCheckable = filtered.filter((c) => c.checkability === "not_checkable");
const lines: string[] = [];
lines.push(
`## Claim-Extraktion: ${claimSet.total_claims} Behauptung${claimSet.total_claims !== 1 ? "en" : ""} gefunden` +
(onlyCheckable && filtered.length < claimSet.total_claims
? ` (${filtered.length} prüfbar angezeigt)`
: "")
);
lines.push(`Sprache: ${claimSet.text_language}`);
if (claimSet.extraction_notes) {
lines.push(`Hinweis: ${claimSet.extraction_notes}`);
}
lines.push("");
function renderClaims(claims: Claim[], sectionTitle: string) {
if (claims.length === 0) return;
lines.push(`**${sectionTitle} (${claims.length}):**`);
for (const c of claims) {
const icon = CHECK_ICON[c.checkability];
const type = TYPE_LABEL[c.claim_type];
lines.push(`\`${c.claim_id}\` ${icon} [${type}] ${c.text}`);
const meta: string[] = [];
if (c.entities.length > 0) meta.push(`Entitäten: ${c.entities.join(", ")}`);
if (c.time_scope) meta.push(`Zeit: ${c.time_scope}`);
if (c.needs_citation) meta.push(`Zitat nötig: ja`);
if (meta.length > 0) {
lines.push(` ${meta.join(" | ")}`);
}
lines.push("");
}
}
renderClaims(checkable, "✓ Prüfbar");
if (!onlyCheckable) {
renderClaims(partlyCheckable, "~ Teilweise prüfbar");
renderClaims(notCheckable, "✗ Nicht prüfbar");
}
const latSec = (latencyMs / 1000).toFixed(1);
const tokenInfo =
tokensIn || tokensOut ? ` · ${tokensIn}+${tokensOut} Tokens` : "";
lines.push(`_[Ollama: ${model}${tokenInfo} · ${latSec}s]_`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pi-Extension-Parameters (TypeBox)
// ---------------------------------------------------------------------------
const PARAMS = Type.Object({
text: Type.String({
description:
"Der zu analysierende Text. Kann ein Artikel, Blogeintrag, Nachrichtentext oder beliebiger Fließtext sein.",
}),
onlyCheckable: Type.Optional(
Type.Boolean({
description:
"Wenn true: nur empirisch prüfbare Claims ausgeben (checkable). Standard: false.",
})
),
maxClaims: Type.Optional(
Type.Number({
description: `Maximale Anzahl Claims pro Aufruf. Standard: ${DEFAULT_MAX_CLAIMS}.`,
})
),
model: Type.Optional(
Type.String({
description: `Ollama-Modell für die Extraktion. Standard: ${DEFAULT_MODEL}. Empfohlene Alternative: qwen3.5:27b für maximale Präzision.`,
})
),
});
// ---------------------------------------------------------------------------
// Pi-Extension: Default Export
// ---------------------------------------------------------------------------
export default function claimExtractorExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "extract_claims",
label: "Claim-Extraktion",
description:
"Zerlegt einen Text in einzelne, diskrete Behauptungen (Claims) als Vorbereitung für Fact-Checking. " +
"Nutze dieses Tool wenn: ein Artikel auf Fakten geprüft werden soll, Behauptungen aus einem Text " +
"identifiziert und klassifiziert werden sollen, oder ein Verifikations-Workflow gestartet werden soll. " +
"Läuft lokal via Ollama — keine API-Kosten.",
promptGuidelines: [
"Use extract_claims when the user wants to fact-check an article, blog post, or any text.",
"Use extract_claims before calling verify or research_web on specific claims.",
"Pass the full text as the 'text' parameter — do not summarize or shorten it first.",
"If the user only wants checkable claims, set onlyCheckable=true.",
"After extraction, ask the user which claims they want to verify, or offer to run the verifier on all checkable claims.",
"The claim_ids (c001, c002, ...) can be referenced in follow-up tool calls to the verifier.",
"Always show the full formatted output to the user, including the [Ollama: ...] cost line.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
const model = params.model ?? DEFAULT_MODEL;
const maxClaims = Math.min(params.maxClaims ?? DEFAULT_MAX_CLAIMS, 60);
const onlyCheckable = params.onlyCheckable ?? false;
try {
const { claimSet, tokensIn, tokensOut, latencyMs } = await callOllamaClaimExtract(
params.text,
model,
maxClaims,
signal
);
const text = formatClaimSet(
claimSet,
onlyCheckable,
model,
tokensIn,
tokensOut,
latencyMs
);
return {
content: [{ type: "text", text }],
details: {
model,
totalClaims: claimSet.total_claims,
checkableClaims: claimSet.claims.filter((c) => c.checkability === "checkable").length,
textLanguage: claimSet.text_language,
tokensIn: tokensIn || null,
tokensOut: tokensOut || null,
latencyMs,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
return {
content: [{ type: "text", text: `Fehler bei Claim-Extraktion: ${msg}` }],
};
}
},
});
}
// ---------------------------------------------------------------------------
// CLI-Modus
// ---------------------------------------------------------------------------
function parseCliArgs(args: string[]): {
text: string;
model: string;
maxClaims: number;
onlyCheckable: boolean;
jsonOutput: boolean;
verbose: boolean;
} {
let model = DEFAULT_MODEL;
let maxClaims = DEFAULT_MAX_CLAIMS;
let onlyCheckable = false;
let jsonOutput = false;
let verbose = false;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--model" && args[i + 1]) {
model = args[++i];
} else if (arg === "--max-claims" && args[i + 1]) {
maxClaims = parseInt(args[++i], 10);
} else if (arg === "--only-checkable") {
onlyCheckable = true;
} else if (arg === "--json") {
jsonOutput = true;
} else if (arg === "--verbose" || arg === "-v") {
verbose = true;
} else if (!arg.startsWith("--")) {
textParts.push(arg);
}
}
const text = textParts.join(" ").trim();
return { text, model, maxClaims, onlyCheckable, jsonOutput, verbose };
}
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
console.log(`
Claim-Extraktor (Ollama) Behauptungen aus Text extrahieren
Verwendung:
npx tsx agenten/ollama-claim-extractor.ts [Optionen] "Text..."
Optionen:
--model <name> Ollama-Modell (Standard: ${DEFAULT_MODEL})
--max-claims <n> Maximale Claims (Standard: ${DEFAULT_MAX_CLAIMS})
--only-checkable Nur prüfbare Claims anzeigen
--json Ausgabe als reines JSON (ClaimSet)
--verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/
--help Diese Hilfe
Beispiele:
npx tsx agenten/ollama-claim-extractor.ts "Die Erde hat 8 Milliarden Einwohner."
npx tsx agenten/ollama-claim-extractor.ts --only-checkable "$(cat artikel.txt)"
npx tsx agenten/ollama-claim-extractor.ts --verbose "$(cat langer-artikel.txt)"
npx tsx agenten/ollama-claim-extractor.ts --model deepseek-r1:32b "..."
npx tsx agenten/ollama-claim-extractor.ts --json "..." > claims.json
`);
process.exit(0);
}
const { text, model, maxClaims, onlyCheckable, jsonOutput, verbose } = parseCliArgs(args);
if (!text) {
console.error("Fehler: Kein Text übergeben. Nutze --help für Hinweise.");
process.exit(1);
}
if (!jsonOutput) {
console.error(
`\nOllama-Modell: ${model} | Max. Claims: ${maxClaims} | Nur prüfbar: ${onlyCheckable}\n`
);
}
const log = createLogger({ verbose });
try {
const { claimSet, tokensIn, tokensOut, latencyMs } = await callOllamaClaimExtract(
text,
model,
maxClaims,
undefined,
log
);
if (jsonOutput) {
console.log(JSON.stringify(claimSet, null, 2));
} else {
console.log(
formatClaimSet(claimSet, onlyCheckable, model, tokensIn, tokensOut, latencyMs)
);
}
} catch (err) {
console.error("Fehler:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
// Einstiegspunkt für CLI — wird ignoriert wenn als Pi-Extension geladen
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] === __filename) {
runCli();
}

View file

@ -0,0 +1,567 @@
/**
* ollama-logic-editor.ts
* Pi-Extension + CLI: Argumentationsanalyse via Ollama (deepseek-r1:32b)
*
* Analysiert einen Text auf:
* - Hauptthese und Unterthesen
* - Explizite Prämissen und Belege
* - Schlussfolgerungen
* - Implizite Annahmen
* - Logische Fehlschlüsse (Ad Hominem, Strohmann, etc.)
* - Verbesserungsvorschläge
*
* Routing: deepseek-r1:32b lokal (Standard) oder OpenRouter (--cloud / high complexity)
* HINWEIS: analyze_logic_llama (llama-logic-editor.ts) bevorzugen für einheitliches Backend.
*
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink)
* Als CLI:
* npx tsx agenten/ollama-logic-editor.ts "Artikeltext..."
* npx tsx agenten/ollama-logic-editor.ts --cloud "Kontroverseller Kommentar..."
* npx tsx agenten/ollama-logic-editor.ts --json "$(cat kommentar.txt)"
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { routeModel, callOpenRouter, estimateOpenRouterCost } from "../lib/router.js";
// ---------------------------------------------------------------------------
// Typen
// ---------------------------------------------------------------------------
type FallacyType =
| "ad_hominem" | "straw_man" | "false_dichotomy" | "slippery_slope"
| "circular_reasoning" | "appeal_to_authority" | "hasty_generalization"
| "false_causation" | "appeal_to_emotion" | "overgeneralization"
| "cherry_picking" | "other";
type Severity = "minor" | "moderate" | "critical";
type EvidenceStrength = "strong" | "moderate" | "weak";
type OverallQuality = "strong" | "adequate" | "weak" | "flawed";
type ArgumentMap = {
schema_version: "1.0.0";
thesis: string;
sub_theses: string[];
premises: string[];
evidence: Array<{ claim: string; supports_thesis: boolean; strength: EvidenceStrength }>;
conclusions: string[];
implicit_assumptions: string[];
fallacies: Array<{
type: FallacyType;
description: string;
location: string;
severity: Severity;
}>;
revision_suggestions: string[];
overall_quality: OverallQuality;
quality_notes: string;
};
type OllamaResponse = {
message?: { content?: string };
eval_count?: number;
prompt_eval_count?: number;
};
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434";
// ---------------------------------------------------------------------------
// Ollama-Schema für strukturierte Argumentationsanalyse
// ---------------------------------------------------------------------------
const ARGUMENT_MAP_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
thesis: { type: "string" },
sub_theses: { type: "array", items: { type: "string" } },
premises: { type: "array", items: { type: "string" } },
evidence: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
claim: { type: "string" },
supports_thesis: { type: "boolean" },
strength: { type: "string", enum: ["strong", "moderate", "weak"] },
},
required: ["claim", "supports_thesis", "strength"],
},
},
conclusions: { type: "array", items: { type: "string" } },
implicit_assumptions: { type: "array", items: { type: "string" } },
fallacies: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
type: {
type: "string",
enum: [
"ad_hominem", "straw_man", "false_dichotomy", "slippery_slope",
"circular_reasoning", "appeal_to_authority", "hasty_generalization",
"false_causation", "appeal_to_emotion", "overgeneralization",
"cherry_picking", "other",
],
},
description: { type: "string" },
location: { type: "string" },
severity: { type: "string", enum: ["minor", "moderate", "critical"] },
},
required: ["type", "description", "location", "severity"],
},
},
revision_suggestions: { type: "array", items: { type: "string" } },
overall_quality: { type: "string", enum: ["strong", "adequate", "weak", "flawed"] },
quality_notes: { type: "string" },
},
required: [
"thesis", "sub_theses", "premises", "evidence", "conclusions",
"implicit_assumptions", "fallacies", "revision_suggestions",
"overall_quality", "quality_notes",
],
};
// ---------------------------------------------------------------------------
// System-Prompt
// ---------------------------------------------------------------------------
const LOGIC_SYSTEM_PROMPT = `Du bist ein Experte für kritisches Denken, Rhetorik und formale Logik.
Antworte ausschließlich auf Deutsch.
Analysiere den folgenden Text auf seine Argumentationsstruktur.
Extrahiere:
1. thesis: Die zentrale Hauptbehauptung als vollständiger Satz
2. sub_theses: Untergeordnete Thesen die die Hauptthese stützen
3. premises: Ausdrücklich genannte Voraussetzungen und Grundannahmen
4. evidence: Verwendete Belege (Fakten, Statistiken, Zitate, Studien) beachte ob sie die These wirklich stützen
5. conclusions: Explizite Schlussfolgerungen die aus den Prämissen gezogen werden
6. implicit_assumptions: Nicht ausgesprochene Annahmen die das Argument voraussetzt
Fehlschluss-Typen:
- ad_hominem: Person statt Argument angegriffen
- straw_man: Gegnerposition verzerrt dargestellt
- false_dichotomy: Falsche Zweiteilung (nur A oder B, obwohl mehr möglich)
- slippery_slope: Kettenreaktion ohne Beleg
- circular_reasoning: These wird durch sich selbst begründet
- appeal_to_authority: Autorität als einziger Beleg
- hasty_generalization: Einzelfall Allgemeinregel
- false_causation: Korrelation als Kausalität dargestellt
- appeal_to_emotion: Emotionen statt Argumente
- overgeneralization: Zu weit gefasste Verallgemeinerung
- cherry_picking: Nur passende Fakten ausgewählt
- other: Sonstiger Fehlschluss
Für jeden Fehlschluss:
- type: einer der oben genannten Typen
- description: Was genau ist der Fehlschluss? (1-2 Sätze, auf Deutsch)
- location: Das WÖRTLICHE ZITAT aus dem Originaltext wo der Fehlschluss vorkommt (max. 120 Zeichen, kein Feldname, kein JSON-Schlüssel)
- severity: minor/moderate/critical
overall_quality:
- strong: Kohärentes, gut belegtes Argument mit klarer Struktur
- adequate: Akzeptable Argumentation mit kleineren Lücken
- weak: Erhebliche Mängel, Lücken überwiegen
- flawed: Fundamentale logische Fehler oder schwere Fehlschlüsse
revision_suggestions: Konkrete, umsetzbare Verbesserungsvorschläge
quality_notes: 2-4 Sätze Begründung der Gesamtbewertung
Antworte NUR mit dem JSON-Objekt. Kein Freitext.`;
// ---------------------------------------------------------------------------
// Ollama-Analyse
// ---------------------------------------------------------------------------
async function analyzeWithOllama(
text: string,
model: string,
signal?: AbortSignal
): Promise<{ map: ArgumentMap; tokensIn: number; tokensOut: number; latencyMs: number }> {
const t0 = Date.now();
const body = {
model,
messages: [
{ role: "system", content: LOGIC_SYSTEM_PROMPT },
{ role: "user", content: `Analysiere die Argumentationsstruktur:\n\n---\n${text}\n---` },
],
format: ARGUMENT_MAP_SCHEMA,
stream: false,
options: { temperature: 0.1, num_ctx: 8192 },
};
const resp = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
if (!resp.ok) {
const errText = await resp.text().catch(() => "");
throw new Error(`Ollama Fehler ${resp.status}: ${errText}`);
}
const data = (await resp.json()) as OllamaResponse;
const raw = data.message?.content ?? "";
if (!raw.trim()) throw new Error("Leere Ollama-Antwort");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Kein gültiges JSON von Ollama: ${raw.slice(0, 200)}`);
}
const map: ArgumentMap = { schema_version: "1.0.0", ...(parsed as Omit<ArgumentMap, "schema_version">) };
return { map, tokensIn: data.prompt_eval_count ?? 0, tokensOut: data.eval_count ?? 0, latencyMs: Date.now() - t0 };
}
// ---------------------------------------------------------------------------
// OpenRouter-Analyse (Freitext → strukturiertes Parsing)
// ---------------------------------------------------------------------------
const OPENROUTER_LOGIC_PROMPT = `${LOGIC_SYSTEM_PROMPT}
WICHTIG: Antworte mit einem einzigen JSON-Objekt. Kein Markdown-Wrapper, kein Freitext davor oder danach.`;
async function analyzeWithOpenRouter(
text: string,
model: string,
signal?: AbortSignal
): Promise<{ map: ArgumentMap; costUSD: number; latencyMs: number }> {
const result = await callOpenRouter(
model,
[
{ role: "system", content: OPENROUTER_LOGIC_PROMPT },
{ role: "user", content: `Analysiere die Argumentationsstruktur:\n\n---\n${text}\n---` },
],
{ temperature: 0.1, maxTokens: 3000, signal }
);
// OpenRouter gibt Freitext zurück — JSON extrahieren
const jsonMatch = result.text.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("Kein JSON in OpenRouter-Antwort gefunden");
let parsed: unknown;
try {
parsed = JSON.parse(jsonMatch[0]);
} catch {
throw new Error(`Ungültiges JSON von OpenRouter: ${result.text.slice(0, 200)}`);
}
const costUSD = estimateOpenRouterCost(model, result.promptTokens, result.completionTokens);
const map: ArgumentMap = { schema_version: "1.0.0", ...(parsed as Omit<ArgumentMap, "schema_version">) };
return { map, costUSD, latencyMs: result.latencyMs };
}
// ---------------------------------------------------------------------------
// Hauptfunktion
// ---------------------------------------------------------------------------
type AnalysisResult = {
map: ArgumentMap;
provider: "ollama" | "openrouter";
model: string;
costUSD: number;
latencyMs: number;
};
export async function analyzeLogic(
text: string,
options?: {
forceCloud?: boolean;
model?: string;
signal?: AbortSignal;
}
): Promise<AnalysisResult> {
const complexity = text.length > 2000 ? "high" : "medium";
const decision = routeModel(
options?.forceCloud ? "deep_reasoning" : "logic_analysis",
complexity
);
const model = options?.model ?? decision.model;
if (decision.provider === "openrouter" || options?.forceCloud) {
const { map, costUSD, latencyMs } = await analyzeWithOpenRouter(text, model, options?.signal);
return { map, provider: "openrouter", model, costUSD, latencyMs };
}
const { map, latencyMs } = await analyzeWithOllama(text, model, options?.signal);
return { map, provider: "ollama", model, costUSD: 0, latencyMs };
}
// ---------------------------------------------------------------------------
// Formatierung
// ---------------------------------------------------------------------------
const QUALITY_LABEL: Record<OverallQuality, string> = {
strong: "STARK",
adequate: "AUSREICHEND",
weak: "SCHWACH",
flawed: "FEHLERHAFT",
};
const QUALITY_ICON: Record<OverallQuality, string> = {
strong: "✓",
adequate: "~",
weak: "⚠",
flawed: "✗",
};
const FALLACY_LABEL: Record<FallacyType, string> = {
ad_hominem: "Ad Hominem",
straw_man: "Strohmann",
false_dichotomy: "Falsche Dichotomie",
slippery_slope: "Schiefe Ebene",
circular_reasoning: "Zirkelschluss",
appeal_to_authority: "Autoritätsargument",
hasty_generalization: "Vorschnelle Generalisierung",
false_causation: "Falsche Kausalität",
appeal_to_emotion: "Appell an Emotionen",
overgeneralization: "Überverallgemeinerung",
cherry_picking: "Rosinenpickerei",
other: "Sonstiger Fehlschluss",
};
const SEVERITY_ICON: Record<Severity, string> = {
minor: "·",
moderate: "⚠",
critical: "✗",
};
function formatArgumentMap(result: AnalysisResult): string {
const { map } = result;
const lines: string[] = [];
const q = map.overall_quality;
lines.push(`## Argumentationsanalyse`);
lines.push(`**Gesamtqualität: ${QUALITY_ICON[q]} ${QUALITY_LABEL[q]}**`);
lines.push(map.quality_notes);
lines.push("");
lines.push(`**Hauptthese:**`);
lines.push(`> ${map.thesis}`);
lines.push("");
if (map.sub_theses.length > 0) {
lines.push(`**Unterthesen (${map.sub_theses.length}):**`);
map.sub_theses.forEach((t) => lines.push(`- ${t}`));
lines.push("");
}
if (map.premises.length > 0) {
lines.push(`**Prämissen:**`);
map.premises.forEach((p) => lines.push(`- ${p}`));
lines.push("");
}
if (map.evidence.length > 0) {
lines.push(`**Belege (${map.evidence.length}):**`);
map.evidence.forEach((e) => {
const icon = e.supports_thesis ? "✓" : "✗";
const str = e.strength === "strong" ? "stark" : e.strength === "moderate" ? "mittel" : "schwach";
lines.push(`${icon} [${str}] ${e.claim}`);
});
lines.push("");
}
if (map.conclusions.length > 0) {
lines.push(`**Schlussfolgerungen:**`);
map.conclusions.forEach((c) => lines.push(`- ${c}`));
lines.push("");
}
if (map.implicit_assumptions.length > 0) {
lines.push(`**Implizite Annahmen (${map.implicit_assumptions.length}):**`);
map.implicit_assumptions.forEach((a) => lines.push(`- _${a}_`));
lines.push("");
}
if (map.fallacies.length > 0) {
lines.push(`**Fehlschlüsse (${map.fallacies.length}):**`);
map.fallacies.forEach((f) => {
lines.push(`${SEVERITY_ICON[f.severity]} **${FALLACY_LABEL[f.type]}** (${f.severity})`);
lines.push(` ${f.description}`);
lines.push(` _"${f.location}"_`);
lines.push("");
});
} else {
lines.push(`_Keine Fehlschlüsse erkannt._`);
lines.push("");
}
if (map.revision_suggestions.length > 0) {
lines.push(`**Verbesserungsvorschläge:**`);
map.revision_suggestions.forEach((s, i) => lines.push(`${i + 1}. ${s}`));
lines.push("");
}
const latSec = (result.latencyMs / 1000).toFixed(1);
const costNote = result.costUSD > 0 ? ` · ~$${result.costUSD.toFixed(4)}` : " · kostenlos (lokal)";
lines.push(`_[${result.provider === "ollama" ? "Ollama" : "OpenRouter"}: ${result.model}${costNote} · ${latSec}s]_`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pi-Extension
// ---------------------------------------------------------------------------
const PARAMS = Type.Object({
text: Type.String({
description:
"Der zu analysierende Text: Artikel, Blogpost, Kommentar, Essay oder Nachrichtentext. " +
"Der Text wird auf logische Struktur, Fehlschlüsse und Argumentationsqualität geprüft.",
}),
cloud: Type.Optional(
Type.Boolean({
description:
"Wenn true: OpenRouter-Modell für tiefere Analyse verwenden (erfordert OPENROUTER_API_KEY). " +
"Standard: lokales Ollama (deepseek-r1:32b).",
})
),
model: Type.Optional(
Type.String({
description: "Modell-Override. Standard wird vom Router entschieden.",
})
),
});
export default function logicEditorExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "analyze_logic",
label: "Argumentationsanalyse",
description:
"Analysiert die logische Struktur eines Texts: Hauptthese, Prämissen, Belege, " +
"Schlussfolgerungen, implizite Annahmen und logische Fehlschlüsse. " +
"Gibt konkrete Verbesserungsvorschläge und eine Qualitätsbewertung. " +
"Standard: lokal via deepseek-r1:32b. Mit cloud=true: OpenRouter-Reasoning-Modell.",
promptGuidelines: [
"PREFER analyze_logic_llama over analyze_logic — it uses llama.cpp (unified backend).",
"Use analyze_logic (this tool) only when the user explicitly requests Ollama or OpenRouter.",
"Use analyze_logic when the user wants to check the argumentation quality of an article, comment, or essay.",
"Use analyze_logic after verify_article to get both factual AND logical quality assessment.",
"Always show the full formatted output including fallacies and revision suggestions.",
"If fallacies with severity 'critical' are found, highlight them prominently.",
"For politically or scientifically sensitive content, recommend cloud=true for deeper analysis.",
"The revision_suggestions are actionable — offer to rewrite specific sections if the user wants.",
"Combine with verify_article for a complete quality assessment: facts + logic.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
try {
const result = await analyzeLogic(params.text, {
forceCloud: params.cloud ?? false,
model: params.model,
signal,
});
return {
content: [{ type: "text", text: formatArgumentMap(result) }],
details: {
overallQuality: result.map.overall_quality,
fallacyCount: result.map.fallacies.length,
criticalFallacies: result.map.fallacies.filter((f) => f.severity === "critical").length,
provider: result.provider,
model: result.model,
costUSD: result.costUSD || null,
latencyMs: result.latencyMs,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
return { content: [{ type: "text", text: `Argumentationsanalyse fehlgeschlagen: ${msg}` }] };
}
},
});
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help") {
console.log(`
Argumentationsanalyse Logik, Fehlschlüsse und Verbesserungsvorschläge
Verwendung:
npx tsx agenten/logic-editor.ts [Optionen] "Text..."
npx tsx agenten/logic-editor.ts "$(cat artikel.txt)"
Optionen:
--cloud OpenRouter verwenden (stärker, kostenpflichtig)
--model <name> Modell-Override
--only-fallacies Nur Fehlschlüsse ausgeben (kein vollständiger Bericht)
--json Ausgabe als JSON
--help Diese Hilfe
`);
process.exit(0);
}
let forceCloud = false;
let model: string | undefined;
let jsonOutput = false;
let onlyFallacies = false;
const textParts: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--cloud") forceCloud = true;
else if (arg === "--model" && args[i + 1]) model = args[++i];
else if (arg === "--json") jsonOutput = true;
else if (arg === "--only-fallacies") onlyFallacies = true;
else if (!arg.startsWith("--")) textParts.push(arg);
}
const text = textParts.join(" ").trim();
if (!text) { console.error("Fehler: Kein Text."); process.exit(1); }
if (!jsonOutput) console.error(`\nAnalyse via ${forceCloud ? "OpenRouter" : "Ollama"}...\n`);
try {
const result = await analyzeLogic(text, { forceCloud, model });
if (onlyFallacies) {
if (jsonOutput) {
console.log(JSON.stringify(result.map.fallacies, null, 2));
} else {
const { map } = result;
if (map.fallacies.length === 0) {
console.log("Keine Fehlschlüsse erkannt.");
} else {
console.log(`## Fehlschlüsse (${map.fallacies.length})\n`);
map.fallacies.forEach((f) => {
const icon = SEVERITY_ICON[f.severity];
const label = FALLACY_LABEL[f.type];
console.log(`${icon} **${label}** (${f.severity})`);
console.log(` ${f.description}`);
console.log(` _"${f.location}"_\n`);
});
const latSec = (result.latencyMs / 1000).toFixed(1);
console.log(`_[${result.provider === "ollama" ? "Ollama" : "OpenRouter"}: ${result.model} · ${latSec}s]_`);
}
}
} else {
console.log(jsonOutput ? JSON.stringify(result.map, null, 2) : formatArgumentMap(result));
}
} catch (err) {
console.error("Fehler:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] === __filename) runCli();

450
agenten/ollama-verifier.ts Normal file
View file

@ -0,0 +1,450 @@
/**
* ollama-verifier.ts
* Pi-Extension + CLI: Eine einzelne Behauptung via Perplexity + Ollama verifizieren.
*
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ollama-verifier.ts
* Nach Änderungen in Pi: /reload
*
* Als CLI:
* npx tsx agenten/ollama-verifier.ts "Die Inflationsrate betrug 2024 in Deutschland 3,2%."
* npx tsx agenten/ollama-verifier.ts --mode deep "Die Erde ist 4,6 Milliarden Jahre alt."
* npx tsx agenten/ollama-verifier.ts --model deepseek-r1:32b "..."
* npx tsx agenten/ollama-verifier.ts --json "..." (gibt VerificationResult als JSON aus)
*
* Ablauf: Perplexity-Suche Ollama-Urteil formatierte Ausgabe
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { searchPerplexity, formatSourcesForPrompt, type PerplexitySource } from "../lib/perplexity.js";
import { createLogger, nullLogger, type Logger } from "../lib/logger.js";
// ---------------------------------------------------------------------------
// Typen
// ---------------------------------------------------------------------------
type VerificationStatus =
| "supported"
| "contradicted"
| "mixed"
| "insufficient_evidence"
| "needs_human_review";
type Confidence = "high" | "medium" | "low";
type VerdictRaw = {
status: VerificationStatus;
confidence: Confidence;
summary: string;
counter_evidence: string | null;
notes: string | null;
supporting_urls: string[];
};
export type VerificationResult = {
claim: string;
status: VerificationStatus;
confidence: Confidence;
summary: string;
counter_evidence: string | null;
notes: string | null;
sources: PerplexitySource[];
supporting_urls: string[];
perplexityCostUSD: number;
latencyMs: number;
model: string;
};
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";
// ---------------------------------------------------------------------------
// JSON-Schema für Ollama Verdict-Ausgabe
// ---------------------------------------------------------------------------
const VERDICT_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
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: ["status", "confidence", "summary", "counter_evidence", "notes", "supporting_urls"],
};
// ---------------------------------------------------------------------------
// Ollama Verdict-Synthese
// ---------------------------------------------------------------------------
function buildVerdictSystemPrompt(): string {
return `Du bist ein erfahrener Fact-Checker. Bewerte eine Behauptung anhand bereitgestellter Webquellen.
Bewertungsskala:
- supported: Quellen bestätigen die Behauptung klar und konsistent
- contradicted: Quellen widersprechen der Behauptung klar und substanziell
- mixed: Quellen liefern widersprüchliche Belege ODER die Behauptung ist technisch ungenau aber im Kern korrekt
- insufficient_evidence: Zu wenig oder qualitativ unzureichende Quellen für ein Urteil
- needs_human_review: Komplex, politisch heikel, veraltete Quellen, oder stark kontextabhängig
Confidence:
- high: Quellenlage ist eindeutig und aus Primärquellen
- medium: Quellen vorhanden aber begrenzt oder sekundär
- low: Quellen sehr rar, veraltet oder widersprüchlich
WICHTIGE REGELN für "contradicted":
- Nur bei klaren, substanziellen Fehlern verwenden: falsche Person, falsch zugeordnetes Ereignis, Zahl um mehr als 10% abweichend, grundlegend falsche Kausalität
- Gerundete oder allgemein akzeptierte Näherungswerte sind "supported"
- Zeitzonendifferenzen bei historischen Ereignissen: "supported" wenn im üblichen Kontext korrekt
- Technische Präzisierungen zu im Wesentlichen korrekten Aussagen "mixed", nicht "contradicted"
- Im Zweifel: "mixed" statt "contradicted"
summary: 1-3 präzise Sätze basierend auf den Quellen. Nicht spekulieren.
counter_evidence: Gegenbelege als Satz beschreiben, falls vorhanden. Sonst null.
notes: Zeitabhängigkeit, regionale Einschränkungen, Vorbehalt. Sonst null.
supporting_urls: URLs aus den Quellen die den Claim stützen (leeres Array wenn keine).
Antworte NUR mit dem JSON-Objekt. Kein Freitext.`;
}
function buildVerdictUserPrompt(claim: string, perplexitySummary: string, sources: PerplexitySource[], context?: string): string {
const contextBlock = context ? `\nARTIKEL-KONTEXT: "${context.slice(0, 300)}"\n` : "";
return `ZU PRÜFENDE BEHAUPTUNG:
"${claim}"
${contextBlock}
RECHERCHE-ERGEBNIS (Perplexity):
${perplexitySummary}
QUELLEN:
${formatSourcesForPrompt(sources, 300)}
Bewerte die Behauptung anhand der Recherche.`;
}
async function synthesizeVerdict(
claim: string,
perplexitySummary: string,
sources: PerplexitySource[],
model: string,
context?: string,
signal?: AbortSignal
): Promise<VerdictRaw> {
const body = {
model,
messages: [
{ role: "system", content: buildVerdictSystemPrompt() },
{ role: "user", content: buildVerdictUserPrompt(claim, perplexitySummary, sources, context) },
],
format: VERDICT_SCHEMA,
stream: false,
options: { temperature: 0.1, num_ctx: 8192 },
};
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 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");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Kein gültiges JSON von Ollama: ${raw.slice(0, 200)}`);
}
return parsed as VerdictRaw;
}
// ---------------------------------------------------------------------------
// Hauptfunktion
// ---------------------------------------------------------------------------
export async function verifyClaim(
claim: string,
options?: {
context?: string;
mode?: "fast" | "deep";
model?: string;
signal?: AbortSignal;
logger?: Logger;
}
): Promise<VerificationResult> {
const t0 = Date.now();
const model = options?.model ?? DEFAULT_MODEL;
const log = options?.logger ?? nullLogger;
log.info("Perplexity-Suche gestartet", { claim: claim.slice(0, 80), mode: options?.mode ?? "fast" });
const perplexityResult = await searchPerplexity(claim, {
mode: options?.mode ?? "fast",
signal: options?.signal,
});
log.info("Perplexity abgeschlossen", {
sources: perplexityResult.sources.length,
costUSD: perplexityResult.estimatedCostUSD.toFixed(4),
});
log.info("Ollama-Urteil generieren...", { model });
const verdict = await synthesizeVerdict(
claim,
perplexityResult.summary,
perplexityResult.sources,
model,
options?.context,
options?.signal
);
log.info("Urteil erhalten", { status: verdict.status, confidence: verdict.confidence });
return {
claim,
status: verdict.status,
confidence: verdict.confidence,
summary: verdict.summary,
counter_evidence: verdict.counter_evidence,
notes: verdict.notes,
sources: perplexityResult.sources,
supporting_urls: verdict.supporting_urls,
perplexityCostUSD: perplexityResult.estimatedCostUSD,
latencyMs: Date.now() - t0,
model,
};
}
// ---------------------------------------------------------------------------
// 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",
};
const CONF_LABEL: Record<Confidence, string> = {
high: "hoch",
medium: "mittel",
low: "niedrig",
};
export function formatVerificationResult(result: VerificationResult): string {
const lines: string[] = [];
lines.push(`## Verifikation`);
lines.push(`**Behauptung:** "${result.claim}"`);
lines.push("");
lines.push(`**${STATUS_ICON[result.status]}** (Konfidenz: ${CONF_LABEL[result.confidence]})`);
lines.push("");
lines.push(`**Begründung:** ${result.summary}`);
if (result.counter_evidence) {
lines.push(`\n**Gegenbelege:** ${result.counter_evidence}`);
}
if (result.notes) {
lines.push(`\n**Hinweise:** ${result.notes}`);
}
if (result.sources.length > 0) {
lines.push("\n**Quellen:**");
result.sources.forEach((s, i) => {
const supporting = result.supporting_urls.includes(s.url) ? " ✓" : "";
const title = s.title ?? s.url;
lines.push(`[${i + 1}]${supporting} [${title}](${s.url})`);
});
} else {
lines.push("\n_(Keine Quellen gefunden)_");
}
const latSec = (result.latencyMs / 1000).toFixed(1);
lines.push(`\n_[Perplexity: ~$${result.perplexityCostUSD.toFixed(4)} | Ollama: ${result.model} | Gesamt: ${latSec}s]_`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pi-Extension: Default Export
// ---------------------------------------------------------------------------
const PARAMS = Type.Object({
claim: Type.String({
description:
"Die zu verifizierende Behauptung als vollständiger, selbstständiger Satz. " +
"Idealerweise das Ergebnis von extract_claims (claim_id + text).",
}),
context: Type.Optional(
Type.String({
description:
"Optionaler Kontext: kurzer Auszug aus dem Artikel, in dem die Behauptung steht. " +
"Hilft dem Fact-Checker bei mehrdeutigen Claims. Max. 300 Zeichen.",
})
),
mode: Type.Optional(
Type.Union([Type.Literal("fast"), Type.Literal("deep")], {
description:
"fast (Standard): sonar, für die meisten Behauptungen ausreichend. " +
"deep: sonar-pro, für komplexe, strittige oder heikle Behauptungen.",
})
),
model: Type.Optional(
Type.String({
description: `Ollama-Modell für die Urteilssynthese. Standard: ${DEFAULT_MODEL}.`,
})
),
});
export default function verifierExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "verify_claim",
label: "Claim-Verifikation",
description:
"Verifiziert eine einzelne Behauptung: Perplexity-Recherche → Ollama-Urteil. " +
"Gibt Status (supported/contradicted/mixed/insufficient_evidence/needs_human_review), " +
"Konfidenz, Begründung und Quellen zurück. " +
"Nutze dieses Tool nach extract_claims um spezifische Claims zu prüfen. " +
"Kosten: ~$0.005-0.015 pro Claim (Perplexity) + lokal (Ollama).",
promptGuidelines: [
"Use verify_claim after extract_claims to check specific claims the user wants verified.",
"Pass the full claim text from extract_claims as the 'claim' parameter.",
"Use mode=deep for complex, politically sensitive, or scientifically contested claims.",
"The 'context' parameter helps when the claim is ambiguous without its original article context.",
"Show the full formatted output including the cost/latency line.",
"If status is 'needs_human_review' or 'insufficient_evidence', clearly communicate this to the user and suggest manual checking.",
"If status is 'contradicted', always show the counter_evidence to the user.",
"For multiple claims from an extract_claims result, use verify_article instead — it is faster and cheaper.",
"IMPORTANT: Never call verify_claim for multiple claims simultaneously. Ollama processes one request at a time — parallel calls will fail with 'fetch failed'. Always verify claims one by one, sequentially.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
try {
const result = await verifyClaim(params.claim, {
context: params.context,
mode: params.mode,
model: params.model,
signal,
});
return {
content: [{ type: "text", text: formatVerificationResult(result) }],
details: {
status: result.status,
confidence: result.confidence,
model: result.model,
sourceCount: result.sources.length,
perplexityCostUSD: result.perplexityCostUSD,
latencyMs: result.latencyMs,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
return { content: [{ type: "text", text: `Verifikationsfehler: ${msg}` }] };
}
},
});
}
// ---------------------------------------------------------------------------
// CLI-Modus
// ---------------------------------------------------------------------------
function parseCliArgs(args: string[]): { claim: string; mode: "fast" | "deep"; model: string; jsonOutput: boolean; verbose: boolean } {
let mode: "fast" | "deep" = "fast";
let model = DEFAULT_MODEL;
let jsonOutput = false;
let verbose = false;
const claimParts: 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 === "--json") {
jsonOutput = true;
} else if (arg === "--verbose" || arg === "-v") {
verbose = true;
} else if (!arg.startsWith("--")) {
claimParts.push(arg);
}
}
return { claim: claimParts.join(" ").trim(), mode, model, jsonOutput, verbose };
}
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help") {
console.log(`
Claim-Verifikator (Ollama) Eine Behauptung mit Perplexity + Ollama prüfen
Verwendung:
npx tsx agenten/ollama-verifier.ts [Optionen] "Behauptung..."
Optionen:
--mode fast|deep Perplexity-Modus (Standard: fast)
--model <name> Ollama-Modell (Standard: ${DEFAULT_MODEL})
--json Ausgabe als JSON
--help Diese Hilfe
`);
process.exit(0);
}
const { claim, mode, model, jsonOutput, verbose } = parseCliArgs(args);
if (!claim) {
console.error("Fehler: Kein Claim übergeben.");
process.exit(1);
}
if (!jsonOutput) {
console.error(`\nVerifiziere: "${claim}"\nModus: ${mode} | Modell: ${model}\n`);
}
const log = createLogger({ verbose });
try {
const result = await verifyClaim(claim, { mode, model, logger: log });
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(formatVerificationResult(result));
}
} catch (err) {
console.error("Fehler:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
const __filename = fileURLToPath(import.meta.url);
if (process.argv[1] === __filename) {
runCli();
}

View file

@ -0,0 +1,809 @@
/**
* 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 >10% 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();
}

579
agenten/ollama-writer.ts Normal file
View file

@ -0,0 +1,579 @@
/**
* ollama-writer.ts
* Pi-Extension + CLI: Artikel schreiben via Ollama (lokales LLM)
*
* Schreibt einen Artikel NUR auf Basis von "supported"-Claims aus einem VerificationReport.
* Widerlgte, gemischte oder unzureichend belegte Claims werden automatisch ausgeschlossen.
*
* Routing: Ollama lokal (Standard) oder OpenRouter für anspruchsvollere Texte.
* HINWEIS: Für thinking-Modelle (qwen3.5:27b etc.) llama-writer.ts bevorzugen.
*
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink)
* Als CLI:
* npx tsx agenten/ollama-verify-article.ts --json "$(cat artikel.txt)" | npx tsx agenten/ollama-writer.ts --from-report
* npx tsx agenten/ollama-writer.ts --from-job <slug> --style blog
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { fileURLToPath } from "node:url";
import { routeModel, callOpenRouter, estimateOpenRouterCost } from "../lib/router.js";
import type { VerificationReport } from "./ollama-verify-article.js";
import {
findJobDir,
loadJobFile,
saveJobFile,
updateJobMeta,
} from "../lib/jobs.js";
// ---------------------------------------------------------------------------
// Typen
// ---------------------------------------------------------------------------
type Style = "journalistic" | "blog" | "academic" | "editorial" | "explanatory";
type ArticleDraft = {
schema_version: "1.0.0";
title: string;
lead: string;
body: string;
conclusion: string | null;
style: Style;
language: string;
word_count: number;
claim_ids_used: string[];
sources: Array<{ number: number; url: string; title: string | null; claim_id: string }>;
excluded_claims: string[];
editorial_notes: string;
};
type OllamaResponse = {
message?: { content?: string };
eval_count?: number;
prompt_eval_count?: number;
};
// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------
const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434";
const DEFAULT_MODEL = "qwen3.5:27b";
// ---------------------------------------------------------------------------
// Ollama-Schema für Artikel-Ausgabe
// ---------------------------------------------------------------------------
const ARTICLE_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
title: { type: "string" },
lead: { type: "string" },
body: { type: "string" },
conclusion: { type: ["string", "null"] },
editorial_notes: { type: "string" },
},
required: ["title", "lead", "body", "conclusion", "editorial_notes"],
};
// ---------------------------------------------------------------------------
// Prompt-Generierung
// ---------------------------------------------------------------------------
type ClaimForWriting = {
id: string;
text: string;
sources: Array<{ url: string; title: string | null }>;
};
function buildWriterPrompt(
claims: ClaimForWriting[],
style: Style,
topic: string,
wordCount: number,
language: string
): string {
const styleGuide: Record<Style, string> = {
journalistic:
"Journalistisch: präzise, faktenbasiert, W-Fragen im Einleitungssatz, Inverted Pyramid, " +
"zitierbare Aussagen direkt belegt, keine Meinungen ohne Kennzeichnung.",
blog:
"Blog: zugänglich, ansprechend, erste Person erlaubt, direkte Ansprache des Lesers, " +
"lebendige Sprache, Zwischenüberschriften als Orientierung.",
academic:
"Akademisch: präzise Terminologie, passive Formulierungen, klare Abschnittsstruktur " +
"(Einleitung, Hauptteil, Schluss), Quellenverweise inline.",
editorial:
"Leitartikel: klare Haltung, argumentativ, Bezug zur aktuellen Debatte, " +
"stützt sich auf Fakten aber formuliert Bewertung.",
explanatory:
"Erklärstück: vereinfacht komplexe Sachverhalte, Analogien und Beispiele, " +
"schrittweise Struktur, Leserfragen antizipieren.",
};
const claimsText = claims
.map((c, i) => {
const srcList = c.sources
.map((s, j) => `[${i * 10 + j + 1}] ${s.title ?? s.url} (${s.url})`)
.join("\n ");
return `Claim ${c.id}: ${c.text}\n Belege:\n ${srcList || "(keine URL)"}`;
})
.join("\n\n");
return `Du bist ein erfahrener ${style === "journalistic" ? "Journalist" : style === "blog" ? "Blogger" : style === "academic" ? "Wissenschaftsautor" : style === "editorial" ? "Leitartikler" : "Erklärer"}.
Schreibe einen Artikel zum Thema: "${topic}"
STIL: ${styleGuide[style]}
SPRACHE: ${language === "de" ? "Deutsch" : language === "en" ? "Englisch" : language}
LÄNGE: ca. ${wordCount} Wörter
VERIFIZIERTE FAKTEN (nur diese dürfen verwendet werden):
${claimsText}
REGELN:
- Verwende NUR die oben genannten verifizierten Claims als Faktengrundlage
- Kennzeichne jeden Fakt mit Inline-Quellenverweisen [N] aus der Beleg-Liste
- Erfinde keine Fakten, Zahlen oder Zitate
- editorial_notes: Was fehlt für einen vollständigen Artikel? Was sollte noch recherchiert werden?
- Antworte NUR mit dem JSON-Objekt. Kein Freitext davor oder danach.`;
}
// ---------------------------------------------------------------------------
// Ollama-Aufruf
// ---------------------------------------------------------------------------
async function writeWithOllama(
claims: ClaimForWriting[],
style: Style,
topic: string,
wordCount: number,
language: string,
model: string,
signal?: AbortSignal
): Promise<{ raw: Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">; tokensIn: number; tokensOut: number; latencyMs: number }> {
const t0 = Date.now();
const prompt = buildWriterPrompt(claims, style, topic, wordCount, language);
const body = {
model,
messages: [{ role: "user", content: prompt }],
format: ARTICLE_SCHEMA,
stream: false,
options: { temperature: 0.4, num_ctx: 12288 },
};
const resp = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
if (!resp.ok) {
const errText = await resp.text().catch(() => "");
throw new Error(`Ollama Fehler ${resp.status}: ${errText}`);
}
const data = (await resp.json()) as OllamaResponse;
const raw = data.message?.content ?? "";
if (!raw.trim()) throw new Error("Leere Ollama-Antwort");
const parsed = JSON.parse(raw) as Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">;
return { raw: parsed, tokensIn: data.prompt_eval_count ?? 0, tokensOut: data.eval_count ?? 0, latencyMs: Date.now() - t0 };
}
// ---------------------------------------------------------------------------
// OpenRouter-Aufruf
// ---------------------------------------------------------------------------
async function writeWithOpenRouter(
claims: ClaimForWriting[],
style: Style,
topic: string,
wordCount: number,
language: string,
model: string,
signal?: AbortSignal
): Promise<{ raw: Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">; costUSD: number; latencyMs: number }> {
const prompt = buildWriterPrompt(claims, style, topic, wordCount, language);
const result = await callOpenRouter(
model,
[{ role: "user", content: prompt + "\n\nAntworte mit einem einzigen JSON-Objekt ohne Markdown-Wrapper." }],
{ temperature: 0.4, maxTokens: 4000, signal }
);
const jsonMatch = result.text.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error("Kein JSON in OpenRouter-Antwort");
const parsed = JSON.parse(jsonMatch[0]) as Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">;
const costUSD = estimateOpenRouterCost(model, result.promptTokens, result.completionTokens);
return { raw: parsed, costUSD, latencyMs: result.latencyMs };
}
// ---------------------------------------------------------------------------
// Quellenverzeichnis aufbauen
// ---------------------------------------------------------------------------
function buildSourceIndex(claims: ClaimForWriting[]): Array<{ number: number; url: string; title: string | null; claim_id: string }> {
const sources: Array<{ number: number; url: string; title: string | null; claim_id: string }> = [];
let n = 1;
for (const c of claims) {
for (const s of c.sources) {
sources.push({ number: n++, url: s.url, title: s.title, claim_id: c.id });
}
}
return sources;
}
// ---------------------------------------------------------------------------
// Hauptfunktion
// ---------------------------------------------------------------------------
export type WriteResult = {
draft: ArticleDraft;
provider: "ollama" | "openrouter";
model: string;
costUSD: number;
latencyMs: number;
};
export async function writeFromReport(
report: VerificationReport,
options?: {
style?: Style;
topic?: string;
wordCount?: number;
language?: string;
cloud?: boolean;
model?: string;
signal?: AbortSignal;
}
): Promise<WriteResult> {
const style = options?.style ?? "journalistic";
const wordCount = options?.wordCount ?? 400;
const language = options?.language ?? "de";
// Nur "supported" Claims verwenden
const supported = report.results.filter((r) => r.status === "supported");
const excluded = report.results.filter((r) => r.status !== "supported").map((r) => r.claim_id);
if (supported.length === 0) {
throw new Error("Keine verifizierten (supported) Claims im Report — kein Artikel möglich.");
}
// Topic aus dem Report ableiten wenn nicht angegeben
const topic = options?.topic ?? report.source_text_summary ?? "Artikel";
// Claims für Writer aufbereiten
const claims: ClaimForWriting[] = supported.map((r) => ({
id: r.claim_id,
text: r.claim_text,
sources: r.sources
.filter((s) => s.supports_claim)
.map((s) => ({ url: s.url, title: s.title })),
}));
// Ohne explizites --cloud immer lokal (complexity "low" → Ollama im Router)
const decision = routeModel(
options?.cloud ? "deep_reasoning" : "article_writing",
options?.cloud ? "medium" : "low"
);
const model = options?.model ?? decision.model;
let raw: Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">;
let costUSD = 0;
let latencyMs = 0;
let provider: "ollama" | "openrouter";
if (decision.provider === "openrouter" || options?.cloud) {
const result = await writeWithOpenRouter(claims, style, topic, wordCount, language, model, options?.signal);
raw = result.raw;
costUSD = result.costUSD;
latencyMs = result.latencyMs;
provider = "openrouter";
} else {
const result = await writeWithOllama(claims, style, topic, wordCount, language, model, options?.signal);
raw = result.raw;
latencyMs = result.latencyMs;
provider = "ollama";
}
const sources = buildSourceIndex(claims);
const wordCountActual = (raw.lead + " " + raw.body + " " + (raw.conclusion ?? ""))
.split(/\s+/).filter(Boolean).length;
const draft: ArticleDraft = {
...raw,
schema_version: "1.0.0" as const,
style,
language,
word_count: wordCountActual,
claim_ids_used: claims.map((c) => c.id),
sources,
excluded_claims: excluded,
editorial_notes: raw.editorial_notes,
};
return { draft, provider, model, costUSD, latencyMs };
}
// ---------------------------------------------------------------------------
// Formatierung
// ---------------------------------------------------------------------------
export function formatDraft(result: WriteResult): string {
const { draft } = result;
const lines: string[] = [];
lines.push(`# ${draft.title}`);
lines.push("");
lines.push(`_${draft.lead}_`);
lines.push("");
lines.push(draft.body);
if (draft.conclusion) {
lines.push("");
lines.push("---");
lines.push(draft.conclusion);
}
if (draft.sources.length > 0) {
lines.push("\n**Quellen:**");
draft.sources.forEach((s) => {
const title = s.title ?? s.url;
lines.push(`[${s.number}] [${title}](${s.url})`);
});
}
if (draft.excluded_claims.length > 0) {
lines.push(`\n_${draft.excluded_claims.length} Claim(s) ausgeschlossen (nicht verifiziert): ${draft.excluded_claims.join(", ")}_`);
}
if (draft.editorial_notes) {
lines.push(`\n**Redaktionshinweise:** ${draft.editorial_notes}`);
}
const latSec = (result.latencyMs / 1000).toFixed(1);
const costNote = result.costUSD > 0 ? ` · ~$${result.costUSD.toFixed(4)}` : " · kostenlos (lokal)";
lines.push(`\n_[${result.provider === "ollama" ? "Ollama" : "OpenRouter"}: ${result.model} · ${draft.word_count} Wörter${costNote} · ${latSec}s]_`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Pi-Extension
// ---------------------------------------------------------------------------
const PARAMS = Type.Object({
reportJson: Type.String({
description:
"JSON-String eines VerificationReport (Ausgabe von verify_article --json oder verify_article). " +
"Nur 'supported'-Claims werden für den Artikel verwendet.",
}),
topic: Type.Optional(
Type.String({ description: "Artikelthema / Überschrift. Standard: wird aus dem Report abgeleitet." })
),
style: Type.Optional(
Type.Union(
[
Type.Literal("journalistic"),
Type.Literal("blog"),
Type.Literal("academic"),
Type.Literal("editorial"),
Type.Literal("explanatory"),
],
{ description: "Schreibstil. Standard: journalistic." }
)
),
wordCount: Type.Optional(
Type.Number({ description: "Ziel-Wortanzahl. Standard: 400." })
),
language: Type.Optional(
Type.String({ description: "Sprache (ISO 639-1). Standard: de." })
),
cloud: Type.Optional(
Type.Boolean({ description: "OpenRouter-Modell verwenden (besserer Stil, kostenpflichtig)." })
),
model: Type.Optional(
Type.String({ description: "Modell-Override." })
),
});
export default function writerExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "write_article",
label: "Artikel schreiben",
description:
"Schreibt einen Artikel ausschließlich auf Basis verifizierter Claims aus einem VerificationReport. " +
"Widerlgte, gemischte oder nicht belegte Claims werden automatisch ausgeschlossen. " +
"Verwendet den vollständigen Workflow: verify_article → write_article. " +
"Kosten: lokal kostenlos (Ollama) oder gering (OpenRouter mit cloud=true).",
promptGuidelines: [
"PREFER write_article_llama over write_article — it uses llama.cpp (no Ollama timeout issues).",
"Use write_article (this tool) only when the user explicitly requests Ollama or OpenRouter.",
"Use write_article after verify_article to generate a fact-checked article draft.",
"Always pass the full JSON output of verify_article as 'reportJson'.",
"Ask the user for the desired style (journalistic, blog, academic, editorial, explanatory) if not specified.",
"Show the full formatted draft including sources and editorial notes.",
"Point out excluded claims to the user — these may be important context that was removed.",
"If editorial_notes mention missing information, suggest running additional research.",
"For high-quality output (interviews, feature articles), recommend cloud=true.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
try {
const report = JSON.parse(params.reportJson) as VerificationReport;
const result = await writeFromReport(report, {
style: params.style,
topic: params.topic,
wordCount: params.wordCount,
language: params.language,
cloud: params.cloud,
model: params.model,
signal,
});
return {
content: [{ type: "text", text: formatDraft(result) }],
details: {
wordCount: result.draft.word_count,
claimsUsed: result.draft.claim_ids_used.length,
claimsExcluded: result.draft.excluded_claims.length,
provider: result.provider,
costUSD: result.costUSD || null,
latencyMs: result.latencyMs,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
return { content: [{ type: "text", text: `Artikelgenerierung fehlgeschlagen: ${msg}` }] };
}
},
});
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function runCli() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "--help") {
console.log(`
Artikel-Writer Schreibt Artikel auf Basis verifizierter Claims
Verwendung:
# Via Pipe (kein Job-Speicher):
npx tsx agenten/verify-article.ts --json "..." | npx tsx agenten/writer.ts --from-report
# Via Job-Speicher (empfohlen):
npx tsx agenten/verify-article.ts --job-id umerziehung "$(cat artikel.txt)"
npx tsx agenten/writer.ts --from-job umerziehung --style blog
Optionen:
--from-report Lese VerificationReport von stdin (JSON)
--from-job <slug> Lese report.json aus Job ~/.pi/agent/jobs/<datum>_<slug>/
Speichert article.md automatisch zurück in den Job
--style <s> journalistic|blog|academic|editorial|explanatory (Standard: journalistic)
--topic <text> Artikelthema
--words <n> Ziel-Wortanzahl (Standard: 400)
--lang <code> Sprache (Standard: de)
--cloud OpenRouter verwenden
--model <name> Modell-Override
--json Ausgabe als JSON
--help Diese Hilfe
`);
process.exit(0);
}
let fromReport = false;
let fromJobSlug: string | undefined;
let style: Style = "journalistic";
let topic: string | undefined;
let wordCount = 400;
let language = "de";
let cloud = false;
let model: string | undefined;
let jsonOutput = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--from-report") fromReport = true;
else if (arg === "--from-job" && args[i + 1]) fromJobSlug = args[++i];
else if (arg === "--style" && args[i + 1]) style = args[++i] as Style;
else if (arg === "--topic" && args[i + 1]) topic = args[++i];
else if (arg === "--words" && args[i + 1]) wordCount = parseInt(args[++i], 10);
else if (arg === "--lang" && args[i + 1]) language = args[++i];
else if (arg === "--cloud") cloud = true;
else if (arg === "--model" && args[i + 1]) model = args[++i];
else if (arg === "--json") jsonOutput = true;
}
let report: VerificationReport;
let jobDir: string | undefined;
if (fromJobSlug) {
// Report aus Job-Speicher laden
const dir = findJobDir(fromJobSlug);
if (!dir) {
console.error(`Fehler: Kein Job mit Slug "${fromJobSlug}" gefunden in ~/.pi/agent/jobs/`);
console.error("Tipp: Zuerst verify-article.ts --job-id <slug> ausführen.");
process.exit(1);
}
jobDir = dir;
const loaded = loadJobFile<VerificationReport>(dir, "report.json");
if (!loaded) {
console.error(`Fehler: Kein report.json in Job ${dir}`);
console.error("Tipp: verify-article.ts --job-id <slug> muss zuerst abgeschlossen werden.");
process.exit(1);
}
report = loaded;
if (!jsonOutput) console.error(`\nJob: ${dir}\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`);
} else if (fromReport) {
// Report von stdin lesen
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
const input = Buffer.concat(chunks).toString("utf-8").trim();
if (!input) { console.error("Fehler: Kein Input von stdin."); process.exit(1); }
report = JSON.parse(input) as VerificationReport;
if (!jsonOutput) console.error(`\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`);
} else {
console.error("Fehler: --from-report oder --from-job <slug> erforderlich.");
process.exit(1);
}
try {
const result = await writeFromReport(report, { style, topic, wordCount, language, cloud, model });
// Im Job speichern wenn --from-job verwendet wurde
if (jobDir) {
saveJobFile(jobDir, "article.md", formatDraft(result));
updateJobMeta(jobDir, {
status: "completed",
steps: {
write: {
completedAt: new Date().toISOString(),
style,
wordCount: result.draft.word_count,
provider: result.provider,
costUSD: result.costUSD,
},
},
});
if (!jsonOutput) process.stderr.write(`\n Artikel in Job gespeichert: ${jobDir}/article.md\n`);
}
console.log(jsonOutput ? JSON.stringify(result.draft, null, 2) : formatDraft(result));
} 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();

431
agenten/research-web.ts Normal file
View file

@ -0,0 +1,431 @@
/**
* research-web.ts
* Pi-Extension: Web-Recherche via Perplexity Sonar
*
* Platzieren in: ~/.pi/agent/extensions/research-web.ts
* Nach Änderungen in Pi: /reload
*
* Kostenstruktur (Stand April 2026):
* sonar: 1 USD/M Input + 1 USD/M Output + 0.005 USD/web_search
* sonar-pro: 3 USD/M Input + 15 USD/M Output + 0.005 USD/web_search
Übersicht aller drei Contexts: Zusatz im Prompt: "Nutze context=<context> .
context Modell-Default Format Optimal für
facts sonar/fast Verbatim + [N] inline Presse, Faktencheck (default)
code sonar/fast Snippet unter jeder Quelle APIs, Libraries, Doku
legal sonar-pro/deep Verbatim + [N] + Paragraf + Snippet + Disclaimer Gesetze, DSGVO, EU-Recht, Urteile
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
const PARAMS = Type.Object({
query: Type.String({ description: "Die Recherchefrage auf Deutsch oder Englisch" }),
recency: Type.Optional(
Type.String({
description: "Aktualitätsfilter: day, week, month oder year. Weglassen = kein Filter",
})
),
allowWikipedia: Type.Optional(
Type.Boolean({
description:
"Wikipedia als Quelle erlauben. Nur setzen wenn der User das explizit anfordert. Standard: false",
})
),
mode: Type.Optional(
Type.String({
description:
"fast (Standard): sonar, kostengünstig, für die meisten Anfragen. " +
"deep: sonar-pro, für komplexe, mehrstufige oder heikle Recherchen.",
})
),
context: Type.Optional(
Type.String({
description:
"facts (Standard): Presse/Fakten-Recherche — Inline-Zitierungen [1][2][3] im Text, verbatim ausgeben. " +
"code: Programmier-Dokumentation — Quellen mit relevantem Textauszug (Snippet). " +
"legal: Gesetze/Urteile/Verordnungen — offizielle Quellen, Paragrafangaben, Gesetzestext als Snippet. Nutzt automatisch mode=deep.",
})
),
});
type ResearchSource = { url: string; title?: string; snippet?: string };
type PerplexityResponse = {
model?: string;
citations?: string[];
search_results?: Array<{ url?: string; title?: string; snippet?: string }>;
choices?: Array<{ message?: { content?: string } }>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
search_queries?: number;
};
};
// Kostenmodell (USD pro 1 Mio Tokens)
const PRICING = {
sonar: { inputPerM: 1, outputPerM: 1 },
"sonar-pro": { inputPerM: 3, outputPerM: 15 },
} as const;
const SEARCH_COST_PER_CALL = 0.005;
class RetryableError extends Error {
constructor(message: string) {
super(message);
this.name = "RetryableError";
}
}
function stripQuotes(s: string): string {
return s.replace(/^["']+|["']+$/g, "").trim();
}
function normalizeMode(raw: string | undefined, fallback: "fast" | "deep"): "fast" | "deep" {
if (!raw) return fallback;
const v = stripQuotes(raw).toLowerCase();
return v === "deep" ? "deep" : "fast";
}
function normalizeContext(raw: string | undefined): "facts" | "code" | "legal" {
if (!raw) return "facts";
const v = stripQuotes(raw).toLowerCase();
if (v === "code") return "code";
if (v === "legal") return "legal";
return "facts";
}
function normalizeRecency(raw: string | undefined): "day" | "week" | "month" | "year" | undefined {
if (!raw) return undefined;
const v = stripQuotes(raw).toLowerCase() as "day" | "week" | "month" | "year";
return ["day", "week", "month", "year"].includes(v) ? v : undefined;
}
async function sleep(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}
function estimateCostUSD(
model: string,
promptTokens: number,
completionTokens: number,
searchQueries: number
): number {
const p = PRICING[model as keyof typeof PRICING] ?? PRICING["sonar"];
const tokenCost =
(promptTokens / 1_000_000) * p.inputPerM + (completionTokens / 1_000_000) * p.outputPerM;
const searchCost = searchQueries * SEARCH_COST_PER_CALL;
return tokenCost + searchCost;
}
function buildSystemPrompt(context: "facts" | "code" | "legal"): string {
if (context === "code") {
return (
"Du bist ein Recherche-Tool für Softwareentwickler. " +
"Antworte knapp und präzise auf Deutsch oder Englisch. " +
"Fokussiere auf die technisch relevante Information und benenne, welche Dokumentation die Frage beantwortet."
);
}
if (context === "legal") {
return (
"Du bist ein juristisches Recherche-Tool. " +
"Antworte sachlich und präzise auf Deutsch. " +
"Fokussiere ausschließlich auf offizielle Rechtsquellen: Gesetze, Verordnungen, EU-Recht, amtliche Bekanntmachungen und Gerichtsurteile. " +
"Nenne Gesetz und Paragraf direkt im Text (z.B. § 13 DSGVO, Art. 5 GG). " +
"Setze Inline-Zitierungen [1][2][3] hinter jeden Satz mit Rechtsgrundlage. " +
"Weise ausdrücklich auf relevante Rechtsänderungen oder Ausnahmen hin. " +
"Keine Rechtsberatung — nur Darstellung der Rechtslage."
);
}
return (
"Du bist ein Recherche-Tool für einen KI-Agenten. " +
"Antworte sachlich und prägnant auf Deutsch. " +
"Setze Inline-Zitierungen [1][2][3] direkt hinter jeden Satz, den du auf Quellen stützt. " +
"Begrenze die Antwort auf das Wesentliche."
);
}
async function callPerplexity(
query: string,
recency: string | undefined,
allowWikipedia: boolean | undefined,
context: "facts" | "code" | "legal",
model: "sonar" | "sonar-pro",
contextSize: "low" | "high",
maxTokens: number,
apiKey: string,
signal: AbortSignal
): Promise<PerplexityResponse> {
const body: Record<string, unknown> = {
model,
messages: [
{
role: "system",
content: buildSystemPrompt(context),
},
{
role: "user",
content: `Recherchefrage: ${query}\n\nKurze, neutrale Zusammenfassung der wichtigsten Fakten.`,
},
],
max_tokens: maxTokens,
temperature: 0.2,
web_search_options: {
search_context_size: contextSize,
},
};
if (recency) {
body.search_recency_filter = recency;
}
// Wikipedia nur auf explizite Anforderung — standardmäßig ausschließen
if (!allowWikipedia) {
body.search_domain_filter = [
"-en.wikipedia.org",
"-de.wikipedia.org",
"-simple.wikipedia.org",
];
}
const resp = await fetch("https://api.perplexity.ai/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal,
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
// Nur bei transienten Fehlern retryen — 4xx-Konfigurationsfehler nicht wiederholen
if (resp.status === 429 || resp.status >= 500) {
throw new RetryableError(`Perplexity API Fehler ${resp.status}: ${text}`);
}
throw new Error(`Perplexity API Fehler ${resp.status}: ${text}`);
}
return (await resp.json()) as PerplexityResponse;
}
function dedupeSources(sources: ResearchSource[]): ResearchSource[] {
const seen = new Set<string>();
const out: ResearchSource[] = [];
for (const s of sources) {
if (!s.url || seen.has(s.url)) continue;
seen.add(s.url);
out.push(s);
}
return out;
}
function parseSources(data: PerplexityResponse, withSnippets: boolean, maxSources = 8): ResearchSource[] {
const fromSearchResults =
data.search_results
?.filter((r) => !!r?.url)
.map((r) => ({
url: r.url!,
title: r.title,
snippet: withSnippets ? r.snippet : undefined,
})) ?? [];
if (fromSearchResults.length > 0) {
return dedupeSources(fromSearchResults).slice(0, maxSources);
}
return dedupeSources(
data.citations
?.filter((u) => typeof u === "string" && /^https?:\/\//.test(u))
.map((url) => ({ url })) ?? []
).slice(0, maxSources);
}
function formatCostLine(
model: string,
mode: string,
promptTokens: number,
completionTokens: number,
estimatedCostUSD: number
): string {
const tokenInfo =
promptTokens || completionTokens ? ` · ${promptTokens}+${completionTokens} Tokens` : "";
return `_[Perplexity: ${model}/${mode}${tokenInfo} · ~$${estimatedCostUSD.toFixed(4)}]_`;
}
function formatSourcesFacts(sources: ResearchSource[]): string[] {
return sources.map((s, i) => {
const num = `[${i + 1}]`;
return s.title ? `${num} [${s.title}](${s.url})` : `${num} ${s.url}`;
});
}
function formatSourcesWithSnippets(sources: ResearchSource[], snippetLen = 200): string[] {
const lines: string[] = [];
sources.forEach((s, i) => {
const num = `[${i + 1}]`;
const titleLine = s.title ? `${num} [${s.title}](${s.url})` : `${num} ${s.url}`;
lines.push(titleLine);
if (s.snippet) {
const trimmed = s.snippet.length > snippetLen
? s.snippet.slice(0, snippetLen - 3) + "…"
: s.snippet;
lines.push(` > "${trimmed}"`);
}
lines.push("");
});
return lines;
}
function formatResult(
summary: string,
sources: ResearchSource[],
context: "facts" | "code" | "legal",
costLine: string
): string {
const lines: string[] = [];
if (context === "facts" || context === "legal") {
// Verbatim-Wrapper: Agent soll diesen Block unverändert ausgeben
const label = context === "legal"
? "⚠️ RECHTLICHE RECHERCHE — Bitte unverändert und vollständig ausgeben (keine Rechtsberatung):\n"
: "⚠️ RECHERCHE-ERGEBNIS — Bitte unverändert und vollständig ausgeben:\n";
lines.push(label);
lines.push(summary);
} else {
lines.push(summary);
}
if (sources.length > 0) {
lines.push("\n**Quellen:**");
if (context === "code") {
lines.push(...formatSourcesWithSnippets(sources, 200));
} else if (context === "legal") {
// Legal: längere Snippets (300 Z.) um Gesetzestext lesbar zu machen
lines.push(...formatSourcesWithSnippets(sources, 300));
} else {
lines.push(...formatSourcesFacts(sources));
}
} else {
lines.push("\n_(Keine Quellen im Response enthalten)_");
}
lines.push(`\n${costLine}`);
return lines.join("\n");
}
export default function researchWebExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "research_web",
label: "Web-Recherche",
description:
"Recherchiert live im Internet via Perplexity Sonar. " +
"Nutze dieses Tool wenn: aktuelle Fakten, Preise, Versionen, News, Gesetze, " +
"Produktänderungen oder unsichere/veraltete Informationen gefragt sind. " +
"Nicht nutzen für reine Logik, Mathematik oder stabile Grundlagen.",
promptGuidelines: [
"Use research_web when the question requires up-to-date or potentially outdated information.",
"Do NOT use research_web for stable knowledge, math, or logic questions.",
"Default to mode=fast. Use mode=deep only for complex multi-part questions, legal/medical/financial topics, or when fast results are clearly insufficient.",
"Default to context=facts. Use context=code for programming/API/documentation questions. Use context=legal for questions about laws, regulations, EU law, court rulings, or compliance.",
"For context=facts: Output the research result VERBATIM and COMPLETE — do not rewrite, paraphrase, or restructure. Preserve all [N] inline citation markers exactly where they appear in the text.",
"For context=code: Show each source with its indented snippet (> \"...\") exactly as provided — this saves the user from opening each URL.",
"For context=legal: Output the result VERBATIM. Always add a disclaimer that this is not legal advice. Preserve paragraph references (§ 13 DSGVO etc.) and [N] inline citations exactly.",
"Always include the complete numbered **Quellen:** section verbatim from the tool result, with all [N] numbers and URLs as clickable links.",
"Always include the cost line (the italicized [Perplexity: ...] line) verbatim at the end of your response.",
"If research_web returns no sources, flag the answer as potentially uncertain.",
"Set allowWikipedia=true ONLY if the user explicitly asks to use Wikipedia as a source.",
],
parameters: PARAMS,
async execute(_toolCallId, params, signal) {
const apiKey = process.env.PERPLEXITY_API_KEY;
if (!apiKey) {
return {
content: [{ type: "text", text: "Fehler: PERPLEXITY_API_KEY ist nicht gesetzt." }],
};
}
const context = normalizeContext(params.context);
// legal braucht immer deep — Gesetze erfordern gründliche Recherche
const mode = normalizeMode(params.mode, context === "legal" ? "deep" : "fast");
const recency = normalizeRecency(params.recency);
const model: "sonar" | "sonar-pro" = mode === "deep" ? "sonar-pro" : "sonar";
const contextSize: "low" | "high" = mode === "deep" ? "high" : "low";
const maxTokens = mode === "deep" ? 600 : 300;
const maxAttempts = 3;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const data = await callPerplexity(
params.query,
recency,
params.allowWikipedia,
context,
model,
contextSize,
maxTokens,
apiKey,
signal
);
const summary = data.choices?.[0]?.message?.content?.trim() ?? "";
if (!summary) {
throw new Error("Leere Antwort von Perplexity erhalten");
}
const sources = parseSources(data, context === "code" || context === "legal");
const usage = data.usage ?? {};
const promptTokens = usage.prompt_tokens ?? 0;
const completionTokens = usage.completion_tokens ?? 0;
const searchQueries = usage.search_queries ?? 0;
const finalModel = data.model ?? model;
const estimatedCostUSD = Number(
estimateCostUSD(finalModel, promptTokens, completionTokens, searchQueries).toFixed(6)
);
const costLine = formatCostLine(finalModel, mode, promptTokens, completionTokens, estimatedCostUSD);
const text = formatResult(summary, sources, context, costLine);
return {
content: [{ type: "text", text }],
details: {
model: finalModel,
mode,
context,
sourceCount: sources.length,
promptTokens: promptTokens || null,
completionTokens: completionTokens || null,
totalTokens: usage.total_tokens ?? null,
searchQueries: searchQueries || null,
estimatedCostUSD,
},
};
} catch (err) {
lastError = err;
// Nur bei transienten Fehlern warten und nochmals versuchen
if (err instanceof RetryableError && attempt < maxAttempts) {
await sleep(400 * 2 ** (attempt - 1));
} else {
break;
}
}
}
const msg = lastError instanceof Error ? lastError.message : "Unbekannter Fehler";
return { content: [{ type: "text", text: `Recherchefehler: ${msg}` }] };
},
});
}