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>
579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
/**
|
|
* 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();
|