781 lines
27 KiB
TypeScript
781 lines
27 KiB
TypeScript
|
|
/**
|
||
|
|
* 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();
|
||
|
|
}
|