feat: Pi Text-Agent — initialer Commit (sauberes Repo)
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>
This commit is contained in:
commit
5146b7fa30
62 changed files with 11279 additions and 0 deletions
579
agenten/ollama-writer.ts
Normal file
579
agenten/ollama-writer.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
/**
|
||||
* 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue