Text_Agent/agenten/ollama-logic-editor.ts

567 lines
19 KiB
TypeScript
Raw Permalink Normal View History

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