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>
308 lines
8.6 KiB
TypeScript
308 lines
8.6 KiB
TypeScript
/**
|
||
* lib/jobs.ts
|
||
* Job-Speicher für die Pipeline-Agenten.
|
||
*
|
||
* Verzeichnisstruktur:
|
||
* ~/.pi/agent/jobs/<datum>_<slug>/
|
||
* ├── 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<ClaimSet>(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: <datum>_<slug>
|
||
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<T>(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<Omit<JobMeta, "slug" | "jobId" | "createdAt">> & { steps?: Partial<JobMeta["steps"]> }
|
||
): 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<JobStatus, string> = {
|
||
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");
|
||
}
|