582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
|
|
/**
|
||
|
|
* llama-writer.ts
|
||
|
|
* Pi-Extension + CLI: Artikel schreiben via llama.cpp (lokales LLM)
|
||
|
|
*
|
||
|
|
* Schreibt einen Artikel NUR auf Basis von "supported"-Claims aus einem VerificationReport.
|
||
|
|
* Widerlgte, gemischte oder unzureichend belegte Claims werden automatisch ausgeschlossen.
|
||
|
|
*
|
||
|
|
* Kein Ollama-format-Parameter — Schema steht als JSON-Literal im System-Prompt.
|
||
|
|
* /no_think deaktiviert den Thinking-Modus bei Qwen3/Qwopus-Reasoning-Modellen.
|
||
|
|
*
|
||
|
|
* Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink)
|
||
|
|
* Als CLI:
|
||
|
|
* npx tsx agenten/llama-writer.ts --from-job <slug> --style blog
|
||
|
|
* npx tsx agenten/llama-verify-article.ts --json "$(cat artikel.txt)" | npx tsx agenten/llama-writer.ts --from-report
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||
|
|
import { Type } from "@sinclair/typebox";
|
||
|
|
import { fileURLToPath } from "node:url";
|
||
|
|
import type { VerificationReport } from "./llama-verify-article.js";
|
||
|
|
import {
|
||
|
|
findJobDir,
|
||
|
|
loadJobFile,
|
||
|
|
saveJobFile,
|
||
|
|
updateJobMeta,
|
||
|
|
} from "../lib/jobs.js";
|
||
|
|
import { createLogger, nullLogger, type Logger } from "../lib/logger.js";
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Typen
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
type Style = "journalistic" | "blog" | "academic" | "editorial" | "explanatory";
|
||
|
|
|
||
|
|
type ArticleDraft = {
|
||
|
|
schema_version: "1.0.0";
|
||
|
|
title: string;
|
||
|
|
lead: string;
|
||
|
|
body: string;
|
||
|
|
conclusion: string | null;
|
||
|
|
style: Style;
|
||
|
|
language: string;
|
||
|
|
word_count: number;
|
||
|
|
claim_ids_used: string[];
|
||
|
|
sources: Array<{ number: number; url: string; title: string | null; claim_id: string }>;
|
||
|
|
excluded_claims: string[];
|
||
|
|
editorial_notes: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
// llama.cpp OpenAI-kompatibles API-Format
|
||
|
|
type LlamaResponse = {
|
||
|
|
choices: Array<{
|
||
|
|
message?: { content?: string; reasoning_content?: string };
|
||
|
|
finish_reason?: string;
|
||
|
|
}>;
|
||
|
|
usage?: {
|
||
|
|
prompt_tokens?: number;
|
||
|
|
completion_tokens?: number;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
export type WriteResult = {
|
||
|
|
draft: ArticleDraft;
|
||
|
|
provider: "llama";
|
||
|
|
model: string;
|
||
|
|
costUSD: 0;
|
||
|
|
latencyMs: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Konfiguration
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf";
|
||
|
|
const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000";
|
||
|
|
const MAX_TOKENS = 16384;
|
||
|
|
const TEMPERATURE = 0.4;
|
||
|
|
const MAX_RETRIES = 3;
|
||
|
|
const RETRY_DELAY_MS = 15_000;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Schema + Prompt-Generierung
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const STYLE_GUIDE: Record<Style, string> = {
|
||
|
|
journalistic:
|
||
|
|
"Journalistisch: präzise, faktenbasiert, W-Fragen im Einleitungssatz, Inverted Pyramid, " +
|
||
|
|
"zitierbare Aussagen direkt belegt, keine Meinungen ohne Kennzeichnung.",
|
||
|
|
blog:
|
||
|
|
"Blog: zugänglich, ansprechend, erste Person erlaubt, direkte Ansprache des Lesers, " +
|
||
|
|
"lebendige Sprache, Zwischenüberschriften als Orientierung.",
|
||
|
|
academic:
|
||
|
|
"Akademisch: präzise Terminologie, passive Formulierungen, klare Abschnittsstruktur " +
|
||
|
|
"(Einleitung, Hauptteil, Schluss), Quellenverweise inline.",
|
||
|
|
editorial:
|
||
|
|
"Leitartikel: klare Haltung, argumentativ, Bezug zur aktuellen Debatte, " +
|
||
|
|
"stützt sich auf Fakten aber formuliert Bewertung.",
|
||
|
|
explanatory:
|
||
|
|
"Erklärstück: vereinfacht komplexe Sachverhalte, Analogien und Beispiele, " +
|
||
|
|
"schrittweise Struktur, Leserfragen antizipieren.",
|
||
|
|
};
|
||
|
|
|
||
|
|
function buildWriterSystemPrompt(style: Style, language: string, wordCount: number): string {
|
||
|
|
const langName = language === "de" ? "Deutsch" : language === "en" ? "Englisch" : language;
|
||
|
|
return `Du bist ein erfahrener Autor. Schreibe einen Artikel nach folgenden Vorgaben:
|
||
|
|
|
||
|
|
STIL: ${STYLE_GUIDE[style]}
|
||
|
|
SPRACHE: ${langName}
|
||
|
|
LÄNGE: ca. ${wordCount} Wörter
|
||
|
|
|
||
|
|
Antworte AUSSCHLIESSLICH mit einem JSON-Objekt gemäß folgendem Schema:
|
||
|
|
{
|
||
|
|
"title": "Artikeltitel (string)",
|
||
|
|
"lead": "Einleitungsabsatz (string)",
|
||
|
|
"body": "Haupttext mit Quellenangaben [N] (string)",
|
||
|
|
"conclusion": "Schlussabsatz oder null",
|
||
|
|
"editorial_notes": "Was fehlt für einen vollständigen Artikel? (string)"
|
||
|
|
}
|
||
|
|
|
||
|
|
REGELN:
|
||
|
|
- Alle Felder required: title, lead, body, conclusion, editorial_notes
|
||
|
|
- conclusion darf null sein
|
||
|
|
- Verwende NUR die vom Nutzer übergebenen verifizierten Claims als Faktengrundlage
|
||
|
|
- Kennzeichne jeden Fakt mit Inline-Quellenverweisen [N] aus der Beleg-Liste
|
||
|
|
- Erfinde keine Fakten, Zahlen oder Zitate
|
||
|
|
- Kein Freitext vor oder nach dem JSON-Objekt`;
|
||
|
|
}
|
||
|
|
|
||
|
|
type ClaimForWriting = {
|
||
|
|
id: string;
|
||
|
|
text: string;
|
||
|
|
sources: Array<{ url: string; title: string | null }>;
|
||
|
|
};
|
||
|
|
|
||
|
|
function buildWriterUserPrompt(claims: ClaimForWriting[], topic: string): string {
|
||
|
|
const claimsText = claims
|
||
|
|
.map((c, i) => {
|
||
|
|
const srcList = c.sources
|
||
|
|
.map((s, j) => `[${i * 10 + j + 1}] ${s.title ?? s.url} (${s.url})`)
|
||
|
|
.join("\n ");
|
||
|
|
return `Claim ${c.id}: ${c.text}\n Belege:\n ${srcList || "(keine URL)"}`;
|
||
|
|
})
|
||
|
|
.join("\n\n");
|
||
|
|
|
||
|
|
return `/no_think\nSchreibe einen Artikel zum Thema: "${topic}"\n\nVERIFIZIERTE FAKTEN (nur diese dürfen verwendet werden):\n${claimsText}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// llama.cpp-Aufruf
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
async function writeWithLlama(
|
||
|
|
claims: ClaimForWriting[],
|
||
|
|
style: Style,
|
||
|
|
topic: string,
|
||
|
|
wordCount: number,
|
||
|
|
language: string,
|
||
|
|
model: string,
|
||
|
|
signal?: AbortSignal,
|
||
|
|
logger?: Logger
|
||
|
|
): Promise<{ raw: Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">; tokensIn: number; tokensOut: number; latencyMs: number }> {
|
||
|
|
const log = logger ?? nullLogger;
|
||
|
|
const t0 = Date.now();
|
||
|
|
|
||
|
|
const body = {
|
||
|
|
model,
|
||
|
|
messages: [
|
||
|
|
{ role: "system", content: buildWriterSystemPrompt(style, language, wordCount) },
|
||
|
|
{ role: "user", content: buildWriterUserPrompt(claims, topic) },
|
||
|
|
],
|
||
|
|
stream: false,
|
||
|
|
temperature: TEMPERATURE,
|
||
|
|
max_tokens: MAX_TOKENS,
|
||
|
|
};
|
||
|
|
|
||
|
|
log.debug("llama.cpp-Writer gestartet", { model, claimCount: claims.length, style, language, wordCount });
|
||
|
|
|
||
|
|
let resp: Response | null = null;
|
||
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||
|
|
try {
|
||
|
|
resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify(body),
|
||
|
|
signal,
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
} catch (err) {
|
||
|
|
const isLast = attempt === MAX_RETRIES;
|
||
|
|
log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, {
|
||
|
|
error: err instanceof Error ? err.message : String(err),
|
||
|
|
retryInMs: isLast ? 0 : RETRY_DELAY_MS,
|
||
|
|
});
|
||
|
|
if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`);
|
||
|
|
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!resp!.ok) {
|
||
|
|
const errorText = await resp!.text().catch(() => "");
|
||
|
|
throw new Error(`llama.cpp API Fehler ${resp!.status}: ${errorText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = (await resp!.json()) as LlamaResponse;
|
||
|
|
const choice = data.choices?.[0];
|
||
|
|
let raw = choice?.message?.content ?? "";
|
||
|
|
|
||
|
|
// Reasoning-Fallback: Wenn content leer, JSON aus reasoning_content extrahieren
|
||
|
|
if (!raw.trim() && choice?.message?.reasoning_content) {
|
||
|
|
const rc = choice.message.reasoning_content;
|
||
|
|
const allMatches = [...rc.matchAll(/\{[^{}]*"title"\s*:/g)];
|
||
|
|
const lastBlock = allMatches.length > 0
|
||
|
|
? rc.match(/\{[\s\S]*"title"[\s\S]*\}/)?.[0]
|
||
|
|
: undefined;
|
||
|
|
if (lastBlock) {
|
||
|
|
raw = lastBlock;
|
||
|
|
log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", {
|
||
|
|
finishReason: choice.finish_reason,
|
||
|
|
rawLength: raw.length,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Markdown-Codeblöcke entfernen
|
||
|
|
const cleanedRaw = raw
|
||
|
|
.replace(/^```(?:json)?\s*/i, "")
|
||
|
|
.replace(/\s*```$/i, "")
|
||
|
|
.trim();
|
||
|
|
|
||
|
|
log.debug("llama.cpp-Writer Antwort", {
|
||
|
|
promptTokens: data.usage?.prompt_tokens,
|
||
|
|
outputTokens: data.usage?.completion_tokens,
|
||
|
|
finishReason: choice?.finish_reason,
|
||
|
|
rawLength: cleanedRaw.length,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!cleanedRaw) throw new Error("Leere Antwort von llama.cpp-Writer");
|
||
|
|
|
||
|
|
let parsed: unknown;
|
||
|
|
try {
|
||
|
|
parsed = JSON.parse(cleanedRaw);
|
||
|
|
} catch {
|
||
|
|
throw new Error(`llama.cpp-Writer-Ausgabe ist kein gültiges JSON: ${cleanedRaw.slice(0, 200)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const p = parsed as Record<string, unknown>;
|
||
|
|
if (typeof p.title !== "string" || typeof p.body !== "string") {
|
||
|
|
throw new Error(`Ungültige Struktur: 'title' oder 'body' fehlt. Keys: ${Object.keys(p).join(", ")}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
raw: p as Pick<ArticleDraft, "title" | "lead" | "body" | "conclusion" | "editorial_notes">,
|
||
|
|
tokensIn: data.usage?.prompt_tokens ?? 0,
|
||
|
|
tokensOut: data.usage?.completion_tokens ?? 0,
|
||
|
|
latencyMs: Date.now() - t0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Quellenverzeichnis aufbauen
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function buildSourceIndex(claims: ClaimForWriting[]): Array<{ number: number; url: string; title: string | null; claim_id: string }> {
|
||
|
|
const sources: Array<{ number: number; url: string; title: string | null; claim_id: string }> = [];
|
||
|
|
let n = 1;
|
||
|
|
for (const c of claims) {
|
||
|
|
for (const s of c.sources) {
|
||
|
|
sources.push({ number: n++, url: s.url, title: s.title, claim_id: c.id });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return sources;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Hauptfunktion
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
export async function writeFromReport(
|
||
|
|
report: VerificationReport,
|
||
|
|
options?: {
|
||
|
|
style?: Style;
|
||
|
|
topic?: string;
|
||
|
|
wordCount?: number;
|
||
|
|
language?: string;
|
||
|
|
model?: string;
|
||
|
|
signal?: AbortSignal;
|
||
|
|
logger?: Logger;
|
||
|
|
}
|
||
|
|
): Promise<WriteResult> {
|
||
|
|
const log = options?.logger ?? nullLogger;
|
||
|
|
const style = options?.style ?? "journalistic";
|
||
|
|
const wordCount = options?.wordCount ?? 400;
|
||
|
|
const language = options?.language ?? "de";
|
||
|
|
const model = options?.model ?? DEFAULT_MODEL;
|
||
|
|
|
||
|
|
const supported = report.results.filter((r) => r.status === "supported");
|
||
|
|
const excluded = report.results.filter((r) => r.status !== "supported").map((r) => r.claim_id);
|
||
|
|
|
||
|
|
if (supported.length === 0) {
|
||
|
|
throw new Error("Keine verifizierten (supported) Claims im Report — kein Artikel möglich.");
|
||
|
|
}
|
||
|
|
|
||
|
|
const topic = options?.topic ?? report.source_text_summary ?? "Artikel";
|
||
|
|
|
||
|
|
const claims: ClaimForWriting[] = supported.map((r) => ({
|
||
|
|
id: r.claim_id,
|
||
|
|
text: r.claim_text,
|
||
|
|
sources: r.sources
|
||
|
|
.filter((s) => s.supports_claim)
|
||
|
|
.map((s) => ({ url: s.url, title: s.title })),
|
||
|
|
}));
|
||
|
|
|
||
|
|
log.info(`llama.cpp-Writer: ${claims.length} Claims, Stil: ${style}, Sprache: ${language}, Ziel: ${wordCount} Wörter`);
|
||
|
|
|
||
|
|
const result = await writeWithLlama(claims, style, topic, wordCount, language, model, options?.signal, log);
|
||
|
|
|
||
|
|
const sources = buildSourceIndex(claims);
|
||
|
|
const wordCountActual = (result.raw.lead + " " + result.raw.body + " " + (result.raw.conclusion ?? ""))
|
||
|
|
.split(/\s+/).filter(Boolean).length;
|
||
|
|
|
||
|
|
const draft: ArticleDraft = {
|
||
|
|
...result.raw,
|
||
|
|
schema_version: "1.0.0" as const,
|
||
|
|
style,
|
||
|
|
language,
|
||
|
|
word_count: wordCountActual,
|
||
|
|
claim_ids_used: claims.map((c) => c.id),
|
||
|
|
sources,
|
||
|
|
excluded_claims: excluded,
|
||
|
|
editorial_notes: result.raw.editorial_notes ?? "",
|
||
|
|
};
|
||
|
|
|
||
|
|
return { draft, provider: "llama", model, costUSD: 0, latencyMs: result.latencyMs };
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Formatierung
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
export function formatDraft(result: WriteResult): string {
|
||
|
|
const { draft } = result;
|
||
|
|
const lines: string[] = [];
|
||
|
|
|
||
|
|
lines.push(`# ${draft.title}`);
|
||
|
|
lines.push("");
|
||
|
|
lines.push(`_${draft.lead}_`);
|
||
|
|
lines.push("");
|
||
|
|
lines.push(draft.body);
|
||
|
|
|
||
|
|
if (draft.conclusion) {
|
||
|
|
lines.push("");
|
||
|
|
lines.push("---");
|
||
|
|
lines.push(draft.conclusion);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (draft.sources.length > 0) {
|
||
|
|
lines.push("\n**Quellen:**");
|
||
|
|
draft.sources.forEach((s) => {
|
||
|
|
const title = s.title ?? s.url;
|
||
|
|
lines.push(`[${s.number}] [${title}](${s.url})`);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (draft.excluded_claims.length > 0) {
|
||
|
|
lines.push(`\n_${draft.excluded_claims.length} Claim(s) ausgeschlossen (nicht verifiziert): ${draft.excluded_claims.join(", ")}_`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (draft.editorial_notes) {
|
||
|
|
lines.push(`\n**Redaktionshinweise:** ${draft.editorial_notes}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const latSec = (result.latencyMs / 1000).toFixed(1);
|
||
|
|
lines.push(`\n_[llama.cpp: ${result.model} · ${draft.word_count} Wörter · kostenlos (lokal) · ${latSec}s]_`);
|
||
|
|
|
||
|
|
return lines.join("\n");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Pi-Extension
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const PARAMS = Type.Object({
|
||
|
|
reportJson: Type.String({
|
||
|
|
description:
|
||
|
|
"JSON-String eines VerificationReport (Ausgabe von verify_article_llama --json oder verify_article_llama). " +
|
||
|
|
"Nur 'supported'-Claims werden für den Artikel verwendet.",
|
||
|
|
}),
|
||
|
|
topic: Type.Optional(
|
||
|
|
Type.String({ description: "Artikelthema / Überschrift. Standard: wird aus dem Report abgeleitet." })
|
||
|
|
),
|
||
|
|
style: Type.Optional(
|
||
|
|
Type.Union(
|
||
|
|
[
|
||
|
|
Type.Literal("journalistic"),
|
||
|
|
Type.Literal("blog"),
|
||
|
|
Type.Literal("academic"),
|
||
|
|
Type.Literal("editorial"),
|
||
|
|
Type.Literal("explanatory"),
|
||
|
|
],
|
||
|
|
{ description: "Schreibstil. Standard: journalistic." }
|
||
|
|
)
|
||
|
|
),
|
||
|
|
wordCount: Type.Optional(
|
||
|
|
Type.Number({ description: "Ziel-Wortanzahl. Standard: 400." })
|
||
|
|
),
|
||
|
|
language: Type.Optional(
|
||
|
|
Type.String({ description: "Sprache (ISO 639-1). Standard: de." })
|
||
|
|
),
|
||
|
|
model: Type.Optional(
|
||
|
|
Type.String({ description: "llama.cpp-Modell-Override." })
|
||
|
|
),
|
||
|
|
});
|
||
|
|
|
||
|
|
export default function llamaWriterExtension(pi: ExtensionAPI) {
|
||
|
|
pi.registerTool({
|
||
|
|
name: "write_article_llama",
|
||
|
|
label: "Artikel schreiben (llama.cpp)",
|
||
|
|
description:
|
||
|
|
"Schreibt einen Artikel ausschließlich auf Basis verifizierter Claims aus einem VerificationReport. " +
|
||
|
|
"Verwendet llama.cpp lokal (kostenlos, kein Ollama-Timeout bei Thinking-Modellen). " +
|
||
|
|
"BEVORZUGT gegenüber write_article (Ollama). " +
|
||
|
|
"Workflow: verify_article_llama → write_article_llama.",
|
||
|
|
promptGuidelines: [
|
||
|
|
"PREFERRED: Use write_article_llama for all article generation (local, free, no timeout issues).",
|
||
|
|
"Use write_article (Ollama) only when explicitly requested by the user.",
|
||
|
|
"Always pass the full JSON output of verify_article or verify_article_llama as 'reportJson'.",
|
||
|
|
"Ask the user for the desired style (journalistic, blog, academic, editorial, explanatory) if not specified.",
|
||
|
|
"Show the full formatted draft including sources and editorial notes.",
|
||
|
|
"Point out excluded claims to the user — these may be important context that was removed.",
|
||
|
|
"If editorial_notes mention missing information, suggest running additional research.",
|
||
|
|
],
|
||
|
|
parameters: PARAMS,
|
||
|
|
async execute(_toolCallId, params, signal) {
|
||
|
|
try {
|
||
|
|
const report = JSON.parse(params.reportJson) as VerificationReport;
|
||
|
|
const result = await writeFromReport(report, {
|
||
|
|
style: params.style,
|
||
|
|
topic: params.topic,
|
||
|
|
wordCount: params.wordCount,
|
||
|
|
language: params.language,
|
||
|
|
model: params.model,
|
||
|
|
signal,
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
content: [{ type: "text", text: formatDraft(result) }],
|
||
|
|
details: {
|
||
|
|
wordCount: result.draft.word_count,
|
||
|
|
claimsUsed: result.draft.claim_ids_used.length,
|
||
|
|
claimsExcluded: result.draft.excluded_claims.length,
|
||
|
|
provider: result.provider,
|
||
|
|
latencyMs: result.latencyMs,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch (err) {
|
||
|
|
const msg = err instanceof Error ? err.message : "Unbekannter Fehler";
|
||
|
|
return { content: [{ type: "text", text: `Artikelgenerierung fehlgeschlagen: ${msg}` }] };
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// CLI
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
async function runCli() {
|
||
|
|
const args = process.argv.slice(2);
|
||
|
|
|
||
|
|
if (args.length === 0 || args[0] === "--help") {
|
||
|
|
console.log(`
|
||
|
|
Llama-Writer — Schreibt Artikel via llama.cpp auf Basis verifizierter Claims
|
||
|
|
|
||
|
|
Verwendung:
|
||
|
|
# Via Job-Speicher (empfohlen):
|
||
|
|
npx tsx agenten/llama-verify-article.ts --job-id umerziehung "$(cat artikel.txt)"
|
||
|
|
npx tsx agenten/llama-writer.ts --from-job umerziehung --style blog
|
||
|
|
|
||
|
|
# Via Pipe:
|
||
|
|
npx tsx agenten/llama-verify-article.ts --json "..." | npx tsx agenten/llama-writer.ts --from-report
|
||
|
|
|
||
|
|
Optionen:
|
||
|
|
--from-report Lese VerificationReport von stdin (JSON)
|
||
|
|
--from-job <slug> Lese report.json aus Job ~/.pi/agent/jobs/<datum>_<slug>/
|
||
|
|
Speichert article.md automatisch zurück in den Job
|
||
|
|
--style <s> journalistic|blog|academic|editorial|explanatory (Standard: journalistic)
|
||
|
|
--topic <text> Artikelthema
|
||
|
|
--words <n> Ziel-Wortanzahl (Standard: 400)
|
||
|
|
--lang <code> Sprache (Standard: de)
|
||
|
|
--model <name> Modell-Override (Standard: ${DEFAULT_MODEL})
|
||
|
|
--json Ausgabe als JSON
|
||
|
|
--verbose Ausführliches Logging
|
||
|
|
--help Diese Hilfe
|
||
|
|
`);
|
||
|
|
process.exit(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
let fromReport = false;
|
||
|
|
let fromJobSlug: string | undefined;
|
||
|
|
let style: Style = "journalistic";
|
||
|
|
let topic: string | undefined;
|
||
|
|
let wordCount = 400;
|
||
|
|
let language = "de";
|
||
|
|
let model: string | undefined;
|
||
|
|
let jsonOutput = false;
|
||
|
|
let verbose = false;
|
||
|
|
|
||
|
|
for (let i = 0; i < args.length; i++) {
|
||
|
|
const arg = args[i];
|
||
|
|
if (arg === "--from-report") fromReport = true;
|
||
|
|
else if (arg === "--from-job" && args[i + 1]) fromJobSlug = args[++i];
|
||
|
|
else if (arg === "--style" && args[i + 1]) style = args[++i] as Style;
|
||
|
|
else if (arg === "--topic" && args[i + 1]) topic = args[++i];
|
||
|
|
else if (arg === "--words" && args[i + 1]) wordCount = parseInt(args[++i], 10);
|
||
|
|
else if (arg === "--lang" && args[i + 1]) language = args[++i];
|
||
|
|
else if (arg === "--model" && args[i + 1]) model = args[++i];
|
||
|
|
else if (arg === "--json") jsonOutput = true;
|
||
|
|
else if (arg === "--verbose") verbose = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const logger = verbose ? createLogger({ verbose: true }) : nullLogger;
|
||
|
|
|
||
|
|
let report: VerificationReport;
|
||
|
|
let jobDir: string | undefined;
|
||
|
|
|
||
|
|
if (fromJobSlug) {
|
||
|
|
const dir = findJobDir(fromJobSlug);
|
||
|
|
if (!dir) {
|
||
|
|
console.error(`Fehler: Kein Job mit Slug "${fromJobSlug}" gefunden in ~/.pi/agent/jobs/`);
|
||
|
|
console.error("Tipp: Zuerst llama-verify-article.ts --job-id <slug> ausführen.");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
jobDir = dir;
|
||
|
|
const loaded = loadJobFile<VerificationReport>(dir, "report.json");
|
||
|
|
if (!loaded) {
|
||
|
|
console.error(`Fehler: Kein report.json in Job ${dir}`);
|
||
|
|
console.error("Tipp: llama-verify-article.ts --job-id <slug> muss zuerst abgeschlossen werden.");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
report = loaded;
|
||
|
|
if (!jsonOutput) console.error(`\nJob: ${dir}\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`);
|
||
|
|
} else if (fromReport) {
|
||
|
|
const chunks: Buffer[] = [];
|
||
|
|
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
||
|
|
const input = Buffer.concat(chunks).toString("utf-8").trim();
|
||
|
|
if (!input) { console.error("Fehler: Kein Input von stdin."); process.exit(1); }
|
||
|
|
report = JSON.parse(input) as VerificationReport;
|
||
|
|
if (!jsonOutput) console.error(`\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`);
|
||
|
|
} else {
|
||
|
|
console.error("Fehler: --from-report oder --from-job <slug> erforderlich.");
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await writeFromReport(report, { style, topic, wordCount, language, model, logger });
|
||
|
|
|
||
|
|
if (jobDir) {
|
||
|
|
saveJobFile(jobDir, "article.md", formatDraft(result));
|
||
|
|
updateJobMeta(jobDir, {
|
||
|
|
status: "completed",
|
||
|
|
steps: {
|
||
|
|
write: {
|
||
|
|
completedAt: new Date().toISOString(),
|
||
|
|
style,
|
||
|
|
wordCount: result.draft.word_count,
|
||
|
|
provider: result.provider,
|
||
|
|
costUSD: 0,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
if (!jsonOutput) process.stderr.write(`\n Artikel in Job gespeichert: ${jobDir}/article.md\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(jsonOutput ? JSON.stringify(result.draft, null, 2) : formatDraft(result));
|
||
|
|
} catch (err) {
|
||
|
|
if (jobDir) updateJobMeta(jobDir, { status: "failed" });
|
||
|
|
console.error("Fehler:", err instanceof Error ? err.message : err);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const __filename = fileURLToPath(import.meta.url);
|
||
|
|
if (process.argv[1] === __filename) runCli();
|