Text_Agent/lib/jobs.ts

308 lines
8.6 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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");
}