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:
Dieter Schlüter 2026-05-12 04:21:48 +02:00
commit 5146b7fa30
62 changed files with 11279 additions and 0 deletions

162
lib/cache.ts Normal file
View file

@ -0,0 +1,162 @@
/**
* lib/cache.ts
* Hash-basierter File-Cache für Perplexity-Ergebnisse.
*
* Vermeidet doppelte Perplexity-Kosten wenn derselbe Claim in mehreren Artikeln
* oder in Wiederholungsläufen geprüft wird.
*
* Ablageort: ~/.pi/agent/cache/perplexity/<sha256>.json
* TTL: 7 Tage (ältere Einträge werden beim Lesen ignoriert)
* Schlüssel: SHA256 des normalisierten Claim-Textes
*
* Verwendung in verify-article.ts:
* import { getCached, setCached } from "../lib/cache.js";
* const cached = getCached(claimText);
* if (cached) return cached;
* const result = await searchPerplexity(claimText, opts);
* setCached(claimText, result);
*/
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync, readFileSync, statSync, readdirSync, unlinkSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
// ---------------------------------------------------------------------------
// Konstanten
// ---------------------------------------------------------------------------
export const CACHE_DIR = join(homedir(), ".pi", "agent", "cache", "perplexity");
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
// ---------------------------------------------------------------------------
// Interner Typ (Cache-Datei)
// ---------------------------------------------------------------------------
type CacheEntry<T> = {
cachedAt: string; // ISO-Timestamp
data: T;
};
// ---------------------------------------------------------------------------
// Hilfsfunktionen
// ---------------------------------------------------------------------------
function ensureCacheDir(): void {
mkdirSync(CACHE_DIR, { recursive: true });
}
/**
* Normalisiert einen Claim-Text für konsistentes Hashing:
* - Whitespace kollabieren
* - Kleinschreibung
* - Führende/nachfolgende Leerzeichen entfernen
*/
function normalizeText(text: string): string {
return text.toLowerCase().replace(/\s+/g, " ").trim();
}
/**
* SHA256-Hash des normalisierten Claim-Textes als Hex-String (64 Zeichen).
*/
export function claimHash(claimText: string): string {
return createHash("sha256").update(normalizeText(claimText)).digest("hex");
}
function cachePath(hash: string): string {
return join(CACHE_DIR, `${hash}.json`);
}
// ---------------------------------------------------------------------------
// Öffentliche API
// ---------------------------------------------------------------------------
/**
* Liest einen gecachten Perplexity-Wert für den gegebenen Claim-Text.
* Gibt null zurück wenn:
* - kein Cache-Eintrag vorhanden
* - der Eintrag älter als TTL_MS ist
* - der Eintrag korrupt ist
*/
export function getCached<T>(claimText: string): T | null {
try {
const path = cachePath(claimHash(claimText));
const stat = statSync(path);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs > TTL_MS) return null; // abgelaufen
const entry = JSON.parse(readFileSync(path, "utf8")) as CacheEntry<T>;
return entry.data;
} catch {
return null;
}
}
/**
* Speichert ein Perplexity-Ergebnis im Cache.
* Fehler beim Schreiben werden ignoriert (Cache ist optional).
*/
export function setCached<T>(claimText: string, data: T): void {
try {
ensureCacheDir();
const entry: CacheEntry<T> = {
cachedAt: new Date().toISOString(),
data,
};
writeFileSync(cachePath(claimHash(claimText)), JSON.stringify(entry, null, 2), "utf8");
} catch {
// Cache-Fehler dürfen den Programmablauf nicht unterbrechen
}
}
/**
* Löscht abgelaufene Cache-Einträge (älter als TTL_MS).
* Gibt die Anzahl gelöschter Einträge zurück.
*/
export function pruneCache(): number {
try {
ensureCacheDir();
const files = readdirSync(CACHE_DIR).filter((f) => f.endsWith(".json"));
let deleted = 0;
for (const file of files) {
try {
const path = join(CACHE_DIR, file);
const ageMs = Date.now() - statSync(path).mtimeMs;
if (ageMs > TTL_MS) {
unlinkSync(path);
deleted++;
}
} catch {
// Einzelne Fehler ignorieren
}
}
return deleted;
} catch {
return 0;
}
}
/**
* Gibt Statistiken über den Cache zurück.
*/
export function cacheStats(): { total: number; expired: number; sizeBytes: number } {
try {
ensureCacheDir();
const files = readdirSync(CACHE_DIR).filter((f) => f.endsWith(".json"));
let expired = 0;
let sizeBytes = 0;
for (const file of files) {
try {
const path = join(CACHE_DIR, file);
const stat = statSync(path);
sizeBytes += stat.size;
if (Date.now() - stat.mtimeMs > TTL_MS) expired++;
} catch {
// ignorieren
}
}
return { total: files.length, expired, sizeBytes };
} catch {
return { total: 0, expired: 0, sizeBytes: 0 };
}
}