/** * lib/jobs.ts * Job-Speicher für die Pipeline-Agenten. * * Verzeichnisstruktur: * ~/.pi/agent/jobs/_/ * ├── input.txt ← Originaltext * ├── claims.json ← Ausgabe ollama-claim-extractor (ClaimSet) * ├── perplexity/ * │ ├── c001.json ← Perplexity-Ergebnis pro Claim (PerplexityResult) * │ └── c002.json * ├── report.json ← Ausgabe verify-article (VerificationReport) * ├── article.md ← Ausgabe writer * └── meta.json ← Timestamp, Modell, Kosten, Status * * Verwendung: * import { createJob, findJobDir, saveJobFile, loadJobFile, updateJobMeta } from "../lib/jobs.js"; * const jobDir = createJob("umerziehung", "qwen3.5:27b"); * saveJobFile(jobDir, "claims.json", claimSet); * const cached = loadJobFile(jobDir, "claims.json"); * updateJobMeta(jobDir, { status: "verifying" }); */ import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; // --------------------------------------------------------------------------- // Konstanten // --------------------------------------------------------------------------- export const JOBS_DIR = join(homedir(), ".pi", "agent", "jobs"); export type JobStatus = | "created" | "extracting" | "verifying" | "writing" | "completed" | "failed"; export type JobMeta = { slug: string; jobId: string; // Verzeichnisname: _ model: string; createdAt: string; updatedAt: string; status: JobStatus; steps: { extract?: { completedAt: string; totalClaims: number; checkableClaims: number; latencyMs: number; }; verify?: { completedAt: string; claimsVerified: number; totalCostUSD: number; latencyMs: number; }; write?: { completedAt: string; style: string; wordCount: number; provider: string; costUSD: number; }; }; }; // --------------------------------------------------------------------------- // Interne Hilfsfunktionen // --------------------------------------------------------------------------- function ensureDir(dir: string): void { mkdirSync(dir, { recursive: true }); } // --------------------------------------------------------------------------- // Job erstellen // --------------------------------------------------------------------------- /** * Legt ein neues Job-Verzeichnis an und schreibt meta.json. * Gibt den absoluten Pfad zum Job-Verzeichnis zurück. * * @param slug Kurzer, menschenlesbarer Name (z.B. "umerziehung", "klimaartikel") * Erlaubte Zeichen: a–z, 0–9, Bindestrich, Unterstrich * @param model Das verwendete Ollama-Modell */ export function createJob(slug: string, model: string): string { ensureDir(JOBS_DIR); const date = new Date().toISOString().slice(0, 10); // "2026-04-16" const safeSlug = slug.toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 40); const jobId = `${date}_${safeSlug}`; const jobDir = join(JOBS_DIR, jobId); ensureDir(jobDir); ensureDir(join(jobDir, "perplexity")); const meta: JobMeta = { slug: safeSlug, jobId, model, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), status: "created", steps: {}, }; writeFileSync(join(jobDir, "meta.json"), JSON.stringify(meta, null, 2), "utf8"); return jobDir; } // --------------------------------------------------------------------------- // Job finden // --------------------------------------------------------------------------- /** * Sucht das neueste Job-Verzeichnis mit dem angegebenen Slug. * Gibt den absoluten Pfad zurück oder null wenn nicht gefunden. */ export function findJobDir(slug: string): string | null { try { const safeSlug = slug.toLowerCase().replace(/[^a-z0-9_-]/g, "_").slice(0, 40); const entries = readdirSync(JOBS_DIR) .filter((d) => { try { return statSync(join(JOBS_DIR, d)).isDirectory() && d.endsWith(`_${safeSlug}`); } catch { return false; } }) .sort() .reverse(); // Neueste zuerst (Datumspräfix sorgt für richtiges Sorting) return entries.length > 0 ? join(JOBS_DIR, entries[0]) : null; } catch { return null; } } /** * Sucht oder erstellt ein Job-Verzeichnis. * Wenn ein Job mit diesem Slug existiert: Wiederverwendung (Resume). * Wenn nicht: neuer Job. */ export function getOrCreateJob(slug: string, model: string): { jobDir: string; isNew: boolean } { const existing = findJobDir(slug); if (existing) { return { jobDir: existing, isNew: false }; } return { jobDir: createJob(slug, model), isNew: true }; } // --------------------------------------------------------------------------- // Dateien lesen / schreiben // --------------------------------------------------------------------------- /** * Schreibt eine Datei in das Job-Verzeichnis. * Bei JSON-Daten (object/array): automatisch serialisiert. * Bei string: direkt geschrieben. */ export function saveJobFile(jobDir: string, filename: string, data: unknown): void { const content = typeof data === "string" ? data : JSON.stringify(data, null, 2); writeFileSync(join(jobDir, filename), content, "utf8"); } /** * Liest eine Datei aus dem Job-Verzeichnis und parst sie als JSON. * Gibt null zurück wenn die Datei nicht existiert oder ungültiges JSON enthält. */ export function loadJobFile(jobDir: string, filename: string): T | null { try { const content = readFileSync(join(jobDir, filename), "utf8"); return JSON.parse(content) as T; } catch { return null; } } /** * Liest eine Datei als rohen String. Gibt null zurück wenn nicht vorhanden. */ export function loadJobText(jobDir: string, filename: string): string | null { try { return readFileSync(join(jobDir, filename), "utf8"); } catch { return null; } } /** * Prüft ob eine Datei im Job-Verzeichnis existiert. */ export function jobFileExists(jobDir: string, filename: string): boolean { try { statSync(join(jobDir, filename)); return true; } catch { return false; } } // --------------------------------------------------------------------------- // Meta-Daten aktualisieren // --------------------------------------------------------------------------- /** * Führt updates in meta.json ein (shallow merge, updatedAt wird automatisch gesetzt). */ export function updateJobMeta( jobDir: string, updates: Partial> & { steps?: Partial } ): void { const metaPath = join(jobDir, "meta.json"); let current: JobMeta = { slug: "", jobId: "", model: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), status: "created", steps: {}, }; try { current = JSON.parse(readFileSync(metaPath, "utf8")) as JobMeta; } catch { // Neue meta.json wenn nicht vorhanden } const updated: JobMeta = { ...current, ...updates, steps: { ...current.steps, ...updates.steps, }, updatedAt: new Date().toISOString(), }; writeFileSync(metaPath, JSON.stringify(updated, null, 2), "utf8"); } // --------------------------------------------------------------------------- // Jobs auflisten // --------------------------------------------------------------------------- /** * Gibt alle Jobs als Array von JobMeta zurück, neueste zuerst. */ export function listJobs(): JobMeta[] { try { ensureDir(JOBS_DIR); return readdirSync(JOBS_DIR) .filter((d) => { try { return statSync(join(JOBS_DIR, d)).isDirectory(); } catch { return false; } }) .sort() .reverse() .map((d) => { try { return JSON.parse(readFileSync(join(JOBS_DIR, d, "meta.json"), "utf8")) as JobMeta; } catch { return null; } }) .filter((m): m is JobMeta => m !== null); } catch { return []; } } /** * Formatiert eine Job-Liste als Tabelle für die CLI-Ausgabe. */ export function formatJobList(jobs: JobMeta[]): string { if (jobs.length === 0) return "Keine Jobs gefunden."; const STATUS_ICON: Record = { created: "○", extracting: "⟳", verifying: "⟳", writing: "⟳", completed: "✓", failed: "✗", }; const lines: string[] = [`Jobs in ${JOBS_DIR}:\n`]; for (const j of jobs) { const icon = STATUS_ICON[j.status] ?? "?"; const stepInfo: string[] = []; if (j.steps.extract) stepInfo.push(`${j.steps.extract.totalClaims} Claims`); if (j.steps.verify) stepInfo.push(`$${j.steps.verify.totalCostUSD.toFixed(4)} Perplexity`); if (j.steps.write) stepInfo.push(`${j.steps.write.wordCount}w ${j.steps.write.style}`); lines.push(`${icon} ${j.jobId} [${j.status}]${stepInfo.length ? " " + stepInfo.join(", ") : ""}`); } return lines.join("\n"); }