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
308
lib/jobs.ts
Normal file
308
lib/jobs.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
/**
|
||||
* 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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue