/** * 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 --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 = { 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; 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; 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; 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; 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 { 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; 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 Lese report.json aus Job ~/.pi/agent/jobs/_/ Speichert article.md automatisch zurück in den Job --style journalistic|blog|academic|editorial|explanatory (Standard: journalistic) --topic Artikelthema --words Ziel-Wortanzahl (Standard: 400) --lang Sprache (Standard: de) --cloud OpenRouter verwenden --model 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 ausführen."); process.exit(1); } jobDir = dir; const loaded = loadJobFile(dir, "report.json"); if (!loaded) { console.error(`Fehler: Kein report.json in Job ${dir}`); console.error("Tipp: verify-article.ts --job-id 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 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();