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:
commit
5146b7fa30
62 changed files with 11279 additions and 0 deletions
781
agenten/llama-claim-extractor.ts
Normal file
781
agenten/llama-claim-extractor.ts
Normal 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();
|
||||
}
|
||||
522
agenten/llama-logic-editor.ts
Normal file
522
agenten/llama-logic-editor.ts
Normal 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
552
agenten/llama-verifier.ts
Normal 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();
|
||||
}
|
||||
838
agenten/llama-verify-article.ts
Normal file
838
agenten/llama-verify-article.ts
Normal 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.05–0.15 für einen Artikel mit 10–15 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
582
agenten/llama-writer.ts
Normal 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();
|
||||
697
agenten/ollama-claim-extractor.ts
Normal file
697
agenten/ollama-claim-extractor.ts
Normal 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();
|
||||
}
|
||||
567
agenten/ollama-logic-editor.ts
Normal file
567
agenten/ollama-logic-editor.ts
Normal 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
450
agenten/ollama-verifier.ts
Normal 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();
|
||||
}
|
||||
809
agenten/ollama-verify-article.ts
Normal file
809
agenten/ollama-verify-article.ts
Normal 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.05–0.15 für einen Artikel mit 10–15 Claims.",
|
||||
promptGuidelines: [
|
||||
"Use verify_article when the user wants to fact-check an entire article, blog post, or longer text.",
|
||||
"Use verify_claim instead when the user wants to check a single specific claim.",
|
||||
"Pass the FULL article text — do not summarize it first.",
|
||||
"Use mode=deep for scientific, medical, legal, or politically sensitive content.",
|
||||
"Always show the full formatted report including the cost/latency line.",
|
||||
"Highlight contradicted claims and claims needing human review prominently.",
|
||||
"If needs_human_review claims exist, explain to the user that they require manual fact-checking.",
|
||||
"After the report, offer to show full sources for specific claims if the user wants details.",
|
||||
],
|
||||
parameters: PARAMS,
|
||||
async execute(_toolCallId, params, signal) {
|
||||
try {
|
||||
const report = await verifyArticle(params.text, {
|
||||
maxClaims: params.maxClaims,
|
||||
mode: params.mode,
|
||||
model: params.model,
|
||||
signal,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: formatReport(report) }],
|
||||
details: {
|
||||
totalClaims: report.stats.total,
|
||||
supported: report.stats.supported,
|
||||
contradicted: report.stats.contradicted,
|
||||
needsHumanReview: report.stats.needs_human_review,
|
||||
totalCostUSD: report.totalCostUSD,
|
||||
latencyMs: report.latencyMs,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
|
||||
return { content: [{ type: "text", text: `Artikel-Verifikation fehlgeschlagen: ${msg}` }] };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI-Modus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args[0] === "--help") {
|
||||
console.log(`
|
||||
Artikel-Verifikator — Vollständige Fact-Check-Pipeline
|
||||
|
||||
Verwendung:
|
||||
npx tsx agenten/ollama-verify-article.ts [Optionen] "Artikeltext..."
|
||||
npx tsx agenten/ollama-verify-article.ts [Optionen] "$(cat artikel.txt)"
|
||||
|
||||
Optionen:
|
||||
--mode fast|deep Perplexity-Modus (Standard: fast)
|
||||
--model <name> Ollama-Modell (Standard: ${DEFAULT_MODEL})
|
||||
--max-claims <n> Max. Claims (Standard: ${DEFAULT_MAX_CLAIMS})
|
||||
--job-id <slug> Job-Speicher aktivieren: Zwischenergebnisse nach ~/.pi/agent/jobs/<datum>_<slug>/
|
||||
Bei Unterbrechung: einfach erneut aufrufen — gecachte Ergebnisse werden wiederverwendet
|
||||
--json Ausgabe als JSON
|
||||
--no-cache Globalen Claim-Cache deaktivieren (erzwingt neue Perplexity-Anfragen)
|
||||
--verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/
|
||||
--help Diese Hilfe
|
||||
|
||||
Beispiele:
|
||||
# Erstlauf mit Job-Speicher:
|
||||
npx tsx agenten/ollama-verify-article.ts --job-id umerziehung "$(cat umerziehung.md)"
|
||||
|
||||
# Bei Absturz: einfach nochmal aufrufen — gecachte Claims + Perplexity-Ergebnisse werden wiederverwendet:
|
||||
npx tsx agenten/ollama-verify-article.ts --job-id umerziehung "$(cat umerziehung.md)"
|
||||
|
||||
# Report aus Job an Writer übergeben:
|
||||
npx tsx agenten/writer.ts --from-job umerziehung --style blog
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let mode: "fast" | "deep" = "fast";
|
||||
let model = DEFAULT_MODEL;
|
||||
let maxClaims = DEFAULT_MAX_CLAIMS;
|
||||
let jobId: string | undefined;
|
||||
let jsonOutput = false;
|
||||
let verbose = false;
|
||||
let noCache = false;
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--mode" && args[i + 1]) {
|
||||
const m = args[++i];
|
||||
if (m === "fast" || m === "deep") mode = m;
|
||||
} else if (arg === "--model" && args[i + 1]) {
|
||||
model = args[++i];
|
||||
} else if (arg === "--max-claims" && args[i + 1]) {
|
||||
maxClaims = parseInt(args[++i], 10);
|
||||
} else if (arg === "--job-id" && args[i + 1]) {
|
||||
jobId = args[++i];
|
||||
} else if (arg === "--json") {
|
||||
jsonOutput = true;
|
||||
} else if (arg === "--verbose" || arg === "-v") {
|
||||
verbose = true;
|
||||
} else if (arg === "--no-cache") {
|
||||
noCache = true;
|
||||
} else if (!arg.startsWith("--")) {
|
||||
textParts.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const text = textParts.join(" ").trim();
|
||||
if (!text) {
|
||||
console.error("Fehler: Kein Text übergeben.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!jsonOutput) {
|
||||
console.error(`\nModus: ${mode} | Modell: ${model} | Max. Claims: ${maxClaims}${jobId ? ` | Job: ${jobId}` : ""}\n`);
|
||||
}
|
||||
|
||||
const log = createLogger({ verbose, jobId });
|
||||
const onProgress = jsonOutput
|
||||
? undefined
|
||||
: (msg: string) => process.stderr.write(` ${msg}\n`);
|
||||
|
||||
// Job-Verzeichnis anlegen oder vorhandenes wiederverwenden
|
||||
let jobDir: string | undefined;
|
||||
if (jobId) {
|
||||
const { jobDir: dir, isNew } = getOrCreateJob(jobId, model);
|
||||
jobDir = dir;
|
||||
// Originaltext speichern (nur beim ersten Mal, nicht überschreiben)
|
||||
if (isNew) {
|
||||
saveJobFile(jobDir, "input.txt", text);
|
||||
}
|
||||
if (!jsonOutput) {
|
||||
process.stderr.write(` Job: ${jobDir} (${isNew ? "neu" : "fortgesetzt"})\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const report = await verifyArticle(text, { maxClaims, mode, model, onProgress, logger: log, jobDir, noCache });
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
console.log(formatReport(report));
|
||||
}
|
||||
} catch (err) {
|
||||
if (jobDir) {
|
||||
updateJobMeta(jobDir, { status: "failed" });
|
||||
}
|
||||
console.error("Fehler:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === __filename) {
|
||||
runCli();
|
||||
}
|
||||
579
agenten/ollama-writer.ts
Normal file
579
agenten/ollama-writer.ts
Normal 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
431
agenten/research-web.ts
Normal 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}` }] };
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue