Text_Agent/lib/jobs.ts
dschlueter 5146b7fa30 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>
2026-05-12 04:21:48 +02:00

308 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: az, 09, 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");
}