162 lines
4.6 KiB
TypeScript
162 lines
4.6 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 };
|
||
|
|
}
|
||
|
|
}
|