From 5146b7fa3076ec0fbcdcd8ceb2167b44678f54ab Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 12 May 2026 04:21:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Pi=20Text-Agent=20=E2=80=94=20initialer?= =?UTF-8?q?=20Commit=20(sauberes=20Repo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 18 + AGENTS.md | 327 +++++++++++ BEDIENUNGSANLEITUNG.md | 419 ++++++++++++++ HANDOFF.md | 181 ++++++ PI_PROMPT.md | 61 ++ README.md | 170 ++++++ TODO.md | 115 ++++ WORKLOG.md | 309 ++++++++++ agenten/llama-claim-extractor.ts | 781 ++++++++++++++++++++++++++ agenten/llama-logic-editor.ts | 522 +++++++++++++++++ agenten/llama-verifier.ts | 552 ++++++++++++++++++ agenten/llama-verify-article.ts | 838 ++++++++++++++++++++++++++++ agenten/llama-writer.ts | 582 +++++++++++++++++++ agenten/ollama-claim-extractor.ts | 697 +++++++++++++++++++++++ agenten/ollama-logic-editor.ts | 567 +++++++++++++++++++ agenten/ollama-verifier.ts | 450 +++++++++++++++ agenten/ollama-verify-article.ts | 809 +++++++++++++++++++++++++++ agenten/ollama-writer.ts | 579 +++++++++++++++++++ agenten/research-web.ts | 431 ++++++++++++++ docs/ARCHITECTURE.md | 255 +++++++++ lib/cache.ts | 162 ++++++ lib/jobs.ts | 308 ++++++++++ lib/logger.ts | 107 ++++ lib/ollama.ts | 237 ++++++++ lib/perplexity.ts | 175 ++++++ lib/router.ts | 299 ++++++++++ package-lock.json | 599 ++++++++++++++++++++ package.json | 18 + tests/corpus/README.md | 43 ++ tests/corpus/case_001/expected.json | 19 + tests/corpus/case_001/input.txt | 1 + tests/corpus/case_001/notes.md | 13 + tests/corpus/case_002/expected.json | 19 + tests/corpus/case_002/input.txt | 1 + tests/corpus/case_002/notes.md | 13 + tests/corpus/case_003/expected.json | 19 + tests/corpus/case_003/input.txt | 1 + tests/corpus/case_003/notes.md | 16 + tests/corpus/case_004/expected.json | 24 + tests/corpus/case_004/input.txt | 1 + tests/corpus/case_004/notes.md | 14 + tests/corpus/case_005/expected.json | 19 + tests/corpus/case_005/input.txt | 1 + tests/corpus/case_005/notes.md | 15 + tests/corpus/case_006/expected.json | 24 + tests/corpus/case_006/input.txt | 1 + tests/corpus/case_006/notes.md | 14 + tests/corpus/case_007/expected.json | 19 + tests/corpus/case_007/input.txt | 1 + tests/corpus/case_007/notes.md | 13 + tests/corpus/case_008/expected.json | 19 + tests/corpus/case_008/input.txt | 1 + tests/corpus/case_008/notes.md | 14 + tests/corpus/case_009/expected.json | 19 + tests/corpus/case_009/input.txt | 1 + tests/corpus/case_009/notes.md | 13 + tests/corpus/case_010/expected.json | 19 + tests/corpus/case_010/input.txt | 1 + tests/corpus/case_010/notes.md | 18 + tests/run_corpus.sh | 271 +++++++++ tsconfig.json | 14 + types/pi-coding-agent.d.ts | 30 + 62 files changed, 11279 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 BEDIENUNGSANLEITUNG.md create mode 100644 HANDOFF.md create mode 100644 PI_PROMPT.md create mode 100644 README.md create mode 100644 TODO.md create mode 100644 WORKLOG.md create mode 100644 agenten/llama-claim-extractor.ts create mode 100644 agenten/llama-logic-editor.ts create mode 100644 agenten/llama-verifier.ts create mode 100644 agenten/llama-verify-article.ts create mode 100644 agenten/llama-writer.ts create mode 100644 agenten/ollama-claim-extractor.ts create mode 100644 agenten/ollama-logic-editor.ts create mode 100644 agenten/ollama-verifier.ts create mode 100644 agenten/ollama-verify-article.ts create mode 100644 agenten/ollama-writer.ts create mode 100644 agenten/research-web.ts create mode 100644 docs/ARCHITECTURE.md create mode 100644 lib/cache.ts create mode 100644 lib/jobs.ts create mode 100644 lib/logger.ts create mode 100644 lib/ollama.ts create mode 100644 lib/perplexity.ts create mode 100644 lib/router.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tests/corpus/README.md create mode 100644 tests/corpus/case_001/expected.json create mode 100644 tests/corpus/case_001/input.txt create mode 100644 tests/corpus/case_001/notes.md create mode 100644 tests/corpus/case_002/expected.json create mode 100644 tests/corpus/case_002/input.txt create mode 100644 tests/corpus/case_002/notes.md create mode 100644 tests/corpus/case_003/expected.json create mode 100644 tests/corpus/case_003/input.txt create mode 100644 tests/corpus/case_003/notes.md create mode 100644 tests/corpus/case_004/expected.json create mode 100644 tests/corpus/case_004/input.txt create mode 100644 tests/corpus/case_004/notes.md create mode 100644 tests/corpus/case_005/expected.json create mode 100644 tests/corpus/case_005/input.txt create mode 100644 tests/corpus/case_005/notes.md create mode 100644 tests/corpus/case_006/expected.json create mode 100644 tests/corpus/case_006/input.txt create mode 100644 tests/corpus/case_006/notes.md create mode 100644 tests/corpus/case_007/expected.json create mode 100644 tests/corpus/case_007/input.txt create mode 100644 tests/corpus/case_007/notes.md create mode 100644 tests/corpus/case_008/expected.json create mode 100644 tests/corpus/case_008/input.txt create mode 100644 tests/corpus/case_008/notes.md create mode 100644 tests/corpus/case_009/expected.json create mode 100644 tests/corpus/case_009/input.txt create mode 100644 tests/corpus/case_009/notes.md create mode 100644 tests/corpus/case_010/expected.json create mode 100644 tests/corpus/case_010/input.txt create mode 100644 tests/corpus/case_010/notes.md create mode 100755 tests/run_corpus.sh create mode 100644 tsconfig.json create mode 100644 types/pi-coding-agent.d.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4af392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +dist/ +Ideen/ +tests/results/ +.claude/ +*.local.* +.env +*.log +Totally_unacceptable_article.txt + +# Container-Storage ausschließen +/home/dschlueter/endeavour_home/ +/home/dschlueter/nvme2n1p7_home/ + +# Allgemeine Container-Overlays +**/overlay/ +**/storage/overlay/ + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..46791ad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,327 @@ +# AGENTS.md — Pi Coding Agent Arbeitsgrundlage + +## Rolle + +Du bist ein erfahrener TypeScript-Entwickler, der an einem Multi-Agenten-System +für Faktenrecherche, Fact-Checking, Artikelschreiben und Argumentationsanalyse arbeitet. + +Du arbeitest **autonom und zügig**. Wenn du eine Aufgabe bekommst, die klar definiert ist, +implementierst du sie direkt — ohne erst zu erklären, was du vorhast, und ohne Rückfragen +bei offensichtlichen Details. + +--- + +## Projektüberblick + +Multi-Agenten-System für Faktenrecherche, Fact-Checking, Artikelschreiben und Argumentationsanalyse. +Läuft als **Pi-Extension-Paket** (`~/.pi/agent/extensions/fact-checker/`) plus CLI-Modus für jeden Agenten. +Backend: lokales Ollama (`qwen3.5:9b`/`27b`, `deepseek-r1:32b`) + llama.cpp (`Qwopus3.6-35B-A3B`, Port 8000) + Perplexity Sonar API + optionales OpenRouter. + +--- + +## Workflow-Protokoll (zwingend) + +### Vor jeder Session + +1. Lies `HANDOFF.md` — aktueller Stand, offene Punkte, bekannte Einschränkungen +2. Lies `TODO.md` — erste nicht abgehakte Aufgabe ist dein Startpunkt +3. Lies `WORKLOG.md` (neueste 2 Einträge) — was in den letzten Sessions getan wurde +4. Lies die betroffene Datei in `agenten/` oder `lib/` (nie aus dem Gedächtnis arbeiten) + +### Während der Arbeit + +- Nach jeder Dateiänderung: `npx tsc --noEmit` ausführen — Fehler sofort beheben +- Teste den geänderten Agenten via CLI (Kommandos im Abschnitt „Kommandos" unten) +- Stoppe und frage den Nutzer, wenn die Bedingungen aus „Wann Pi stoppen" zutreffen + +### Nach jeder erledigten Aufgabe + +1. `WORKLOG.md` ergänzen (append-only, neueste Einträge oben) +2. `TODO.md`: betroffenes `[ ]` auf `[x]` setzen +3. `HANDOFF.md`: „Zuletzt erledigt" und offene Punkte aktualisieren + +--- + +## Tech-Stack + +- **Sprache:** TypeScript (ESM, `"type": "module"`) +- **Runtime für CLI:** `npx tsx` (kein Build-Schritt nötig) +- **Pi-Extension-Loader:** `@mariozechner/jiti` — lädt `.ts`-Dateien direkt +- **Parameter-Schemas in Pi:** `@sinclair/typebox` (`Type.Object(...)`) +- **Ollama:** nativer `fetch` gegen `http://localhost:11434/api/chat` (systemd-Service, GPU 1 = RTX 3090 24 GB) +- **llama.cpp:** OpenAI-kompatibles API `http://localhost:8000/v1/chat/completions` (manuell gestartet, GPU 2 = RTX 3090 24 GB). Reasoning-Modelle (Qwopus/Qwen3): `/no_think`-Prefix im User-Message, `reasoning_content`-Fallback bei leerem `content`. +- **Perplexity:** `https://api.perplexity.ai/chat/completions` +- **OpenRouter:** `https://openrouter.ai/api/v1/chat/completions` +- **Node.js:** v22.22.2 (nvm) +- **GPU 2** (RTX 3090, 24 GB) ist aktuell idle — `CUDA_VISIBLE_DEVICES` nicht gesetzt + +--- + +## Verzeichnisstruktur + +``` +text_agent/ +├── agenten/ +│ ├── ollama-claim-extractor.ts ← Text → ClaimSet (Ollama, Pi-Tool: extract_claims) +│ ├── llama-claim-extractor.ts ← Text → ClaimSet (llama.cpp, Pi-Tool: extract_claims_llama) +│ ├── ollama-verifier.ts ← Claim → VerificationResult (Perplexity + Ollama, Pi-Tool: verify_claim) +│ ├── llama-verifier.ts ← Claim → VerificationResult (Perplexity + llama.cpp, Pi-Tool: verify_claim_llama) ★ BEVORZUGT +│ ├── ollama-verify-article.ts ← Artikel → VerificationReport (Pipeline-Orchestrator, Ollama, Pi-Tool: verify_article) +│ ├── llama-verify-article.ts ← Artikel → VerificationReport (Pipeline-Orchestrator, llama.cpp, Pi-Tool: verify_article_llama) +│ ├── ollama-logic-editor.ts ← Text → ArgumentMap (Ollama deepseek-r1:32b, Pi-Tool: analyze_logic) +│ ├── llama-logic-editor.ts ← Text → ArgumentMap (llama.cpp, Pi-Tool: analyze_logic_llama) ★ BEVORZUGT +│ ├── ollama-writer.ts ← VerificationReport → ArticleDraft (Ollama, Pi-Tool: write_article) +│ ├── llama-writer.ts ← VerificationReport → ArticleDraft (llama.cpp, Pi-Tool: write_article_llama) ★ BEVORZUGT +│ └── research-web.ts ← Web-Recherche via Perplexity (standalone) +├── lib/ +│ ├── perplexity.ts ← Perplexity-API-Wrapper (Retry, Kosten, Deduplizierung) +│ ├── router.ts ← Model-Router (lokal vs. OpenRouter) +│ ├── logger.ts ← File-Logger (→ ~/.pi/agent/logs/) +│ ├── jobs.ts ← Job-Speicher (→ ~/.pi/agent/jobs/) +│ └── cache.ts ← SHA256-Claim-Cache (→ ~/.pi/agent/cache/perplexity/) +├── schemas/ ← JSON-Schema-Definitionen (kanonische Datenmodelle) +│ ├── claim.schema.json +│ ├── source-record.schema.json +│ ├── verification-result.schema.json +│ ├── argument-map.schema.json +│ └── article-draft.schema.json +├── types/ +│ └── pi-coding-agent.d.ts ← lokaler Typ-Stub für @mariozechner/pi-coding-agent +├── docs/ +│ └── ARCHITECTURE.md +├── tests/ +│ ├── corpus/ ← 10 Testfälle (input.txt, expected.json, notes.md) +│ └── run_corpus.sh ← Precision/Recall-Test-Runner +├── AGENTS.md / HANDOFF.md / TODO.md / WORKLOG.md +├── package.json +└── tsconfig.json +``` + +--- + +## Deployment-Pfade + +``` +~/.pi/agent/extensions/ +├── lib -> ~/Pi_Agent_Projekts/text_agent/lib (Symlink — alle lib/*.ts verfügbar) +├── research-web.ts (Standalone-Datei) +└── fact-checker/ + ├── package.json (pi.extensions-Manifest) + ├── ollama-claim-extractor.ts -> agenten/ollama-claim-extractor.ts + ├── llama-claim-extractor.ts -> agenten/llama-claim-extractor.ts + ├── ollama-verifier.ts -> agenten/ollama-verifier.ts + ├── llama-verifier.ts -> agenten/llama-verifier.ts + ├── ollama-verify-article.ts -> agenten/ollama-verify-article.ts + ├── llama-verify-article.ts -> agenten/llama-verify-article.ts + ├── ollama-logic-editor.ts -> agenten/ollama-logic-editor.ts + ├── llama-logic-editor.ts -> agenten/llama-logic-editor.ts + ├── ollama-writer.ts -> agenten/ollama-writer.ts + └── llama-writer.ts -> agenten/llama-writer.ts + +~/.pi/agent/jobs/ ← Job-Verzeichnisse (von lib/jobs.ts angelegt) +~/.pi/agent/logs/ ← Log-Dateien (von lib/logger.ts angelegt) +~/.pi/agent/cache/ ← Perplexity-Claim-Cache (von lib/cache.ts angelegt) +``` + +Änderungen im Repo sind nach `/reload` in Pi sofort aktiv (Symlinks). + +--- + +## Kommandos + +```bash +cd ~/Pi_Agent_Projekts/text_agent + +# TypeScript prüfen +npx tsc --noEmit + +# Claim-Extraktion — Ollama-Version +npx tsx agenten/ollama-claim-extractor.ts "Textinhalt..." +npx tsx agenten/ollama-claim-extractor.ts --only-checkable "$(cat artikel.txt)" +npx tsx agenten/ollama-claim-extractor.ts --verbose "$(cat langer-text.txt)" # Chunking-Details +npx tsx agenten/ollama-claim-extractor.ts --json "..." > claims.json + +# Claim-Extraktion — llama.cpp-Version (Port 8000) +npx tsx agenten/llama-claim-extractor.ts "Textinhalt..." +npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --only-checkable +npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --translate-to de # + Übersetzung +npx tsx agenten/llama-claim-extractor.ts --json --file artikel.txt > claims.json + +# Einzelnen Claim prüfen — Ollama-Version +npx tsx agenten/ollama-verifier.ts "Die Inflationsrate betrug 2024 in Deutschland 3,2%." +npx tsx agenten/ollama-verifier.ts --mode deep --verbose "Strittige Behauptung..." +npx tsx agenten/ollama-verifier.ts --json "..." > result.json + +# Einzelnen Claim prüfen — llama.cpp-Version ★ BEVORZUGT +npx tsx agenten/llama-verifier.ts "Die EZB hat den Leitzins im Juni 2024 gesenkt." +npx tsx agenten/llama-verifier.ts --mode deep --user-language en "Claim..." +npx tsx agenten/llama-verifier.ts --json "..." | python3 -m json.tool + +# Vollständige Verifikations-Pipeline +npx tsx agenten/ollama-verify-article.ts "$(cat artikel.txt)" +npx tsx agenten/ollama-verify-article.ts --job-id mein-artikel "$(cat artikel.txt)" # mit Job-Speicher +npx tsx agenten/ollama-verify-article.ts --no-cache --job-id test "$(cat artikel.txt)" # Cache umgehen +npx tsx agenten/ollama-verify-article.ts --json "..." > report.json +npx tsx agenten/ollama-verify-article.ts --verbose --job-id test "$(cat artikel.txt)" + +# Artikel schreiben — llama.cpp-Version ★ BEVORZUGT +npx tsx agenten/llama-writer.ts --from-job mein-artikel --style blog +npx tsx agenten/llama-writer.ts --from-job mein-artikel --style journalistic --words 600 +cat report.json | npx tsx agenten/llama-writer.ts --from-report --style blog + +# Artikel schreiben — Ollama-Version +cat report.json | npx tsx agenten/ollama-writer.ts --from-report --style blog +npx tsx agenten/ollama-writer.ts --from-job mein-artikel --style blog + +# Argumentationsanalyse — llama.cpp-Version ★ BEVORZUGT +npx tsx agenten/llama-logic-editor.ts "Argumentativer Text..." +npx tsx agenten/llama-logic-editor.ts --only-fallacies "Text..." +npx tsx agenten/llama-logic-editor.ts --json "..." > map.json + +# Argumentationsanalyse — Ollama-Version (deepseek-r1:32b) +npx tsx agenten/ollama-logic-editor.ts "Argumentativer Text..." +npx tsx agenten/ollama-logic-editor.ts --only-fallacies "Text..." +npx tsx agenten/ollama-logic-editor.ts --cloud "Text..." # OpenRouter + +# Job-Speicher prüfen +ls ~/.pi/agent/jobs/ +cat ~/.pi/agent/jobs/_/meta.json + +# Ollama-Status +curl -s http://localhost:11434/api/ps | python3 -m json.tool + +# Vollständige llama.cpp-Pipeline ★ BEVORZUGT +npx tsx agenten/llama-verify-article.ts --json "$(cat artikel.txt)" \ + | npx tsx agenten/llama-writer.ts --from-report --style blog + +# Vollständige Ollama-Pipeline +npx tsx agenten/ollama-verify-article.ts --json "$(cat artikel.txt)" \ + | npx tsx agenten/ollama-writer.ts --from-report --style blog + +# Testkorpus ausführen +bash tests/run_corpus.sh # alle 10 Fälle (Precision/Recall) +bash tests/run_corpus.sh --mode deep # mit sonar-pro +bash tests/run_corpus.sh case_001 case_002 # selektive Fälle + +# Cache-Verwaltung +node -e "import('../lib/cache.js').then(m => console.log(m.cacheStats()))" +node -e "import('../lib/cache.js').then(m => console.log(m.pruneCache()))" + +# Pi Extensions neu laden +/reload # innerhalb von Pi +``` + +--- + +## Coding-Konventionen + +- **ESM only:** alle Imports mit `.js`-Extension (auch wenn die Datei `.ts` ist) +- **Relative Imports:** `../lib/perplexity.js`, `./ollama-claim-extractor.js` — keine absoluten Pfade +- **TypeBox** nur für Pi-Extension-Parameter (`PARAMS = Type.Object(...)`) +- **Ollama structured output:** `format: `, `stream: false`, `additionalProperties: false` +- **num_ctx bei qwen3.5:27b:** max. 8192 (VRAM-Limit auf RTX 3090) +- **llama.cpp:** `POST /v1/chat/completions`, `stream: false`, `max_tokens: 16384`. Schema als JSON-Literal im System-Prompt (kein `format:`-Parameter). `/no_think` als erste Zeile im User-Message bei Reasoning-Modellen. Fallback: `choices[0].message.reasoning_content` per Regex wenn `content` leer. +- **Temperatur:** 0.1 für Extraktion/Verifikation, 0.3–0.4 für Schreiben +- **Fehler:** `err instanceof Error ? err.message : String(err)` — nie `.toString()` +- **CLI-Einstiegspunkt:** `if (process.argv[1] === fileURLToPath(import.meta.url))` +- **Pi-Rückgabe:** `{ content: [{ type: "text", text }], details: {...} }` +- **Logging:** `lib/logger.ts` verwenden — kein `console.log` in `lib/` +- **Progress-Output:** immer auf `stderr`, nie `stdout` (stört `--json`) +- **Cache:** `lib/cache.ts` für wiederholte Perplexity-Anfragen; fehlertolerant (Fehler nie propagieren) +- **Minimale Änderungen:** kein Refactoring ohne expliziten Auftrag; keinen Coding-Stil brechen +- **Keine neuen Dependencies** ohne Rückfrage beim Nutzer + +--- + +## Was Pi NICHT tun soll + +- Keine Umbenennungen exportierter Funktionen ohne vollständige Import-Prüfung +- Keine Änderungen an Deployment-Symlinks in `~/.pi/agent/extensions/` +- Nicht `research-web.ts` umbenennen/löschen +- Kein `console.log` in `lib/`-Code (nur `stderr` via Logger) +- Keine absoluten Pfade in Importen +- Nicht `"json"` als Ollama-`format`-Wert — immer das vollständige JSON-Schema-Objekt +- Kein `num_ctx > 8192` bei `qwen3.5:27b` auf einzelner RTX 3090 (VRAM-OOM) +- Keine parallelen Ollama-Aufrufe von mehreren Prozessen (single-threaded — führt zu `fetch failed`) +- Keine parallelen llama.cpp-Aufrufe (ebenfalls single-threaded) +- Bei llama.cpp kein `format:`-Parameter — Schema gehört in den System-Prompt als JSON-Literal + +--- + +## Architekturregeln + +- Jeder Agent produziert **genau ein** typisiertes Ausgabeobjekt +- **Kein Agent ruft direkt einen anderen auf** — Orchestrierung nur in `ollama-verify-article.ts` und `llama-verify-article.ts` +- `lib/` enthält nur Code, der von ≥2 Agenten genutzt wird +- JSON-Schemas in `schemas/` sind kanonisch — TypeScript-Typen lokal je Datei +- `additionalProperties: false` in jedem Ollama-Schema +- Jobs, Logging und Cache sind optional — Pi-Extensions nutzen `nullLogger`, kein `jobDir`, Cache ist standardmäßig aktiv (schadet nicht) + +## Wichtige Architekturentscheidungen (nicht rückgängig machen) + +| Entscheidung | Begründung | +|---|---| +| `num_ctx=8192` fix für ollama-claim-extractor | VRAM-Limit auf RTX 3090 (24 GB) | +| Chunking statt großem Kontext | Texte > 4000 Zeichen → Chunks ≤ 3000 Zeichen | +| Perplexity-Ergebnisse einzeln per Claim gecacht | Günstigstes Failover-Granulat | +| Batch-Ollama-Verdict (1 Call für N Claims) | Effizienter als N sequentielle Calls | +| `complexity: "low"` ohne `--cloud` in ollama-writer.ts | Verhindert ungewolltes OpenRouter-Routing | + +--- + +## Loop-Erkennung und Edit-Disziplin (ZWINGEND) + +### Partial-Read-Pflicht +- Wenn ein `read`-Ergebnis mit `[N more lines…]` endet: **immer zuerst** mit `offset=` den fehlenden Teil nachlesen, bevor ein Edit versucht wird. +- Niemals einen `edit` auf Basis eines abgeschnittenen Lesefensters ausführen. + +### Edit-Fehlschlag-Protokoll +1. **Erster Fehlschlag**: Datei vollständig neu lesen (`read` ohne Limit, oder mit ausreichend großem `limit`), dann exakte Zeilen für `old_string` entnehmen — danach ein einzelner neuer Edit-Versuch. +2. **Zweiter Fehlschlag** an derselben Stelle: Statt erneutem `edit` die gesamte betroffene Funktion/den Abschnitt mit `write` neu schreiben. +3. **Dritter Fehlschlag oder tsc-Fehler nach Rewrite**: **SOFORT STOPPEN.** Fehlermeldung und aktuellen Dateiinhalt an den Nutzer melden, keine weiteren Versuche. + +### Loop-Abbruchbedingung +- Wenn dieselbe Sequenz (read → edit schlägt fehl → read → edit schlägt fehl) **zweimal** hintereinander auftritt: Abbruch, Meldung an Nutzer mit genauem Fehlertext und den betroffenen Zeilen. +- Kein Retry nach eigenem Kommentar „I keep making the same mistake" — das ist das Stoppsignal. + +--- + +## Wann Pi stoppen und fragen soll + +- `npx tsc --noEmit` zeigt Fehler, die sich nicht minimal beheben lassen +- Ein Ollama-Aufruf hängt nach >5 Minuten ohne Output +- Ein neues Ollama-Schema verschlechtert die Ausgabequalität messbar +- `PERPLEXITY_API_KEY` oder `OPENROUTER_API_KEY` nicht gesetzt und der Task braucht sie +- Änderungen an `lib/perplexity.ts`, `lib/router.ts`, `lib/jobs.ts`, `lib/logger.ts` oder `lib/cache.ts` +- Edit schlägt 2× an derselben Datei/Stelle fehl (→ Loop-Erkennung oben) + +--- + +## WORKLOG-Format + +``` +## [YYYY-MM-DD] + +### Erledigt +- [Was geändert] in `datei.ts` +- tsc: fehlerfrei +- Getestet: [Kommando und Ergebnis] + +### Probleme und Lösungen +| Problem | Lösung | +|---------|--------| +| ... | ... | + +### Verbleibende offene Punkte +- ... +``` + +--- + +## Definition of Done + +Eine Änderung ist fertig wenn: +1. `npx tsc --noEmit` fehlerfrei +2. Betroffener Agent via CLI getestet +3. `WORKLOG.md` ergänzt +4. `[ ]` in `TODO.md` als `[x]` markiert diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md new file mode 100644 index 0000000..8229ddd --- /dev/null +++ b/BEDIENUNGSANLEITUNG.md @@ -0,0 +1,419 @@ +# Bedienungsanleitung — Pi Text-Agent + +Fact-Checking, Artikelschreiben und Argumentationsanalyse — lokal und kostengünstig. + +--- + +## Was kann das System? + +| Aufgabe | Pi-Tool (★ bevorzugt) | Kosten | +|---------|----------------------|--------| +| Behauptungen aus einem Text extrahieren | `extract_claims_llama` ★ | kostenlos (lokal) | +| Einzelne Behauptung auf Wahrheit prüfen | `verify_claim_llama` ★ | ~$0.005–0.015 (Perplexity) | +| Ganzen Artikel automatisch fact-checken | `verify_article_llama` ★ | ~$0.05–0.15 pro Artikel | +| Fact-gechecken Artikel schreiben | `write_article_llama` ★ | kostenlos (lokal) | +| Text auf Fehlschlüsse analysieren | `analyze_logic_llama` ★ | kostenlos (lokal) | +| Im Web recherchieren | `research_web` | ~$0.001–0.005 | + +★ = llama.cpp-Backend (bevorzugt). Ollama-Varianten (ohne `_llama`) sind ebenfalls verfügbar. + +--- + +## Voraussetzungen + +```bash +# llama.cpp-Server läuft? (★ bevorzugtes Backend) +curl -s http://localhost:8000/v1/models | python3 -m json.tool + +# Ollama läuft? (für Fallback + research_web) +curl -s http://localhost:11434/api/tags | python3 -m json.tool | grep name + +# API-Keys gesetzt? +echo $PERPLEXITY_API_KEY # muss gesetzt sein für Fact-Checking +echo $OPENROUTER_API_KEY # optional, nur für --cloud (Ollama-Writer) + +# llama.cpp-Server starten (falls nicht aktiv): +llama-server --model Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf \ + --host 0.0.0.0 --port 8000 -c 32768 + +# Ollama-Modelle verfügbar? +ollama list | grep -E "qwen3.5:27b|deepseek-r1:32b" +``` + +--- + +## Verwendung in Pi + +Einfach Pi öffnen und in natürlicher Sprache beschreiben was du willst. Pi ruft die richtigen Tools automatisch auf. + +### Typische Pi-Eingaben + +``` +Prüfe diesen Artikel auf Fakten: [Text einfügen] + +Extrahiere alle Behauptungen aus diesem Text und zeige mir nur die prüfbaren: [Text] + +Verifiziere diese Behauptung: "Die EZB hat den Leitzins im Juni 2024 auf 4,25% gesenkt." + +Schreibe einen Blog-Artikel auf Basis dieses verifizierten Reports: [Report einfügen] + +Analysiere diesen Text auf logische Fehlschlüsse: [Text] + +Recherchiere: Wie hoch ist der aktuelle Wohnungsbestand in Deutschland? +``` + +### Hinweise für Pi-Nutzung + +- Pi verarbeitet Claims **nacheinander** — nicht erschrecken wenn es etwas dauert +- Fortschritt wird in Pi direkt angezeigt +- Lange Texte werden automatisch in Abschnitte aufgeteilt (kein manuelles Kürzen nötig) +- Bei `verify_article_llama`: Pi zeigt nach der Verifikation welche Claims bestätigt/widerlegt wurden + +--- + +## Verwendung via CLI + +### 1. Behauptungen extrahieren + +```bash +cd ~/Pi_Agent_Projekts/text_agent + +# ★ llama.cpp-Version (empfohlen) +npx tsx agenten/llama-claim-extractor.ts "Die Erde hat 8 Milliarden Einwohner. \ + Die Inflationsrate lag 2024 bei 3,2 Prozent." + +# Aus Datei +npx tsx agenten/llama-claim-extractor.ts --file ~/Dokumente/artikel.txt + +# Nur prüfbare Claims +npx tsx agenten/llama-claim-extractor.ts --only-checkable "$(cat artikel.txt)" + +# JSON-Ausgabe (für Weiterverarbeitung) +npx tsx agenten/llama-claim-extractor.ts --json "$(cat artikel.txt)" > claims.json + +# Mit Übersetzung (englischer Text → deutsche Claim-Anzeige) +npx tsx agenten/llama-claim-extractor.ts --translate-to de --file english_article.txt + +# Ollama-Fallback (falls llama.cpp nicht verfügbar) +npx tsx agenten/ollama-claim-extractor.ts --verbose "$(cat ~/Dokumente/langer-essay.txt)" +``` + +**Beispielausgabe:** +``` +## Claim-Extraktion: 4 Behauptungen gefunden + +**✓ Prüfbar (2):** +`c001` ✓ [STATISTIK] Die Erde hat 8 Milliarden Einwohner. + Entitäten: Erde | Zeit: 2024 | Zitat nötig: ja + +`c002` ✓ [STATISTIK] Die Inflationsrate lag 2024 in Deutschland bei 3,2 Prozent. + Entitäten: Deutschland | Zeit: 2024 | Zitat nötig: ja +``` + +--- + +### 2. Einzelne Behauptung prüfen + +```bash +# ★ llama.cpp-Version (empfohlen) +npx tsx agenten/llama-verifier.ts "Die EZB hat den Leitzins im Juni 2024 gesenkt." + +# Genauere Suche (sonar-pro) +npx tsx agenten/llama-verifier.ts --mode deep "Die Inflationsrate betrug 2024 in Deutschland 3,2%." + +# Urteilstext auf Englisch +npx tsx agenten/llama-verifier.ts --user-language en "Claim in any language..." + +# JSON-Ausgabe +npx tsx agenten/llama-verifier.ts --json "Behauptung..." > result.json + +# Ollama-Fallback +npx tsx agenten/ollama-verifier.ts --verbose "Behauptung..." +``` + +**Beispielausgabe:** +``` +## Verifikation +**Behauptung:** "Die Inflationsrate betrug 2024 in Deutschland 3,2%." + +**✗ WIDERLEGT** (Konfidenz: hoch) + +**Begründung:** Die Inflationsrate in Deutschland betrug 2024 durchschnittlich 2,2%, +nicht 3,2%. Destatis bestätigt diesen Wert. + +**Gegenbelege:** Statistisches Bundesamt weist 2,2% für 2024 aus. + +**Quellen:** +[1] ✓ [Destatis — Inflationsrate 2024](https://www.destatis.de/...) +``` + +--- + +### 3. Ganzen Artikel fact-checken + +#### Mit Job-Speicher (empfohlen für lange Texte) + +Der Job-Speicher sichert jeden Schritt. Bei Unterbrechung (Server-Neustart, Stromausfall) +einfach denselben Befehl nochmals aufrufen — bereits erledigte Schritte werden übersprungen, +und vor allem: bereits bezahlte Perplexity-Anfragen werden **nicht nochmals abgerechnet**. + +```bash +# ★ llama.cpp-Version (empfohlen) +npx tsx agenten/llama-verify-article.ts --job-id mein-artikel "$(cat artikel.txt)" + +# Bei Unterbrechung: identisch nochmals aufrufen +npx tsx agenten/llama-verify-article.ts --job-id mein-artikel "$(cat artikel.txt)" +# → Claims aus Cache geladen — Extraktion übersprungen. +# → 3/12 Perplexity-Ergebnisse aus Job-Cache geladen. +# → 9 neue Perplexity-Anfragen... + +# Mit ausführlichem Log +npx tsx agenten/llama-verify-article.ts --job-id mein-artikel --verbose "$(cat artikel.txt)" + +# Cache umgehen (erzwingt neue Perplexity-Anfragen) +npx tsx agenten/llama-verify-article.ts --no-cache --job-id test "$(cat artikel.txt)" + +# Ollama-Fallback +npx tsx agenten/ollama-verify-article.ts --job-id mein-artikel "$(cat artikel.txt)" +``` + +**Jobs ansehen:** +```bash +ls ~/.pi/agent/jobs/ +cat ~/.pi/agent/jobs/2026-05-12_mein-artikel/meta.json +``` + +**Beispielausgabe:** +``` +Modus: fast | Max. Claims: 15 | Job: mein-artikel + + Claims extrahieren (llama.cpp)... + 8 Claims — 5 prüfbar, 3 nicht prüfbar. + Recherche läuft (5 Claims, max. 5 parallel)... + [1/5] c001 ✓ "Die Inflationsrate betrug 2024 in Deutschland..." + [2/5] c002 ✓ "Die EZB hat den Leitzins im Juni 2024 gesenkt." + ... + Urteilssynthese (llama.cpp, 5 Claims)... + +## Verifikationsbericht +8 Claims extrahiert, 5 recherchiert. 3 bestätigt. 1 widerlegt. 1 ohne Belege. + +**✗ WIDERLEGT (1):** +`c001` "Die Inflationsrate betrug 2024 in Deutschland 3,2%." + → Die tatsächliche Rate war 2,2% (Destatis). Abweichung: +1 Prozentpunkt. + ✗ Gegenbeleg: Statistisches Bundesamt weist 2,2% für 2024 aus. +``` + +--- + +### 4. Artikel schreiben + +```bash +# ★ llama.cpp-Version (empfohlen) +npx tsx agenten/llama-writer.ts --from-job mein-artikel --style blog + +# Aus Pipe (kein Job-Speicher) +npx tsx agenten/llama-verify-article.ts --json "$(cat artikel.txt)" \ + | npx tsx agenten/llama-writer.ts --from-report --style journalistic + +# Stile: journalistic | blog | academic | editorial | explanatory +# Länge anpassen +npx tsx agenten/llama-writer.ts --from-job mein-artikel --style blog --words 800 + +# Ollama-Version mit Cloud-Modell (besserer Stil, kostenpflichtig) +npx tsx agenten/ollama-writer.ts --from-job mein-artikel --style academic --cloud +``` + +**Beispielausgabe:** +```markdown +# Inflation in Deutschland: Was die Zahlen wirklich sagen + +_Die Diskussion um die Inflationsrate hat in den letzten Monaten an Schärfe gewonnen. +Doch was sagen die offiziellen Daten tatsächlich?_ + +Laut Statistischem Bundesamt lag die Inflationsrate in Deutschland 2024 bei 2,2 Prozent [1]. +Die EZB reagierte im Juni 2024 mit einer Zinssenkung auf 4,25 Prozent [2]... + +**Quellen:** +[1] [Destatis — Inflationsrate 2024](https://...) +[2] [EZB — Zinsentscheid Juni 2024](https://...) + +_[llama.cpp: Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf · 412 Wörter · kostenlos (lokal) · 22.4s]_ +``` + +--- + +### 5. Fehlschlüsse analysieren + +```bash +# ★ llama.cpp-Version (empfohlen) +npx tsx agenten/llama-logic-editor.ts "$(cat essay.txt)" + +# Nur Fehlschlüsse (schneller, kompakter) +npx tsx agenten/llama-logic-editor.ts --only-fallacies "$(cat essay.txt)" + +# JSON für Weiterverarbeitung +npx tsx agenten/llama-logic-editor.ts --json "$(cat essay.txt)" > analysis.json + +# Ollama-Version mit Cloud-Modell (deepseek-r1 via OpenRouter) +npx tsx agenten/ollama-logic-editor.ts --cloud "$(cat komplexer-text.txt)" +``` + +**Beispielausgabe:** +``` +⚠ Ad Hominem (kritisch) + Der Autor greift die Person an statt das Argument zu widerlegen. + _"Wer so denkt, hat offensichtlich keine Ahnung von Wirtschaft."_ + +⚠ Falsche Dichotomie (kritisch) + Es werden nur zwei Optionen präsentiert, obwohl weitere existieren. + _"Entweder wir sparen jetzt, oder wir gehen bankrott."_ + +~ Autoritätsargument (moderat) + Berufung auf Autorität ohne Prüfung der Aussage. + _"Experten sind sich einig, dass..."_ +``` + +--- + +### 6. Vollständiger Workflow — von Artikel zu Artikel + +```bash +# Schritt 1: Artikel fact-checken (★ llama.cpp, mit Job-Speicher) +npx tsx agenten/llama-verify-article.ts \ + --job-id klimaartikel \ + --mode deep \ + --verbose \ + "$(cat ~/Dokumente/klimaartikel.txt)" + +# Schritt 2: Verifikationsbericht ansehen +cat ~/.pi/agent/jobs/2026-05-12_klimaartikel/report.json | python3 -m json.tool + +# Schritt 3: Neuen Artikel schreiben (nur aus verifizierten Fakten) +npx tsx agenten/llama-writer.ts \ + --from-job klimaartikel \ + --style journalistic \ + --words 600 + +# Schritt 4: Argumente des Original-Artikels analysieren +npx tsx agenten/llama-logic-editor.ts --only-fallacies "$(cat ~/Dokumente/klimaartikel.txt)" +``` + +--- + +## Optionen im Überblick + +### Gemeinsame Flags (alle CLI-Tools) + +| Flag | Beschreibung | +|------|-------------| +| `--json` | Ausgabe als JSON (maschinenlesbar, stdout) | +| `--verbose`, `-v` | Ausführliche Ausgabe + Log-Datei in `~/.pi/agent/logs/` | +| `--model ` | Modell überschreiben | + +### `llama-claim-extractor.ts` / `ollama-claim-extractor.ts` + +| Flag | Beschreibung | +|------|-------------| +| `--only-checkable` | Nur empirisch prüfbare Claims anzeigen | +| `--max-claims ` | Max. Anzahl Claims (Standard: 40) | +| `--file ` | Textdatei statt Argument (nur llama-Version) | +| `--translate-to ` | Übersetzung der Claims (nur llama-Version) | + +### `llama-verifier.ts` / `ollama-verifier.ts` + +| Flag | Beschreibung | +|------|-------------| +| `--mode fast\|deep` | Perplexity-Modell: `sonar` (Standard) oder `sonar-pro` | +| `--user-language ` | Sprache des Urteilstexts, z.B. `de`, `en` (nur llama-Version) | + +### `llama-verify-article.ts` / `ollama-verify-article.ts` + +| Flag | Beschreibung | +|------|-------------| +| `--job-id ` | Job-Speicher aktivieren (Resume bei Unterbrechung) | +| `--mode fast\|deep` | Perplexity-Modus | +| `--max-claims ` | Max. zu prüfende Claims (Standard: 15, Max: 20) | +| `--no-cache` | Globalen Claim-Cache deaktivieren (erzwingt neue Perplexity-Anfragen) | + +### `llama-writer.ts` / `ollama-writer.ts` + +| Flag | Beschreibung | +|------|-------------| +| `--from-report` | Report von stdin lesen (Pipe-Modus) | +| `--from-job ` | Report aus Job-Speicher laden | +| `--style ` | `journalistic` \| `blog` \| `academic` \| `editorial` \| `explanatory` | +| `--words ` | Ziel-Wortanzahl (Standard: 400) | +| `--cloud` | OpenRouter statt lokalem Backend (nur ollama-writer, besserer Stil) | + +### `llama-logic-editor.ts` / `ollama-logic-editor.ts` + +| Flag | Beschreibung | +|------|-------------| +| `--only-fallacies` | Nur Fehlschlüsse ausgeben (ohne vollständige ArgumentMap) | +| `--cloud` | OpenRouter (nur ollama-logic-editor — deepseek-r1 via Cloud) | + +--- + +## Kosten-Übersicht + +| Schritt | Backend | Kosten | Anmerkung | +|---------|---------|--------|-----------| +| Claim-Extraktion | llama.cpp (Qwopus3.6) | $0.00 | auch bei Chunking | +| Perplexity `fast` | sonar | ~$0.005/Claim | Standard | +| Perplexity `deep` | sonar-pro | ~$0.015/Claim | für heikle Themen | +| Verdict-Synthese | llama.cpp (Qwopus3.6) | $0.00 | Batch für alle Claims | +| Artikel schreiben | llama.cpp (Qwopus3.6) | $0.00 | Standard ★ | +| Artikel schreiben | OpenRouter (`--cloud`) | ~$0.01–0.05 | besserer Stil | +| Argumentationsanalyse | llama.cpp (Qwopus3.6) | $0.00 | ★ | + +**Typische Gesamtkosten pro Artikel:** $0.03–0.15 (nur Perplexity) + +--- + +## Troubleshooting + +### „fetch failed" (llama.cpp) +```bash +# llama.cpp-Server läuft? +curl -s http://localhost:8000/v1/models +# Falls nicht: Server neu starten +llama-server --model Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf --host 0.0.0.0 --port 8000 -c 32768 +# Retry-Logik wartet automatisch 15s und versucht 3× +``` + +### „fetch failed" (Ollama) +```bash +# Ollama läuft? +systemctl status ollama +curl -s http://localhost:11434/api/tags | head -1 +# Ollama neu starten +sudo systemctl restart ollama +# Dann denselben Befehl nochmals aufrufen — Retry-Logik wartet automatisch 15s +``` + +### „0 Claims extrahiert" +Sollte mit der aktuellen Chunking-Implementierung nicht mehr auftreten. +Falls doch: Text kürzer als 500 Zeichen? Dann gibt es möglicherweise schlicht keine prüfbaren Behauptungen. + +### Erste Ollama-Anfrage dauert 200+ Sekunden +Das Modell wird geladen. Folgeaufrufe sind schnell. Dauerhaft lösen: +```bash +sudo systemctl edit ollama +# Environment="OLLAMA_KEEP_ALIVE=-1" +sudo systemctl daemon-reload && sudo systemctl restart ollama +``` + +### GPU wird nicht genutzt +```bash +nvidia-smi # GPU-Auslastung prüfen +# GPU 2 (zweite RTX 3090) ist aktuell idle — für parallele Nutzung: +sudo systemctl edit ollama +# Environment="CUDA_VISIBLE_DEVICES=1,2" +``` + +### Log-Dateien lesen +```bash +ls -lt ~/.pi/agent/logs/ # Neueste Logs zuerst +cat ~/.pi/agent/logs/2026-05-12_*.log | tail -50 +``` diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..62798c8 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,181 @@ +# HANDOFF.md — Aktueller Projektstand + +**Letzte Aktualisierung:** 2026-05-12 +**Session:** Vollständige llama.cpp-Pipeline + Writer-Umbenennung + +--- + +## Aktueller Stand: Dual-Backend-System (Ollama + llama.cpp) + +Alle P1–P3-Features implementiert. Zusätzlich vollständige llama.cpp-Pipeline für Reasoning-Modelle (Qwopus/Qwen3), die Ollama-Timeout-Probleme bei Thinking-Modellen umgeht. + +Naming-Convention: `ollama-.ts` (Ollama-Backend) / `llama-.ts` (llama.cpp-Backend, ★ BEVORZUGT). + +--- + +## Zuletzt erledigt (2026-05-12) + +### Vollständige llama.cpp-Pipeline implementiert +- **`llama-claim-extractor.ts`** — Pi-Tool: `extract_claims_llama` +- **`llama-verifier.ts`** — Pi-Tool: `verify_claim_llama` ★ BEVORZUGT +- **`llama-verify-article.ts`** — Pi-Tool: `verify_article_llama` +- **`llama-writer.ts`** — Pi-Tool: `write_article_llama` ★ BEVORZUGT +- Alle llama.cpp-Agenten: Schema im System-Prompt, `/no_think`-Prefix, `reasoning_content`-Fallback, Retry 3×/15s + +### Umbenennungen +- `writer.ts` → `ollama-writer.ts` +- `verify-article.ts` → `ollama-verify-article.ts` + +### Deployment +- Alle Symlinks in `~/.pi/agent/extensions/fact-checker/` aktualisiert +- `package.json`: 9 Extensions (4 Ollama + 4 llama.cpp + logic-editor) +- AGENTS.md, README.md, HANDOFF.md, TODO.md, WORKLOG.md aktualisiert +- Git: 47 Dateien committed (Branch: master) + +--- + +## Zuletzt erledigt (2026-04-17) + +### Standard-Modell für Claim-Extraktion auf `qwen3.5:9b` gesetzt +- **ollama-claim-extractor.ts** — `DEFAULT_MODEL` von `qwen3.5:27b` auf `qwen3.5:9b` geändert +- **Präzision:** 8/9 Claims vs. 9/9 (27B) — minimaler Verlust +- **Geschwindigkeit:** 2× schneller (96 s vs. 205 s bei Apollo-11-Text) +- **VRAM:** 6.6 GB statt 17 GB — vollständig in VRAM, kein CPU-Offloading +- **Kommentar** in Header aktualisiert: Empfehlung qwen3.5:9b (6.6 GB, 1 GPU, fast gleiche Präzision wie 27B, 2× schneller) +- **TypeBox-Parameter:** Beschreibung ergänzt "Empfohlene Alternative: qwen3.5:27b für maximale Präzision" + +--- + +## Zuletzt erledigt (P3-Session — 2026-04-16) + +### `lib/cache.ts` — neu (globaler Perplexity-Claim-Cache) +- SHA256 des normalisierten Claim-Texts als Cache-Key +- Ablageort: `~/.pi/agent/cache/perplexity/.json`, TTL 7 Tage +- `getCached(claimText)` / `setCached(claimText, data)` — fehlertolerant +- `pruneCache()` — abgelaufene Einträge löschen +- `cacheStats()` — {total, expired, sizeBytes} +- Integriert in `verify-article.ts`: prüft globalen Cache **vor** Job-Cache und Perplexity-Aufruf +- `--no-cache` CLI-Flag in `verify-article.ts` um Cache zu umgehen + +### `tests/corpus/` — 10 Testfälle +Themen: Inflation DE, EZB-Zins, Mondlandung, Bevölkerung DE, Erneuerbare Energien, +Bitcoin ATH, COVID-Impfstoff, Bundeshaushalt, Klimaabkommen, Weltbevölkerung. +- **Fehler-Fälle** (erwartetes `contradicted`): 001, 002, 004, 005, 006, 008 +- **Negativtests** (nur korrekte Fakten, kein False-Positive erwartet): 003, 007, 009, 010 + +### `tests/run_corpus.sh` — Test-Runner +- Führt alle 10 Fälle durch `verify-article --json`, vergleicht mit `expected.json` +- Berechnet Precision + Recall für `contradicted`-Urteile, TP/FP/FN/TN +- Selektiver Aufruf: `bash tests/run_corpus.sh case_001 case_002` +- Reports in `tests/results//`, Summary als `summary.txt` + +--- + +## Früher erledigt (P1/P2-Sessions) + +### `lib/jobs.ts` +Persistente Job-Verzeichnisse `~/.pi/agent/jobs/_/` — Resume-Logik für abgebrochene Pipelines. + +### `lib/logger.ts` +File-Logger `~/.pi/agent/logs/`, `--verbose`-Flag in allen CLI-Tools. + +### Chunking + Retry (`ollama-claim-extractor.ts`) +Texte >4000 Zeichen → Chunks ≤3000 (Absatzgrenzen), 3 Retries à 15s bei `fetch failed`. + +--- + +## Bekannte Einschränkungen + +| Problem | Ursache | Status | +|---------|---------|--------| +| Chunking-Laufzeit ~4 min/Chunk | qwen3.5:27b auf 1 GPU | erwartet, kein Bug | +| Erste Ollama-Anfrage nach Inaktivität ~200s | Modell-Ladezeit | OLLAMA_KEEP_ALIVE noch nicht gesetzt | +| `fetch failed` Ollama + Thinking-Modell + format: | qwen3.5:27b verbraucht Kontext-Budget im Thinking-Modus | **GELÖST**: llama-writer.ts verwenden | +| `fetch failed` bei manuellem Ollama-Neustart | Verbindung unterbrochen | Retry-Logik fängt das ab (3×15s) | +| Nur 1 GPU genutzt (GPU 2 idle) | Ollama kennt nur GPU 1 | CUDA_VISIBLE_DEVICES noch nicht gesetzt | + +--- + +## Offene Punkte + +### Nutzer-Aktion erforderlich: OLLAMA_KEEP_ALIVE + Dual-GPU + +```bash +sudo systemctl edit ollama +# Einfügen unter [Service]: +# Environment="OLLAMA_KEEP_ALIVE=-1" +# Environment="CUDA_VISIBLE_DEVICES=1,2" +# Environment="OLLAMA_NUM_PARALLEL=2" +sudo systemctl daemon-reload && sudo systemctl restart ollama +``` +Effekt: Modell bleibt geladen (keine 200s Wartezeit), beide RTX 3090s genutzt (48 GB VRAM → größere Modelle möglich). + +--- + +## Wichtige Pfade + +| Was | Pfad | +|-----|------| +| Projekt | `~/Pi_Agent_Projekts/text_agent/` | +| Pi Extensions | `~/.pi/agent/extensions/fact-checker/` | +| Lib-Symlink | `~/.pi/agent/extensions/lib` → `~/Pi_Agent_Projekts/text_agent/lib` | +| Standalone research-web | `~/.pi/agent/extensions/research-web.ts` | +| Job-Verzeichnisse | `~/.pi/agent/jobs/_/` | +| Log-Dateien | `~/.pi/agent/logs/.log` | +| Perplexity-Cache | `~/.pi/agent/cache/perplexity/.json` | +| Testkorpus | `~/Pi_Agent_Projekts/text_agent/tests/corpus/` | + +--- + +## GPU-Setup + +``` +GPU 0 T600 4 GB → Display +GPU 1 RTX 3090 24 GB → Ollama (nur diese, CUDA_VISIBLE_DEVICES noch nicht gesetzt) +GPU 2 RTX 3090 24 GB → idle (ungenutzt!) +``` + +Modelle aktuell auf GPU 1: +- `qwen3.5:27b` — 22 GB, für Extraktion + Verifikation + Schreiben +- `deepseek-r1:32b` — 19 GB, für Argumentationsanalyse (logic-editor) + +Mit `CUDA_VISIBLE_DEVICES=1,2` hätte Ollama 48 GB → könnte 70B-Modelle laden. + +--- + +## Umgebungsvariablen + +| Variable | Zweck | Status | +|----------|-------|--------| +| `PERPLEXITY_API_KEY` | Perplexity Sonar API | gesetzt ✓ | +| `OPENROUTER_API_KEY` | Cloud-Modelle via OpenRouter | gesetzt ✓ | +| `OLLAMA_KEEP_ALIVE` | Modell im Speicher halten | **nicht gesetzt** | +| `CUDA_VISIBLE_DEVICES` | Welche GPUs Ollama nutzt | **nicht gesetzt** (nur GPU 1) | +| `OLLAMA_NUM_PARALLEL` | Parallele Anfragen | **nicht gesetzt** | +| `ROUTER_FORCE_LOCAL=1` | Immer lokales Modell | optional | +| `ROUTER_FORCE_CLOUD=1` | Immer OpenRouter | optional | + +--- + +## Getestete Workflows + +```bash +# llama.cpp-Pipeline ★ BEVORZUGT (kein Ollama-Timeout bei Thinking-Modellen) +npx tsx agenten/llama-claim-extractor.ts "Textinhalt..." +npx tsx agenten/llama-verifier.ts "Die EZB hat den Leitzins im Juni 2024 gesenkt." +npx tsx agenten/llama-verify-article.ts --job-id mein-artikel "$(cat artikel.txt)" +npx tsx agenten/llama-writer.ts --from-job mein-artikel --style blog + +# Ollama-Pipeline (Fallback oder explizit gewünscht) +npx tsx agenten/ollama-claim-extractor.ts --verbose "$(cat ~/Dokumente/Umerziehung.md)" +npx tsx agenten/ollama-verify-article.ts --job-id mein-artikel --verbose "$(cat artikel.txt)" +npx tsx agenten/ollama-writer.ts --from-job mein-artikel --style blog + +# Argumentationsanalyse +npx tsx agenten/logic-editor.ts --only-fallacies "Argumentativer Text..." + +# Testkorpus ausführen +bash tests/run_corpus.sh # alle 10 Fälle +bash tests/run_corpus.sh --mode deep # mit sonar-pro +bash tests/run_corpus.sh case_001 case_002 # selektiv +``` diff --git a/PI_PROMPT.md b/PI_PROMPT.md new file mode 100644 index 0000000..fe26e85 --- /dev/null +++ b/PI_PROMPT.md @@ -0,0 +1,61 @@ +# Pi Coding Agent — Session-Prompt + +Du übernimmst die Weiterentwicklung eines laufenden TypeScript-Projekts. +Lies **zuerst** die folgenden Dateien, bevor du irgendetwas tust: + +1. `AGENTS.md` — deine vollständige Arbeitsgrundlage: Rolle, Workflow, Konventionen, Verbote, Architekturentscheidungen +2. `HANDOFF.md` — aktueller Projektstand, bekannte Einschränkungen, offene Punkte +3. `TODO.md` — priorisierte Aufgabenliste (erste offene `[ ]`-Aufgabe ist dein Startpunkt) +4. `WORKLOG.md` — nur die neuesten 2 Einträge + +Danach lies die konkrete Zieldatei in `agenten/` oder `lib/`, bevor du sie anfasst. + +--- + +## Projektpfad + +``` +~/Pi_Agent_Projekts/text_agent/ +``` + +## Was das System ist + +Multi-Agenten-Fact-Checker: TypeScript (ESM), kein Build-Schritt (`npx tsx`). +Ollama lokal (`qwen3.5:27b`, `deepseek-r1:32b`) + Perplexity Sonar API + optionales OpenRouter. +Alle Agenten laufen als Pi-Extension **und** als CLI-Tool. + +## Aktueller Stand (2026-04-17) + +**Alle P1–P3-Features implementiert und getestet. System ist feature-komplett.** + +- ✓ `ollama-claim-extractor.ts` — Chunking, Retry, Deduplication +- ✓ `ollama-verifier.ts` — Perplexity + Ollama-Verdict +- ✓ `verify-article.ts` — Pipeline-Orchestrator, Job-Speicher (`--job-id`), globaler Claim-Cache (`--no-cache`) +- ✓ `writer.ts` — aus Job oder Pipe, Stile, `--lang`, `--cloud` +- ✓ `logic-editor.ts` — Fehlschluss-Analyse, `--only-fallacies` +- ✓ `lib/cache.ts` — SHA256-Claim-Cache, TTL 7 Tage +- ✓ `lib/jobs.ts` — persistente Pipeline-Ergebnisse, Resume-Logik +- ✓ `lib/logger.ts` — File-Logger, `--verbose`-Flag +- ✓ `tests/corpus/` — 10 Testfälle mit expected.json +- ✓ `tests/run_corpus.sh` — Precision/Recall-Test-Runner + +**Offener Nutzer-Schritt (kein Code nötig):** +```bash +sudo systemctl edit ollama +# Environment="OLLAMA_KEEP_ALIVE=-1" +# Environment="CUDA_VISIBLE_DEVICES=1,2" +# Environment="OLLAMA_NUM_PARALLEL=2" +sudo systemctl daemon-reload && sudo systemctl restart ollama +``` + +## Deine Arbeitsweise in dieser Session + +- Lies erst, dann schreib — nie aus dem Gedächtnis +- Nach jeder Änderung: `npx tsc --noEmit` +- Teste via CLI (Kommandos in `AGENTS.md`) +- Nach jeder erledigten Aufgabe: `WORKLOG.md` ergänzen, `TODO.md` abhaken, `HANDOFF.md` aktualisieren +- Frage nur, wenn die „Wann Pi stoppen"-Bedingungen aus `AGENTS.md` zutreffen + +## Jetzt + +Lies die 4 Dateien oben. Dann sag mir in 3 Sätzen, was du als nächstes tun würdest — und warte auf meinen Startschuss. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fff651 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# Pi Text-Agent + +Lokales Multi-Agenten-System für Fact-Checking, Artikelschreiben und Argumentationsanalyse. +Läuft als Pi-Extension-Paket und als CLI. Zwei lokale KI-Backends: Ollama und llama.cpp (★ bevorzugt für Reasoning-Modelle), Webrecherche via Perplexity. + +--- + +## Was es kann + +``` +Artikel-Text + │ + ├─► extract_claims_llama ★ → Welche Behauptungen stecken im Text? + │ + ├─► verify_article_llama ★ → Welche Behauptungen sind wahr / falsch? + │ └─► verify_claim_llama ★ → Einzelne Behauptung prüfen + │ + ├─► write_article_llama ★ → Neuen Artikel nur aus verifizierten Fakten schreiben + │ + └─► analyze_logic_llama ★ → Welche logischen Fehlschlüsse enthält der Text? + +Suchanfrage + └─► research_web → Webrecherche via Perplexity + +★ = llama.cpp-Backend (bevorzugt); Ollama-Varianten ohne Suffix ebenfalls verfügbar +``` + +--- + +## Voraussetzungen + +- **llama.cpp-Server** mit `Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf` auf Port 8000 (★ bevorzugtes Backend) +- **Ollama** mit `qwen3.5:27b` und `deepseek-r1:32b` (Fallback-Backend + Logik-Analyse) +- **Perplexity API Key** (`PERPLEXITY_API_KEY`) — für Webrecherche und Fact-Checking +- **Pi Coding Agent** — für den Extension-Modus +- **Node.js** ≥ 20 (empfohlen: v22 via nvm) +- Optional: **OpenRouter API Key** (`OPENROUTER_API_KEY`) — für Cloud-Modelle + +```bash +# llama.cpp-Server starten (GPU 2, Port 8000) +llama-server --model Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf --host 0.0.0.0 --port 8000 -c 32768 + +# Ollama-Modelle laden (für ollama-logic-editor + Fallback) +ollama pull qwen3.5:27b +ollama pull deepseek-r1:32b +``` + +--- + +## Schnellstart + +```bash +cd ~/Pi_Agent_Projekts/text_agent + +# ★ llama.cpp-Pipeline (empfohlen) +npx tsx agenten/llama-claim-extractor.ts "Die Inflationsrate betrug 2024 in Deutschland 3,2%." +npx tsx agenten/llama-verifier.ts "Die EZB hat den Leitzins im Juni 2024 gesenkt." +npx tsx agenten/llama-verify-article.ts --job-id mein-artikel "$(cat artikel.txt)" +npx tsx agenten/llama-writer.ts --from-job mein-artikel --style blog + +# Fehlschlüsse analysieren (★ llama.cpp) +npx tsx agenten/llama-logic-editor.ts --only-fallacies "$(cat essay.txt)" +``` + +--- + +## Agenten + +| Agent | Pi-Tool | Modell | Kosten | +|-------|---------|--------|--------| +| `llama-claim-extractor.ts` ★ | `extract_claims_llama` | Qwopus3.6-35B (llama.cpp) | kostenlos | +| `llama-verifier.ts` ★ | `verify_claim_llama` | Perplexity + Qwopus3.6 | ~$0.005–0.015/Claim | +| `llama-verify-article.ts` ★ | `verify_article_llama` | Perplexity + Qwopus3.6 | ~$0.05–0.15/Artikel | +| `llama-writer.ts` ★ | `write_article_llama` | Qwopus3.6 (llama.cpp) | kostenlos | +| `ollama-claim-extractor.ts` | `extract_claims` | qwen3.5:27b (Ollama) | kostenlos | +| `ollama-verifier.ts` | `verify_claim` | Perplexity + qwen3.5:27b | ~$0.005–0.015/Claim | +| `ollama-verify-article.ts` | `verify_article` | Perplexity + qwen3.5:27b | ~$0.05–0.15/Artikel | +| `ollama-writer.ts` | `write_article` | qwen3.5:27b (Ollama) | kostenlos | +| `llama-logic-editor.ts` ★ | `analyze_logic_llama` | Qwopus3.6 (llama.cpp) | kostenlos | +| `ollama-logic-editor.ts` | `analyze_logic` | deepseek-r1:32b (Ollama) | kostenlos | +| `research-web.ts` | `research_web` | Perplexity | ~$0.001–0.005 | + +★ = llama.cpp-Backend, bevorzugt (kein Ollama-Timeout bei Reasoning-Modellen) + +--- + +## Job-Speicher + +`llama-verify-article.ts --job-id ` sichert jeden Schritt in `~/.pi/agent/jobs/`: + +``` +~/.pi/agent/jobs/2026-04-16_mein-artikel/ +├── input.txt ← Originaltext +├── claims.json ← Extrahierte Behauptungen +├── perplexity/ +│ ├── c001.json ← Perplexity-Ergebnis pro Claim (gecacht!) +│ └── c002.json +├── report.json ← Verifikationsbericht +├── article.md ← Fertiggestellter Artikel +└── meta.json ← Status, Timestamps, Kosten +``` + +Bei Unterbrechung: denselben Befehl nochmals aufrufen. Bereits abgeschlossene Schritte — inklusive bereits bezahlter Perplexity-Anfragen — werden übersprungen. + +--- + +## Verzeichnisstruktur + +``` +text_agent/ +├── agenten/ ← Agenten (Pi-Extension + CLI) +├── lib/ +│ ├── perplexity.ts ← Perplexity-API-Wrapper +│ ├── router.ts ← Model-Router (lokal/cloud) +│ ├── logger.ts ← File-Logger (~/.pi/agent/logs/) +│ ├── jobs.ts ← Job-Speicher (~/.pi/agent/jobs/) +│ └── cache.ts ← SHA256-Claim-Cache (~/.pi/agent/cache/perplexity/) +├── schemas/ ← JSON-Schema-Definitionen +├── tests/ +│ ├── corpus/ ← 10 Testfälle (input.txt, expected.json, notes.md) +│ └── run_corpus.sh ← Precision/Recall-Test-Runner +├── types/ ← TypeScript-Typ-Stubs +└── docs/ + └── ARCHITECTURE.md +``` + +--- + +## Deployment als Pi-Extension + +```bash +# Symlinks anlegen (einmalig) +mkdir -p ~/.pi/agent/extensions/fact-checker +ln -s ~/Pi_Agent_Projekts/text_agent/lib ~/.pi/agent/extensions/lib +for agent in ollama-claim-extractor llama-claim-extractor \ + ollama-verifier llama-verifier \ + ollama-verify-article llama-verify-article \ + ollama-logic-editor llama-logic-editor \ + ollama-writer llama-writer; do + ln -s ~/Pi_Agent_Projekts/text_agent/agenten/${agent}.ts \ + ~/.pi/agent/extensions/fact-checker/${agent}.ts +done + +# In Pi nach Änderungen +/reload +``` + +--- + +## Dokumentation + +| Datei | Inhalt | +|-------|--------| +| `BEDIENUNGSANLEITUNG.md` | Ausführliche Nutzungsanleitung mit Beispielen | +| `docs/ARCHITECTURE.md` | Technische Architektur, Datenfluß, VRAM-Details | +| `AGENTS.md` | Arbeitsgrundlage für Pi Coding Agent | +| `HANDOFF.md` | Aktueller Projektstand für Entwickler-Übergaben | +| `TODO.md` | Offene Aufgaben nach Priorität | + +--- + +## Technische Details + +- **Sprache:** TypeScript (ESM), keine Build-Schritte — direkt via `npx tsx` +- **llama.cpp:** OpenAI-kompatibles API `/v1/chat/completions`, Schema als JSON-Literal im System-Prompt, `/no_think`-Prefix für Reasoning-Modelle +- **Ollama structured output:** `format: `, `stream: false`, `num_ctx: 8192` +- **Chunking:** Texte > 4000 Zeichen werden automatisch in Abschnitte ≤ 3000 Zeichen aufgeteilt +- **Parallelität:** Max. 5 gleichzeitige Perplexity-Anfragen; llama.cpp und Ollama sequenziell +- **Retry:** 3 Versuche mit 15s Pause bei Verbindungsfehlern +- **Getestet auf:** Ubuntu, 2× RTX 3090 (24 GB), Node.js v22 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..f0f2f82 --- /dev/null +++ b/TODO.md @@ -0,0 +1,115 @@ +# TODO.md — Priorisierte Aufgabenliste + +Format: `[x]` erledigt, `[ ]` offen. Aufgaben sind nach Priorität sortiert. +Nach Erledigung: `[x]` setzen + WORKLOG.md ergänzen + HANDOFF.md aktualisieren. + +--- + +## P1 — Sofort + +- [x] **`logic-editor.ts` retest nach Prompt-Fixes** — bestätigt OK +- [x] **`writer.ts` End-to-End-Test via Pipe** — bestätigt OK +- [x] **Pi `/reload` und Extension-Test** — bestätigt OK (Probelauf zeigt alle 6 Extensions aktiv) + +- [x] **`verify_article` 0-Claims-Bug bei langem Text** + - Fix: dynamisches `num_ctx` in `callOllamaClaimExtract()` — 8192/16384/32768 je nach Textlänge + - Fix: 0-Claims wirft jetzt expliziten Fehler mit num_ctx + prompt_tokens in der Meldung + - Fix: Duplikat-Unterdrückung im System-Prompt ergänzt + - Getestet: Umerziehung.md (17964 Zeichen) → bekommt num_ctx=16384 ✓ + +- [x] **Pi-Prompt-Guidelines für `verify_claim` ergänzen** + - `ollama-verifier.ts` promptGuidelines: "Never call verify_claim for multiple claims simultaneously" + +--- + +## P2 — Mittelfristig (Stabilität + Konfiguration) + +- [x] **Job-Speicher: persistente Pipeline-Ergebnisse** + - `lib/jobs.ts` — `createJob/findJobDir/getOrCreateJob/saveJobFile/loadJobFile/updateJobMeta/listJobs` + - `ollama-verify-article.ts --job-id ` / `llama-verify-article.ts --job-id ` — speichert `input.txt`, `claims.json`, `perplexity/.json`, `report.json` + - Resume: bei erneutem Aufruf mit gleichem `--job-id` werden gecachte Ergebnisse wiederverwendet + - Claims-Cache: Extraktion übersprungen wenn `claims.json` vorhanden + - Perplexity-Cache: jeder einzelne `perplexity/.json` gecacht — kein doppelter API-Aufruf + - `ollama-writer.ts --from-job ` / `llama-writer.ts --from-job ` — liest `report.json`, speichert `article.md` + aktualisiert `meta.json` + - `meta.json` enthält Status (`created/extracting/verifying/writing/completed/failed`) + Step-Metadaten + +- [ ] **OLLAMA_KEEP_ALIVE via systemd setzen** + - Was: Modell wird nach 5min entladen, dann ~200s Ladezeit + - Wer: Nutzer muss selbst ausführen: + ```bash + sudo systemctl edit ollama + # Einfügen unter [Service]: + # Environment="OLLAMA_KEEP_ALIVE=-1" + sudo systemctl daemon-reload && sudo systemctl restart ollama + ``` + - Akzeptanzkriterium: Zweiter Ollama-Aufruf nach >5min braucht keine Ladezeit mehr + +- [x] **`logic-editor.ts` — `--only-fallacies` Flag** + - Gibt nur Fehlschlüsse aus (Text- oder JSON-Modus), kombinierbar mit `--json` + +- [x] **`verify-article.ts` — Fortschrittsanzeige** + - `onProgress`-Callback in `verifyArticle()` — schreibt auf stderr, stört `--json`-Output nicht + - Zeigt: Claim-Extraktion, Anzahl prüfbarer Claims, [N/M] pro Perplexity-Ergebnis, Urteilssynthese + +- [x] **`lib/logger.ts` — Persistentes Logging + `--verbose` in allen CLI-Tools** + - `createLogger(opts)` → schreibt in `~/.pi/agent/logs/.log` + - `Logger.info/warn/error/debug(msg, data)` — strukturierte Einträge mit ISO-Timestamp + - `verbose=true` → alle Einträge auch auf stderr; `warn`/`error` immer auf stderr + - `nullLogger` für Pi-Extension-Kontext (keine Seiteneffekte) + - `--verbose` / `-v` Flag in: `ollama-claim-extractor.ts`, `verify-article.ts`, `ollama-verifier.ts` + - Chunking-Details, Token-Counts, Perplexity-Kosten, Laufzeiten werden geloggt + +- [x] **Long-Text Bug final bestätigen: Chunking-Test auf Umerziehung.md** + - 5/7 Chunks erfolgreich (Chunk 6 failed weil Nutzer Ollama manuell neu startete — kein Bug) + - Retry-Logik ergänzt: 3 Versuche mit 15s Pause bei `fetch failed` + - Chunking funktioniert korrekt ✓ + +--- + +## P3 — Langfristig (Testkorpus + Metriken) + +- [x] **Testkorpus anlegen: `tests/corpus/`** + - 10 Fälle: Inflation, EZB-Zins, Mondlandung, Bevölkerung DE, Erneuerbare, Bitcoin, COVID-Impfstoff, Bundeshaushalt, Klimaabkommen, Weltbevölkerung + - Negativtests (nur korrekte Fakten): case_003, case_007, case_009 + +- [x] **Test-Runner-Skript** + - `tests/run_corpus.sh` — führt alle Fälle durch verify-article, vergleicht mit expected.json + - Precision/Recall für `contradicted`, Kosten, Laufzeit; Reports in `tests/results//` + - Verwendung: `bash tests/run_corpus.sh [--mode deep] [--no-cache] [case_001 ...]` + +- [x] **`ollama-writer.ts` — `--lang` Flag** — bereits implementiert (war nicht nötig) + +- [x] **`ollama-verify-article.ts` — Cache für wiederholte Claims** + - `lib/cache.ts`: SHA256-basierter File-Cache, TTL 7 Tage, `~/.pi/agent/cache/perplexity/` + - `--no-cache` Flag in `ollama-verify-article.ts` und `llama-verify-article.ts` + +--- + +## Erledigte Aufgaben (Phase 1–3) + +- [x] `package.json`, `tsconfig.json`, `types/pi-coding-agent.d.ts` +- [x] `schemas/claim.schema.json` +- [x] `schemas/source-record.schema.json` +- [x] `schemas/verification-result.schema.json` +- [x] `schemas/argument-map.schema.json` +- [x] `schemas/article-draft.schema.json` (inkl. Bugfix type-Array) +- [x] `agenten/ollama-claim-extractor.ts` — getestet +- [x] `lib/perplexity.ts` +- [x] `agenten/ollama-verifier.ts` — getestet +- [x] `agenten/ollama-verify-article.ts` — End-to-End getestet (umbenannt von verify-article.ts) +- [x] `lib/router.ts` +- [x] `agenten/logic-editor.ts` — getestet +- [x] `agenten/ollama-writer.ts` — getestet (umbenannt von writer.ts) +- [x] Deployment: Symlinks in `~/.pi/agent/extensions/` +- [x] `AGENTS.md`, `HANDOFF.md`, `TODO.md`, `WORKLOG.md`, `docs/ARCHITECTURE.md` + +## Erledigte Aufgaben (llama.cpp-Pipeline) + +- [x] `agenten/llama-claim-extractor.ts` — Pi-Tool: extract_claims_llama, getestet +- [x] `agenten/llama-verifier.ts` — Pi-Tool: verify_claim_llama, getestet (★ BEVORZUGT) +- [x] `agenten/llama-verify-article.ts` — Pi-Tool: verify_article_llama, End-to-End getestet +- [x] `agenten/llama-writer.ts` — Pi-Tool: write_article_llama, getestet (★ BEVORZUGT) +- [x] `agenten/llama-logic-editor.ts` — Pi-Tool: analyze_logic_llama, getestet (★ BEVORZUGT) +- [x] `logic-editor.ts` → `ollama-logic-editor.ts` (Naming-Convention) +- [x] Symlinks + package.json für alle llama.cpp-Agenten aktualisiert +- [x] Dokumentation vollständig aktualisiert (BEDIENUNGSANLEITUNG.md, README.md, AGENTS.md, HANDOFF.md, WORKLOG.md, TODO.md) diff --git a/WORKLOG.md b/WORKLOG.md new file mode 100644 index 0000000..99b4cd7 --- /dev/null +++ b/WORKLOG.md @@ -0,0 +1,309 @@ +# WORKLOG.md — Append-only Arbeitslog + +Neueste Einträge oben. Nicht bearbeiten — nur anhängen. + +--- + +## [2026-05-12] llama-logic-editor + Dokumentation vollständig aktualisiert + +### Erledigt +- `agenten/llama-logic-editor.ts` — Pi-Tool: `analyze_logic_llama` ★ BEVORZUGT +- `logic-editor.ts` → `ollama-logic-editor.ts` (Naming-Convention) +- BEDIENUNGSANLEITUNG.md: Kompletter Rewrite auf llama.cpp-Pipeline +- README.md: logic-editor-Einträge auf ollama/llama-Varianten aktualisiert +- AGENTS.md: logic-editor-Einträge aktualisiert + +### tsc: fehlerfrei +### Getestet +- `npx tsx agenten/llama-logic-editor.ts "Sokrates-Syllogismus"` → STARK, 13s ✓ + +--- + +## [2026-05-12] Vollständige llama.cpp-Pipeline + Writer-Umbenennung + +### Erledigt + +**Neue llama.cpp-Agenten** (alle: Schema im System-Prompt, `/no_think`, `reasoning_content`-Fallback, Retry 3×/15s): +- `agenten/llama-claim-extractor.ts` — Pi-Tool: `extract_claims_llama` +- `agenten/llama-verifier.ts` — Pi-Tool: `verify_claim_llama` ★ BEVORZUGT, `userLanguage`-Parameter +- `agenten/llama-verify-article.ts` — Pi-Tool: `verify_article_llama`, Batch-Verdict-Aufruf +- `agenten/llama-writer.ts` — Pi-Tool: `write_article_llama` ★ BEVORZUGT + +**Umbenennungen** (Naming-Convention `ollama-*` / `llama-*`): +- `writer.ts` → `ollama-writer.ts` (Pi-Tool bleibt `write_article`) +- `verify-article.ts` → `ollama-verify-article.ts` (Pi-Tool bleibt `verify_article`) + +**Deployment:** +- `~/.pi/agent/extensions/fact-checker/package.json`: 9 Extensions +- Symlinks: `writer.ts` entfernt, `ollama-writer.ts` + `llama-writer.ts` hinzugefügt +- Alle anderen llama.cpp-Symlinks angelegt + +**Dokumentation:** AGENTS.md, README.md, HANDOFF.md, TODO.md, WORKLOG.md aktualisiert + +### tsc: fehlerfrei + +### Getestet +- `npx tsx agenten/llama-writer.ts --from-job pipeline-test --style blog` → 253 Wörter, 18.7s ✓ +- Pipeline-Test: `llama-verify-article` → `llama-writer` kompatibel (gleiche `VerificationReport`-Struktur) ✓ + +### Probleme und Lösungen +| Problem | Lösung | +|---------|--------| +| `writer.ts` → "fetch failed" mit qwen3.5:27b + `format:` | llama-writer.ts: Schema im System-Prompt, `/no_think` | +| `createLogger("name", "debug")` — falsige Signatur | `createLogger({ verbose: true })` verwendet | + +### Verbleibende offene Punkte +- Pi `/reload` (manuell) damit `write_article_llama` aktiv wird +- OLLAMA_KEEP_ALIVE via systemd (optional, niedrige Priorität) + +--- + +## [2026-04-17] Standard-Modell auf qwen3.5:9b gesetzt + +### Erledigt +- **claim-extractor.ts** — Standardmodell von `qwen3.5:27b` auf `qwen3.5:9b` geändert + - Präzision: 8/9 Claims vs. 9/9 (qwen3.5:27b) — fast gleich + - Geschwindigkeit: 2× schneller (96 s vs. 205 s bei Apollo-11-Text) + - VRAM: 6.6 GB statt 17 GB — vollständig in VRAM, kein CPU-Offloading +- Kommentar in Header aktualisiert: Empfehlung qwen3.5:9b (6.6 GB, 1 GPU, fast gleiche Präzision wie 27B, 2× schneller) +- TypeBox-Parameter-Beschreibung ergänzt: "Empfohlene Alternative: qwen3.5:27b für maximale Präzision" + +### tsc: fehlerfrei + +### Getestet +- `npx tsx agenten/claim-extractor.ts --verbose "Test"` → zeigt "Ollama-Modell: qwen3.5:9b" +- Apollo-11-Test: 8 Claims extrahiert (qwen3.5:27b: 9 Claims) — akzeptabler Trade-off + +--- + +## [2026-04-16] Job-Speicher implementiert + +### Erledigt + +**lib/jobs.ts** (neue Datei, ~200 Zeilen) — reines File-I/O, keine Agenten-Abhängigkeiten +- `createJob(slug, model)` → legt `~/.pi/agent/jobs/_/` + `perplexity/`-Unterverzeichnis an +- `findJobDir(slug)` → neuestes Job-Verzeichnis mit diesem Slug +- `getOrCreateJob(slug, model)` → Resume-Logik: vorhandenes wiederverwenden oder neu anlegen +- `saveJobFile/loadJobFile/loadJobText/jobFileExists` → Datei-I/O mit JSON-Serialisierung +- `updateJobMeta` → shallow merge in `meta.json` mit `updatedAt`-Timestamp +- `listJobs` → alle Jobs sortiert nach Datum (neueste zuerst) +- `formatJobList` → Tabellenausgabe für CLI + +**verify-article.ts — `--job-id `** +- Erstlauf: erstellt Job, speichert `input.txt`, `claims.json`, `perplexity/.json`, `report.json` +- Resume: gecachte `claims.json` → Extraktion übersprungen; gecachte `perplexity/.json` → kein API-Aufruf +- `meta.json` wird bei jedem Schritt aktualisiert (status + step-Metadaten) +- Fehler: `meta.json` auf `status: "failed"` gesetzt + +**writer.ts — `--from-job ` + `--from-report`** +- `--from-job`: lädt `report.json` aus Job, speichert `article.md` nach Fertigstellung +- Fehlerausgabe wenn Job oder report.json nicht gefunden + +**tsc:** fehlerfrei + +### Verzeichnisstruktur + +``` +~/.pi/agent/jobs/_/ +├── input.txt +├── claims.json +├── perplexity/ +│ ├── c001.json +│ └── c002.json ... +├── report.json +├── article.md +└── meta.json +``` + +--- + +## [2026-04-16] Verbose-Modus + lib/logger.ts implementiert + +### Erledigt + +**lib/logger.ts** (neue Datei, ~90 Zeilen) +- `Logger`-Klasse mit `info/warn/error/debug`-Methoden +- Schreibt in `~/.pi/agent/logs/YYYY-MM-DD_HH-MM-SS[_jobId].log` (append, FS-Fehler werden ignoriert) +- `verbose=true` → alle Einträge zusätzlich auf stderr; `warn`/`error` immer auf stderr +- `createLogger(opts)` — Factory; legt LOG_DIR automatisch an +- `nullLogger` — Null-Objekt für Pi-Extension-Kontext (keine Seiteneffekte) + +**claim-extractor.ts — Logging + --verbose** +- Alle Chunking-Schritte geloggt: Chunk-Aufteilung, Token-Counts, Laufzeit pro Chunk, Dedup-Stats +- Ollama-Antwort-Details geloggt (promptTokens, outputTokens, rawLength) +- Fehler (API-Fehler, JSON-Parse, 0-Claims) mit Kontext geloggt +- `--verbose` / `-v` CLI-Flag: erstellt Logger mit verbose=true + +**verify-article.ts — Logging + --verbose** +- Pipeline-Start/Ende geloggt (textLength, model, totalCostUSD, latencyMs) +- Perplexity-Outcomes geloggt (successful/failed Count, Kosten) +- `--verbose` / `-v` CLI-Flag + +**verifier.ts — Logging + --verbose** +- Perplexity-Aufruf + Urteilssynthese geloggt +- `--verbose` / `-v` CLI-Flag + +**tsc:** fehlerfrei + +### Chunking-Test läuft + +- `claim-extractor.ts --verbose "$(cat Umerziehung.md)"` gestartet +- Logger bestätigt: 7 Chunks (2945–2984 Zeichen), Chunk 1/7 wird verarbeitet +- Alter Fehler (`fetch failed`) war temporär (Ollama nicht bereit); kein Code-Bug + +--- + +## [2026-04-16] Phasen 1–3 komplett, Handoff-Dokumentation + +### Erledigt + +**Phase 1 — Fundament** +- Projektstruktur analysiert, GPU-Setup entschieden (qwen3.5:27b auf 1 GPU) +- `package.json` + `tsconfig.json` erstellt (ESM, tsx, no-build) +- `types/pi-coding-agent.d.ts` — Stub für lokale TS-Prüfung ohne Pi-Paket +- `schemas/claim.schema.json` — ClaimSet mit 6 Claim-Typen, 3 Checkability-Stufen +- `schemas/source-record.schema.json` — Perplexity-Quellen +- `schemas/verification-result.schema.json` — 5 Status-Werte +- `agenten/claim-extractor.ts` (509 Zeilen) — Pi-Extension + CLI + - Getestet: qwen3.5:27b extrahiert korrekt, structured output funktioniert + +**Phase 2 — Kernpipeline** +- `lib/perplexity.ts` (175 Zeilen) — Retry, Kostenberechnung, Deduplizierung +- `agenten/verifier.ts` (427 Zeilen) — Perplexity + Ollama-Verdict + - Getestet: "Inflationsrate 3,2%" → WIDERLEGT (korrekt: 2,2%, Destatis-Quelle) + - Kosten: ~$0.0054/Claim +- `agenten/verify-article.ts` (600 Zeilen) — Orchestrator + - `runWithConcurrencyLimit()` für max. 5 parallele Perplexity-Calls + - Batch-Ollama-Verdict (1 Call für N Claims, `num_ctx: 16384`) + - End-to-End-Test: 4 falsche Facts erkannt, 2 korrekte bestätigt — $0.0324, 407s + +**Phase 3 — Produktionsreife** +- `lib/router.ts` (232 Zeilen) — TaskType-basiertes Routing, ENV-Overrides +- `schemas/argument-map.schema.json` — 12 Fehlschluss-Typen +- `schemas/article-draft.schema.json` — 5 Stile, Quellenverzeichnis + - Bugfix: `"type": "["string", "null"]"` (String) → `"type": ["string", "null"]` (Array) +- `agenten/logic-editor.ts` (538 Zeilen) — deepseek-r1:32b, Pi + CLI + - Zwei Bugs gefixt (location-Feld, Chinesische Zeichen im Output) + - **Retest nach Fix steht noch aus** +- `agenten/writer.ts` (522 Zeilen) — Pipe-CLI, 5 Stile, Quellenautomatik + - tsc fehlerfrei, **End-to-End-Test steht noch aus** + +**Deployment** +- `~/.pi/agent/extensions/fact-checker/` mit 5 Symlinks +- `~/.pi/agent/extensions/lib` → `~/Pi_Agent_Projekts/text_agent/lib` +- Pi `/reload` noch nicht getestet + +**Handoff-Dokumentation** +- `AGENTS.md`, `SYSTEM.md`, `HANDOFF.md`, `TODO.md`, `WORKLOG.md`, `docs/ARCHITECTURE.md` + +### Probleme und Lösungen + +| Problem | Lösung | +|---------|--------| +| `@mariozechner/pi-coding-agent` nicht von tsc gefunden | Lokaler Typ-Stub in `types/` | +| `execute params` implicit `any` in Stub | Generic entfernt, `params: any` direkt | +| `ClaimSet` nicht exportiert | `export type` zu allen Typen in claim-extractor.ts | +| `ArticleDraft` fehlt `schema_version` | Lokaler Typ ergänzt in writer.ts | +| `article-draft.schema.json` type-Bug | Array-Syntax korrigiert | +| logic-editor zeigt JSON-Keys statt Textzitate | Prompt-Anweisung mit Zitat-Beispiel ergänzt | +| Chinesische Zeichen im Output | "Antworte ausschließlich auf Deutsch." an Prompt-Anfang | +| Nutzer will `.bashrc` nicht von Claude editieren lassen | Anleitung gegeben: manuell `export OLLAMA_KEEP_ALIVE=-1` einfügen | +| Ollama läuft via systemd, nicht direkt | Anleitung: `sudo systemctl edit ollama` + `Environment=...` | + +### Offen nach dieser Session + +- logic-editor.ts retest nach Fixes +- writer.ts End-to-End-Test +- Pi `/reload` bestätigen +- OLLAMA_KEEP_ALIVE in systemd setzen (Nutzer) +- Testkorpus aufbauen + +--- + + + +## [2026-04-16] 0-Claims-Bug gefixt, sequential-Guideline ergänzt + +### Erledigt + +**claim-extractor.ts — dynamisches num_ctx** +- Ursache des 0-Claims-Bugs: `num_ctx=8192` für 17964-Zeichen-Essay zu klein + (Eingabe ~4491 Tokens + Prompt ~1000 + Output ~3200 = ~8691 → Overflow) +- Fix: `estimateNumCtx(textLength, maxClaims)` berechnet 8192/16384/32768 dynamisch + - Umerziehung.md (17964 Zeichen) → 16384 ✓ + - Kurze Texte (<~3000 Zeichen) → bleiben bei 8192 +- Fix: 0-Claims wirft jetzt Fehler: `"Ollama hat 0 Claims extrahiert (num_ctx=..., prompt_tokens=...)"` +- Fix: System-Prompt Duplikat-Unterdrückung: "Extrahiere jeden Sachverhalt nur einmal" + +**verify-article.ts — Warnung bei 0 prüfbaren Claims** +- `onProgress("⚠ Keine prüfbaren Claims gefunden...")` wenn checkableClaims.length === 0 + +**verifier.ts — sequential Guideline** +- `promptGuidelines` ergänzt: nie mehrere verify_claim gleichzeitig aufrufen + +**tsc:** fehlerfrei nach allen Änderungen + +## [2026-04-16] Probelauf analysiert, Status-Dateien aktualisiert + +### Probelauf-Ergebnisse (Probelauf.md) +- Pi lädt alle 6 Extensions korrekt ✓ +- `extract_claims` auf 2.000-Wort-Essay: 25 Claims (14 prüfbar, 10 teilweise, 1 Meinung) — 285s ✓ +- `verify_claim` einzeln: 11/14 bestätigt, 2 widerlegt (41 Mio. Wohnungen veraltet, IW-Zahl nicht belegbar), 1 → deep retry → bestätigt ✓ +- `research_web`: korrekte Recherche zu Wohnungsbestand ✓ +- **Problem:** `verify_article` auf langem Text → 0 Claims extrahiert (173s) — Bug +- **Problem:** parallele `verify_claim`-Aufrufe → viele `fetch failed` — Ollama-Überlastung + +### Status-Dateien aktualisiert +- HANDOFF.md: Probelauf-Ergebnisse und neue offene Punkte eingetragen +- TODO.md: P1 um Bug-Fix und Prompt-Guidelines ergänzt, erledigte P1-Items abgehakt + +## [2026-04-16] P2-Features implementiert + +### Erledigt + +**verify-article.ts — Fortschrittsanzeige** +- `onProgress`-Callback als optionaler Parameter in `verifyArticle()` +- Meldet: "Claims extrahieren...", "N Claims extrahiert — M prüfbar", "[1/3] ✓ ...", "Urteilssynthese..." +- CLI-Modus: Callback schreibt auf `process.stderr` → `--json`-Output auf stdout unberührt +- Pi-Extension: kein Callback → keine Seiteneffekte + +**logic-editor.ts — `--only-fallacies` Flag** +- Neues CLI-Flag `--only-fallacies`: gibt nur Fehlschlüsse ohne vollständige ArgumentMap aus +- Kombinierbar mit `--json` → liefert JSON-Array der fallacies +- Ohne `--json` → formatierter Text mit Icon, Typ, Beschreibung, Zitat + +**Beide:** tsc fehlerfrei, CLI-Tests bestanden + +## [2026-04-16] P1-Bugs gefixt, alle Agenten getestet + +### Erledigt + +**logic-editor.ts — Bugfix + Retest** +- Gefunden: `analyzeWithOpenRouter(model, text, signal)` — Argumente vertauscht (model/text) +- Fix: `analyzeWithOpenRouter(text, model, signal)` +- Prompt-Fixes aus vorheriger Session (location-Feld, Deutsch-Anweisung) bestätigt +- Retest erfolgreich: 3 Fehlschlüsse korrekt erkannt (Ad Hominem critical, Falsche Dichotomie critical, Autoritätsargument moderate) +- location-Felder zeigen wörtliche Zitate ✓, kein Chinesisch ✓, alles Deutsch ✓ + +**writer.ts — Bugfix + End-to-End-Test** +- Gefunden: `routeModel("article_writing")` ohne cloud-Flag wählte OpenRouter statt Ollama + (weil OPENROUTER_API_KEY gesetzt → medium-Complexity → Cloud-Branch) + → OpenRouter-Modell lieferte `body` als Array, `lead` als undefined +- Fix in writer.ts: complexity "low" wenn kein --cloud → Router wählt immer Ollama +- End-to-End-Test: 3 Claims → verify-article → writer --style blog + - c001 (Inflationsrate 3,2%) korrekt als `contradicted` ausgeschlossen + - c002 (EZB-Zinssenkung Juni 2024) + c003 (DAX 20.000) als `supported` verwendet + - Blog-Artikel mit Quellenverzeichnis, editorial_notes korrekt + - Laufzeit: ~144s, kostenlos (lokal) +- tsc nach allen Fixes: fehlerfrei + +**Handoff-Dokumentation vervollständigt** +- SYSTEM.md, HANDOFF.md, TODO.md, WORKLOG.md, docs/ARCHITECTURE.md geschrieben +- TODO.md: P1-Aufgaben abgehakt +- HANDOFF.md: Offene Punkte aktualisiert + +### Verbleibende offene Punkte + +- Pi `/reload` in Pi-App testen (nur vom Nutzer durchführbar) +- OLLAMA_KEEP_ALIVE via systemd setzen (nur vom Nutzer durchführbar) +- Testkorpus (tests/corpus/) — P3, noch nicht begonnen diff --git a/agenten/llama-claim-extractor.ts b/agenten/llama-claim-extractor.ts new file mode 100644 index 0000000..7f26b86 --- /dev/null +++ b/agenten/llama-claim-extractor.ts @@ -0,0 +1,781 @@ +/** + * llama-claim-extractor.ts + * Pi-Extension + CLI: Einzelbehauptungen aus Texten extrahieren via lokalem llama.cpp + * + * Als Pi-Extension: ~/.pi/agent/extensions/llama-claim-extractor.ts + * Nach Änderungen in Pi: /reload + * + * Als CLI: + * npx tsx agenten/llama-claim-extractor.ts "Textinhalt..." + * npx tsx agenten/llama-claim-extractor.ts --file artikel.txt + * npx tsx agenten/llama-claim-extractor.ts --only-checkable --file artikel.txt + * npx tsx agenten/llama-claim-extractor.ts --json "..." (nur JSON-Ausgabe) + * + * llama.cpp-Server starten: + * llama-server --model --host 0.0.0.0 --port 8000 -c 8192 + * + * Hinweis: llama.cpp verwendet das OpenAI-kompatible API-Format (/v1/chat/completions). + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { readFile } from "node:fs/promises"; +import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +export type ClaimType = "fact" | "causal" | "statistical" | "quote" | "prediction" | "opinion"; +export type Checkability = "checkable" | "partly_checkable" | "not_checkable"; + +export type Claim = { + claim_id: string; + text: string; + text_translated?: string; // Übersetzung für Lesbarkeit — NIE für Faktencheck verwenden + claim_type: ClaimType; + checkability: Checkability; + needs_citation: boolean; + entities: string[]; + time_scope: string | null; + source_sentence: string; +}; + +export type ClaimSet = { + schema_version: "1.0.0"; + text_language: string; + extraction_notes: string; + total_claims: number; + claims: Claim[]; +}; + +// llama.cpp OpenAI-kompatibles API-Format +// reasoning_content: Qwen3/DeepSeek-R1-Reasoning-Modelle schreiben Denkkette hierhin +type LlamaResponse = { + choices: Array<{ + message?: { content?: string; reasoning_content?: string }; + finish_reason?: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf"; +const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000"; +const DEFAULT_MAX_CLAIMS = 40; +const TEMPERATURE = 0.1; +// Reasoning-Modelle brauchen mehr Tokens: Denkkette + JSON-Output +// Mit Übersetzung noch mehr: base 16384, mit Translation 32768 +const MAX_TOKENS_BASE = 16384; +const MAX_TOKENS_WITH_TRANSLATION = 32768; + +const CHUNK_THRESHOLD = 4000; +const CHUNK_SIZE = 3000; + +// --------------------------------------------------------------------------- +// JSON-Schema für strukturierten Output +// --------------------------------------------------------------------------- + +export const CLAIM_JSON_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + schema_version: { type: "string" }, + text_language: { type: "string" }, + extraction_notes: { type: "string" }, + total_claims: { type: "integer" }, + claims: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + claim_id: { type: "string" }, + text: { type: "string" }, + text_translated: { type: "string" }, + claim_type: { + type: "string", + enum: ["fact", "causal", "statistical", "quote", "prediction", "opinion"], + }, + checkability: { + type: "string", + enum: ["checkable", "partly_checkable", "not_checkable"], + }, + needs_citation: { type: "boolean" }, + entities: { type: "array", items: { type: "string" } }, + time_scope: { type: ["string", "null"] }, + source_sentence: { type: "string" }, + }, + required: [ + "claim_id", + "text", + "claim_type", + "checkability", + "needs_citation", + "entities", + "time_scope", + "source_sentence", + ], + }, + }, + }, + required: ["schema_version", "text_language", "extraction_notes", "total_claims", "claims"], +}; + +// --------------------------------------------------------------------------- +// System-Prompt +// --------------------------------------------------------------------------- + +function buildSystemPrompt(maxClaims: number, translateTo?: string): string { + return `Du bist ein Experte für Faktenextraktion und Fact-Checking-Vorbereitung. + +Deine Aufgabe: Analysiere den Text und extrahiere alle Behauptungen als diskrete, einzeln prüfbare Einheiten. +Extrahiere maximal ${maxClaims} Behauptungen. Bei sehr langen Texten priorisiere die wichtigsten und prüfbarsten. + +REGELN für die Extraktion: +- Formuliere jede Behauptung als eigenständigen, vollständigen Satz (nicht als Fragment) +- Behalte den Sinn der Originalformulierung bei, mache Behauptungen aber selbstständig lesbar +- claim_id: fortlaufend "c001", "c002", "c003", ... + +CLAIM TYPES: +- fact: Konkrete Tatsachenbehauptung ("X ist Y", "X hat Z getan") +- causal: Kausalbehauptung ("X hat zu Y geführt", "wegen X passiert Y") +- statistical: Zahlen, Prozentwerte, Statistiken, Rankings +- quote: Wörtliches oder indirektes Zitat einer Person +- prediction: Prognose, Vorhersage, Erwartung über Zukunftsereignisse +- opinion: Wertung, Meinung, normative Aussage (gut/schlecht/sollte) + +CHECKABILITY: +- checkable: Empirisch überprüfbar durch Primärquellen, Datenbanken, offizielle Stellen +- partly_checkable: Nur teilweise prüfbar (z.B. enthält sowohl Fakt als auch Wertung) +- not_checkable: Reine Meinung, reine Prognose, Werturteil ohne Tatsachenkern + +NEEDS_CITATION: true wenn Zahlen, spezifische Fakten, Zitate oder Studienergebnisse vorhanden + +ENTITIES: Alle benannten Entitäten: Personen, Organisationen, Länder, Institutionen, Produkte, konkrete Daten + +TIME_SCOPE: Zeitrahmen wenn angegeben (z.B. "2024", "Q1 2025", "seit 1990"), sonst null + +SOURCE_SENTENCE: Der originale Satz aus dem Quelltext (wörtlich, max. 200 Zeichen) + +DUPLIKATE: Extrahiere jeden Sachverhalt nur einmal. Wenn derselbe Fakt im Text mehrfach vorkommt (z.B. als Einleitung und später als Detail), erstelle nur einen Claim dafür. + +SPRACHE DES OUTPUTS (ZWINGEND): +- "text" und "source_sentence" IMMER in der Originalsprache des Artikels belassen — niemals übersetzen +- Wörtliche Zitate (claim_type="quote") wortwörtlich aus dem Text übernehmen +- Übersetzungen verfälschen den späteren Faktencheck und sind in diesen Feldern verboten +` + (translateTo + ? "\nÜBERSETZUNG (zusätzlich):\n" + + "- Füge für jeden Claim das Feld text_translated hinzu\n" + + "- text_translated enthält die Übersetzung von text ins " + (translateTo === "de" ? "Deutsche" : translateTo === "en" ? "Englische" : translateTo) + "\n" + + "- Nur zur Lesbarkeit — nicht für den Faktencheck\n" + : "") + ` +ANTWORTFORMAT: Antworte NUR mit einem JSON-Objekt — kein Freitext davor oder danach. Das JSON muss folgende Felder enthalten: +- schema_version: "1.0.0" +- text_language: Sprache des Textes als ISO 639-1 Code (z.B. "de", "en", "fr") +- extraction_notes: Kurze Notiz zur Extraktion +- total_claims: Anzahl der Claims +- claims: Array von Claim-Objekten mit den Feldern: + - claim_id: "c001", "c002", etc. + - text: Die Behauptung als vollständiger Satz (ORIGINALSPRACHE!) + ` + (translateTo ? "- text_translated: Übersetzung ins " + (translateTo === "de" ? "Deutsche" : translateTo === "en" ? "Englische" : translateTo) + "\n " : "") + `- claim_type: einer von [fact, causal, statistical, quote, prediction, opinion] + - checkability: einer von [checkable, partly_checkable, not_checkable] + - needs_citation: true/false + - entities: Array von benannten Entitäten + - time_scope: Zeitrahmen oder null + - source_sentence: Originalsatz aus dem Text (ORIGINALSPRACHE!, max. 200 Zeichen)`; +} + +// --------------------------------------------------------------------------- +// Text-Chunking für lange Texte +// --------------------------------------------------------------------------- + +function splitIntoChunks(text: string): string[] { + const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 0); + const chunks: string[] = []; + let current = ""; + + for (const para of paragraphs) { + if (current.length + para.length + 2 > CHUNK_SIZE && current.length > 0) { + chunks.push(current.trim()); + current = para; + } else { + current = current ? current + "\n\n" + para : para; + } + } + if (current.trim()) chunks.push(current.trim()); + return chunks; +} + +function deduplicateClaims(claims: Claim[]): Claim[] { + const seen = new Set(); + return claims.filter((c) => { + const key = c.text.toLowerCase().replace(/\s+/g, " ").trim(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +// --------------------------------------------------------------------------- +// llama.cpp-Aufruf +// --------------------------------------------------------------------------- + +export async function callLlamaClaimExtract( + text: string, + model: string, + maxClaims: number, + signal?: AbortSignal, + logger?: Logger, + translateTo?: string +): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { + const log = logger ?? nullLogger; + if (text.length > CHUNK_THRESHOLD) { + log.info("Text zu lang für Single-Pass — Chunking aktiv", { textLength: text.length, threshold: CHUNK_THRESHOLD }); + return callLlamaClaimExtractChunked(text, model, maxClaims, signal, log, translateTo); + } + log.debug("Single-Pass Extraktion", { textLength: text.length, model, maxClaims }); + return callLlamaClaimExtractSingle(text, model, maxClaims, signal, log, translateTo); +} + +async function callLlamaClaimExtractChunked( + text: string, + model: string, + maxClaims: number, + signal?: AbortSignal, + logger?: Logger, + translateTo?: string +): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { + const log = logger ?? nullLogger; + const t0 = Date.now(); + const chunks = splitIntoChunks(text); + const claimsPerChunk = Math.ceil(maxClaims / chunks.length); + + log.info(`Text in ${chunks.length} Chunks aufgeteilt`, { + chunks: chunks.length, + claimsPerChunk, + chunkLengths: chunks.map((c) => c.length), + }); + + let totalIn = 0; + let totalOut = 0; + const allClaims: Claim[] = []; + let language = "de"; + const notes: string[] = []; + + for (let i = 0; i < chunks.length; i++) { + log.info(`Chunk ${i + 1}/${chunks.length} extrahieren...`, { chunkLength: chunks[i].length, claimsPerChunk }); + const result = await callLlamaClaimExtractSingle(chunks[i], model, claimsPerChunk, signal, log, translateTo); + log.info(`Chunk ${i + 1}/${chunks.length} fertig`, { + claims: result.claimSet.claims.length, + tokensIn: result.tokensIn, + tokensOut: result.tokensOut, + latencyMs: result.latencyMs, + }); + allClaims.push(...result.claimSet.claims); + totalIn += result.tokensIn; + totalOut += result.tokensOut; + language = result.claimSet.text_language; + if (result.claimSet.extraction_notes) notes.push(result.claimSet.extraction_notes); + } + + const beforeDedup = allClaims.length; + const unique = deduplicateClaims(allClaims).slice(0, maxClaims); + const renumbered: Claim[] = unique.map((c, i) => ({ + ...c, + claim_id: `c${String(i + 1).padStart(3, "0")}`, + })); + + log.info("Chunking abgeschlossen", { + totalBeforeDedup: beforeDedup, + afterDedup: renumbered.length, + totalTokensIn: totalIn, + totalTokensOut: totalOut, + totalLatencyMs: Date.now() - t0, + }); + + return { + claimSet: { + schema_version: "1.0.0", + text_language: language, + extraction_notes: `Text in ${chunks.length} Abschnitte aufgeteilt. ${notes.filter(Boolean).join(" ")}`, + total_claims: renumbered.length, + claims: renumbered, + }, + tokensIn: totalIn, + tokensOut: totalOut, + latencyMs: Date.now() - t0, + }; +} + +async function callLlamaClaimExtractSingle( + text: string, + model: string, + maxClaims: number, + signal?: AbortSignal, + logger?: Logger, + translateTo?: string +): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { + const log = logger ?? nullLogger; + const t0 = Date.now(); + const maxTokens = translateTo ? MAX_TOKENS_WITH_TRANSLATION : MAX_TOKENS_BASE; + + const body = { + model, + messages: [ + { + role: "system", + content: buildSystemPrompt(maxClaims, translateTo), + }, + { + role: "user", + // /no_think deaktiviert den Thinking-Modus bei Qwen3/Qwopus-Reasoning-Modellen + content: `/no_think\nExtrahiere alle Behauptungen aus folgendem Text:\n\n---\n${text}\n---`, + }, + ], + stream: false, + temperature: TEMPERATURE, + max_tokens: maxTokens, + }; + + log.debug("llama.cpp-Aufruf gestartet", { model, textLength: text.length, max_tokens: maxTokens, translateTo }); + + const MAX_RETRIES = 3; + const RETRY_DELAY_MS = 15_000; + let resp: Response | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + break; + } catch (err) { + const isLast = attempt === MAX_RETRIES; + log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, { + error: err instanceof Error ? err.message : String(err), + retryInMs: isLast ? 0 : RETRY_DELAY_MS, + }); + if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`); + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + } + + if (!resp!.ok) { + const errorText = await resp!.text().catch(() => ""); + log.error("llama.cpp API Fehler", { status: resp!.status, body: errorText.slice(0, 200) }); + throw new Error(`llama.cpp API Fehler ${resp!.status}: ${errorText}`); + } + + const data = (await resp!.json()) as LlamaResponse; + const choice = data.choices?.[0]; + let raw = choice?.message?.content ?? ""; + + // Reasoning-Modelle (Qwen3, DeepSeek-R1) schreiben Denkkette in reasoning_content. + // Wenn content leer ist aber reasoning_content JSON enthält: als Fallback verwenden. + if (!raw.trim() && choice?.message?.reasoning_content) { + const rc = choice.message.reasoning_content; + // Letztes vollständiges JSON-Objekt mit "claims"-Array suchen (greedy, von hinten) + const allMatches = [...rc.matchAll(/\{[^{}]*"claims"\s*:\s*\[[\s\S]*?\]\s*[^{}]*\}/g)]; + const lastMatch = allMatches.length > 0 + ? allMatches[allMatches.length - 1][0] + : rc.match(/\{[\s\S]*"claims"[\s\S]*\}/)?.[0]; + if (lastMatch) { + raw = lastMatch; + log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", { + finishReason: choice.finish_reason, + rawLength: raw.length, + }); + } + } + + // llama.cpp wrappt JSON manchmal in Markdown-Codeblöcke (```json ... ```) + const cleanedRaw = raw + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + + log.debug("llama.cpp-Antwort empfangen", { + promptTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens, + finishReason: choice?.finish_reason, + rawLength: raw.length, + cleanedLength: cleanedRaw.length, + }); + + if (!cleanedRaw) { + log.error("Leere llama.cpp-Antwort", { + promptTokens: data.usage?.prompt_tokens, + finishReason: choice?.finish_reason, + hasReasoningContent: !!choice?.message?.reasoning_content, + }); + throw new Error("Leere Antwort von llama.cpp erhalten"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(cleanedRaw); + } catch { + log.error("JSON-Parse-Fehler", { cleanedRawPreview: cleanedRaw.slice(0, 200) }); + throw new Error(`llama.cpp-Ausgabe ist kein gültiges JSON: ${cleanedRaw.slice(0, 200)}`); + } + + const p = parsed as Record; + if (!Array.isArray(p.claims)) { + log.error("Ungültige Struktur: claims fehlt", { keys: Object.keys(p) }); + throw new Error(`Ungültige Struktur: 'claims' fehlt oder ist kein Array`); + } + + if ((p.claims as unknown[]).length === 0) { + const usedCtx = data.usage?.prompt_tokens ?? 0; + log.warn("0 Claims extrahiert", { promptTokens: usedCtx, max_tokens: maxTokens, textLength: text.length }); + throw new Error( + `llama.cpp hat 0 Claims extrahiert (prompt_tokens=${usedCtx}). ` + + `Text zu lang für Kontext-Fenster oder Modell-Fehler.` + ); + } + + const claimSet: ClaimSet = { + schema_version: "1.0.0", + text_language: typeof p.text_language === "string" ? p.text_language : "unknown", + extraction_notes: typeof p.extraction_notes === "string" ? p.extraction_notes : "", + total_claims: typeof p.total_claims === "number" ? p.total_claims : (p.claims as unknown[]).length, + claims: p.claims as Claim[], + }; + + return { + claimSet, + tokensIn: data.usage?.prompt_tokens ?? 0, + tokensOut: data.usage?.completion_tokens ?? 0, + latencyMs: Date.now() - t0, + }; +} + +// --------------------------------------------------------------------------- +// Formatierung (Pi-Ausgabe + CLI-Ausgabe) +// --------------------------------------------------------------------------- + +const TYPE_LABEL: Record = { + fact: "FAKT", + causal: "KAUSAL", + statistical: "STATISTIK", + quote: "ZITAT", + prediction: "PROGNOSE", + opinion: "MEINUNG", +}; + +const CHECK_ICON: Record = { + checkable: "✓", + partly_checkable: "~", + not_checkable: "✗", +}; + +function formatClaimSet( + claimSet: ClaimSet, + onlyCheckable: boolean, + model: string, + tokensIn: number, + tokensOut: number, + latencyMs: number +): string { + const filtered = onlyCheckable + ? claimSet.claims.filter((c) => c.checkability === "checkable") + : claimSet.claims; + + const checkable = filtered.filter((c) => c.checkability === "checkable"); + const partlyCheckable = filtered.filter((c) => c.checkability === "partly_checkable"); + const notCheckable = filtered.filter((c) => c.checkability === "not_checkable"); + + const lines: string[] = []; + + lines.push( + `## Claim-Extraktion: ${claimSet.total_claims} Behauptung${claimSet.total_claims !== 1 ? "en" : ""} gefunden` + + (onlyCheckable && filtered.length < claimSet.total_claims + ? ` (${filtered.length} prüfbar angezeigt)` + : "") + ); + lines.push(`Sprache: ${claimSet.text_language}`); + if (claimSet.extraction_notes) { + lines.push(`Hinweis: ${claimSet.extraction_notes}`); + } + lines.push(""); + + function renderClaims(claims: Claim[], sectionTitle: string) { + if (claims.length === 0) return; + lines.push(`**${sectionTitle} (${claims.length}):**`); + for (const c of claims) { + const icon = CHECK_ICON[c.checkability]; + const type = TYPE_LABEL[c.claim_type]; + lines.push(`\`${c.claim_id}\` ${icon} [${type}] ${c.text}`); + if (c.text_translated) { + lines.push(` → _${c.text_translated}_`); + } + + const meta: string[] = []; + if (c.entities.length > 0) meta.push(`Entitäten: ${c.entities.join(", ")}`); + if (c.time_scope) meta.push(`Zeit: ${c.time_scope}`); + if (c.needs_citation) meta.push(`Zitat nötig: ja`); + if (meta.length > 0) { + lines.push(` ${meta.join(" | ")}`); + } + lines.push(""); + } + } + + renderClaims(checkable, "✓ Prüfbar"); + if (!onlyCheckable) { + renderClaims(partlyCheckable, "~ Teilweise prüfbar"); + renderClaims(notCheckable, "✗ Nicht prüfbar"); + } + + const latSec = (latencyMs / 1000).toFixed(1); + const tokenInfo = tokensIn || tokensOut ? ` · ${tokensIn}+${tokensOut} Tokens` : ""; + lines.push(`_[llama.cpp: ${model}${tokenInfo} · ${latSec}s]_`); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension-Parameters (TypeBox) +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + text: Type.String({ + description: + "Der zu analysierende Text. Kann ein Artikel, Blogeintrag, Nachrichtentext oder beliebiger Fließtext sein.", + }), + onlyCheckable: Type.Optional( + Type.Boolean({ + description: "Wenn true: nur empirisch prüfbare Claims ausgeben (checkable). Standard: false.", + }) + ), + maxClaims: Type.Optional( + Type.Number({ + description: `Maximale Anzahl Claims pro Aufruf. Standard: ${DEFAULT_MAX_CLAIMS}.`, + }) + ), + model: Type.Optional( + Type.String({ + description: `llama.cpp-Modell für die Extraktion. Standard: ${DEFAULT_MODEL}.`, + }) + ), + translateTo: Type.Optional( + Type.String({ + description: + "Zielsprache für optionale Übersetzung der Claims (z.B. \"de\", \"en\"). " + + "Das Feld `text` bleibt immer in der Originalsprache des Artikels. " + + "Wenn gesetzt: jeder Claim erhält zusätzlich `text_translated`. Standard: keine Übersetzung.", + }) + ), +}); + +// --------------------------------------------------------------------------- +// Pi-Extension: Default Export +// --------------------------------------------------------------------------- + +export default function llamaClaimExtractorExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "extract_claims_llama", + label: "Claim-Extraktion (llama.cpp)", + description: + "Zerlegt einen Text in einzelne, diskrete Behauptungen (Claims) als Vorbereitung für Fact-Checking. " + + "Nutze dieses Tool wenn: ein Artikel auf Fakten geprüft werden soll, Behauptungen aus einem Text " + + "identifiziert und klassifiziert werden sollen, oder ein Verifikations-Workflow gestartet werden soll. " + + "Läuft lokal via llama.cpp — keine API-Kosten.", + promptGuidelines: [ + "Use extract_claims_llama when the user wants to fact-check an article, blog post, or any text.", + "Use extract_claims_llama before calling verify or research_web on specific claims.", + "Pass the full text as the 'text' parameter — do not summarize or shorten it first.", + "If the user only wants checkable claims, set onlyCheckable=true.", + "After extraction, ask the user which claims they want to verify, or offer to run the verifier on all checkable claims.", + "The claim_ids (c001, c002, ...) can be referenced in follow-up tool calls to the verifier.", + "Always show the full formatted output to the user, including the [llama.cpp: ...] cost line.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + const model = params.model ?? DEFAULT_MODEL; + const maxClaims = Math.min(params.maxClaims ?? DEFAULT_MAX_CLAIMS, 60); + const onlyCheckable = params.onlyCheckable ?? false; + const translateTo = params.translateTo; + + try { + const { claimSet, tokensIn, tokensOut, latencyMs } = await callLlamaClaimExtract( + params.text, + model, + maxClaims, + signal, + undefined, + translateTo + ); + + const text = formatClaimSet(claimSet, onlyCheckable, model, tokensIn, tokensOut, latencyMs); + + return { + content: [{ type: "text", text }], + details: { + model, + totalClaims: claimSet.total_claims, + checkableClaims: claimSet.claims.filter((c) => c.checkability === "checkable").length, + textLanguage: claimSet.text_language, + tokensIn: tokensIn || null, + tokensOut: tokensOut || null, + latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { + content: [{ type: "text", text: `Fehler bei Claim-Extraktion: ${msg}` }], + }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI-Modus +// --------------------------------------------------------------------------- + +function parseCliArgs(args: string[]): { + text: string; + file: string | null; + model: string; + maxClaims: number; + onlyCheckable: boolean; + jsonOutput: boolean; + verbose: boolean; + translateTo: string | undefined; +} { + let model = DEFAULT_MODEL; + let maxClaims = DEFAULT_MAX_CLAIMS; + let onlyCheckable = false; + let jsonOutput = false; + let verbose = false; + let file: string | null = null; + let translateTo: string | undefined; + const textParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--model" && args[i + 1]) { + model = args[++i]; + } else if (arg === "--max-claims" && args[i + 1]) { + maxClaims = parseInt(args[++i], 10); + } else if (arg === "--only-checkable") { + onlyCheckable = true; + } else if (arg === "--json") { + jsonOutput = true; + } else if (arg === "--verbose" || arg === "-v") { + verbose = true; + } else if ((arg === "--file" || arg === "-f") && args[i + 1]) { + file = args[++i]; + } else if (arg === "--translate-to" && args[i + 1]) { + translateTo = args[++i]; + } else if (!arg.startsWith("--")) { + textParts.push(arg); + } + } + + const text = textParts.join(" ").trim(); + return { text, file, model, maxClaims, onlyCheckable, jsonOutput, verbose, translateTo }; +} + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + console.log(` +Claim-Extraktor — Behauptungen aus Text extrahieren (llama.cpp-Version) + +Verwendung: + npx tsx agenten/llama-claim-extractor.ts [Optionen] "Text..." + npx tsx agenten/llama-claim-extractor.ts --file [Optionen] + +Optionen: + --file, -f Text aus Datei lesen (statt als Argument übergeben) + --model llama.cpp-Modell (Standard: ${DEFAULT_MODEL}) + --max-claims Maximale Claims (Standard: ${DEFAULT_MAX_CLAIMS}) + --only-checkable Nur prüfbare Claims anzeigen + --translate-to Übersetzung der Claims in Zielsprache (z.B. "de", "en") + text bleibt in Originalsprache — text_translated enthält Übersetzung + --json Ausgabe als reines JSON (ClaimSet) + --verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/ + --help Diese Hilfe + +Umgebungsvariablen: + LLAMA_HOST llama.cpp-Server-URL (Standard: http://localhost:8000) + +Beispiele: + npx tsx agenten/llama-claim-extractor.ts "Die Erde hat 8 Milliarden Einwohner." + npx tsx agenten/llama-claim-extractor.ts --file Totally_unacceptable_article.txt + npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --only-checkable + npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --json > claims.json + npx tsx agenten/llama-claim-extractor.ts --file artikel.txt --verbose +`); + process.exit(0); + } + + const { text: argText, file, model, maxClaims, onlyCheckable, jsonOutput, verbose, translateTo } = parseCliArgs(args); + + let text: string; + if (file) { + try { + text = await readFile(file, "utf-8"); + } catch (err) { + console.error(`Fehler: Datei '${file}' konnte nicht gelesen werden: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } else { + text = argText; + } + + if (!text.trim()) { + console.error("Fehler: Kein Text übergeben. Nutze --file oder übergib den Text direkt. --help für Details."); + process.exit(1); + } + + if (!jsonOutput) { + const source = file ? `Datei: ${file}` : "Direkteingabe"; + const transInfo = translateTo ? ` | Übersetzung: ${translateTo}` : ""; + console.error(`\nllama.cpp-Modell: ${model} | Max. Claims: ${maxClaims} | Nur prüfbar: ${onlyCheckable} | ${source}${transInfo}\n`); + } + + const log = createLogger({ verbose }); + + try { + const { claimSet, tokensIn, tokensOut, latencyMs } = await callLlamaClaimExtract( + text, + model, + maxClaims, + undefined, + log, + translateTo + ); + + if (jsonOutput) { + console.log(JSON.stringify(claimSet, null, 2)); + } else { + console.log(formatClaimSet(claimSet, onlyCheckable, model, tokensIn, tokensOut, latencyMs)); + } + } catch (err) { + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +// Einstiegspunkt für CLI — wird ignoriert wenn als Pi-Extension geladen +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { + runCli(); +} diff --git a/agenten/llama-logic-editor.ts b/agenten/llama-logic-editor.ts new file mode 100644 index 0000000..3809033 --- /dev/null +++ b/agenten/llama-logic-editor.ts @@ -0,0 +1,522 @@ +/** + * llama-logic-editor.ts + * Pi-Extension + CLI: Argumentationsanalyse via llama.cpp (Qwopus3.6) + * + * Analysiert einen Text auf: + * - Hauptthese und Unterthesen + * - Explizite Prämissen und Belege + * - Schlussfolgerungen + * - Implizite Annahmen + * - Logische Fehlschlüsse (Ad Hominem, Strohmann, etc.) + * - Verbesserungsvorschläge + * + * Kein Ollama-format-Parameter — Schema steht als JSON-Literal im System-Prompt. + * /no_think deaktiviert den Thinking-Modus bei Qwen3/Qwopus-Reasoning-Modellen. + * + * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink) + * Als CLI: + * npx tsx agenten/llama-logic-editor.ts "Artikeltext..." + * npx tsx agenten/llama-logic-editor.ts --only-fallacies "$(cat kommentar.txt)" + * npx tsx agenten/llama-logic-editor.ts --json "$(cat essay.txt)" + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +type FallacyType = + | "ad_hominem" | "straw_man" | "false_dichotomy" | "slippery_slope" + | "circular_reasoning" | "appeal_to_authority" | "hasty_generalization" + | "false_causation" | "appeal_to_emotion" | "overgeneralization" + | "cherry_picking" | "other"; + +type Severity = "minor" | "moderate" | "critical"; +type EvidenceStrength = "strong" | "moderate" | "weak"; +type OverallQuality = "strong" | "adequate" | "weak" | "flawed"; + +type ArgumentMap = { + schema_version: "1.0.0"; + thesis: string; + sub_theses: string[]; + premises: string[]; + evidence: Array<{ claim: string; supports_thesis: boolean; strength: EvidenceStrength }>; + conclusions: string[]; + implicit_assumptions: string[]; + fallacies: Array<{ + type: FallacyType; + description: string; + location: string; + severity: Severity; + }>; + revision_suggestions: string[]; + overall_quality: OverallQuality; + quality_notes: string; +}; + +// llama.cpp OpenAI-kompatibles API-Format +type LlamaResponse = { + choices: Array<{ + message?: { content?: string; reasoning_content?: string }; + finish_reason?: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + }; +}; + +export type AnalysisResult = { + map: ArgumentMap; + provider: "llama"; + model: string; + costUSD: 0; + latencyMs: number; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf"; +const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000"; +const MAX_TOKENS = 16384; +const TEMPERATURE = 0.1; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 15_000; + +// --------------------------------------------------------------------------- +// System-Prompt mit eingebettetem JSON-Schema +// --------------------------------------------------------------------------- + +const ANALYSIS_SYSTEM_PROMPT = `Du bist ein Experte für kritisches Denken, Rhetorik und formale Logik. +Antworte ausschließlich auf Deutsch. +Analysiere den folgenden Text auf seine Argumentationsstruktur. + +Extrahiere: +1. thesis: Die zentrale Hauptbehauptung als vollständiger Satz +2. sub_theses: Untergeordnete Thesen die die Hauptthese stützen +3. premises: Ausdrücklich genannte Voraussetzungen und Grundannahmen +4. evidence: Verwendete Belege (Fakten, Statistiken, Zitate, Studien) — beachte ob sie die These wirklich stützen +5. conclusions: Explizite Schlussfolgerungen die aus den Prämissen gezogen werden +6. implicit_assumptions: Nicht ausgesprochene Annahmen die das Argument voraussetzt + +Fehlschluss-Typen (für das "type"-Feld): +- ad_hominem: Person statt Argument angegriffen +- straw_man: Gegnerposition verzerrt dargestellt +- false_dichotomy: Falsche Zweiteilung (nur A oder B, obwohl mehr möglich) +- slippery_slope: Kettenreaktion ohne Beleg +- circular_reasoning: These wird durch sich selbst begründet +- appeal_to_authority: Autorität als einziger Beleg +- hasty_generalization: Einzelfall → Allgemeinregel +- false_causation: Korrelation als Kausalität dargestellt +- appeal_to_emotion: Emotionen statt Argumente +- overgeneralization: Zu weit gefasste Verallgemeinerung +- cherry_picking: Nur passende Fakten ausgewählt +- other: Sonstiger Fehlschluss + +overall_quality-Werte: "strong" | "adequate" | "weak" | "flawed" +severity-Werte: "minor" | "moderate" | "critical" +strength-Werte: "strong" | "moderate" | "weak" + +Antworte AUSSCHLIESSLICH mit einem JSON-Objekt gemäß folgendem Schema: +{ + "thesis": "string", + "sub_theses": ["string"], + "premises": ["string"], + "evidence": [{"claim": "string", "supports_thesis": true, "strength": "strong|moderate|weak"}], + "conclusions": ["string"], + "implicit_assumptions": ["string"], + "fallacies": [{"type": "ad_hominem|...", "description": "string", "location": "wörtliches Zitat max. 120 Zeichen", "severity": "minor|moderate|critical"}], + "revision_suggestions": ["string"], + "overall_quality": "strong|adequate|weak|flawed", + "quality_notes": "string" +} + +Kein Freitext vor oder nach dem JSON-Objekt.`; + +// --------------------------------------------------------------------------- +// Prompt-Generierung +// --------------------------------------------------------------------------- + +function buildUserPrompt(text: string): string { + return `/no_think\nAnalysiere die Argumentationsstruktur:\n\n---\n${text}\n---`; +} + +// --------------------------------------------------------------------------- +// llama.cpp-Aufruf +// --------------------------------------------------------------------------- + +async function analyzeWithLlama( + text: string, + model: string, + signal?: AbortSignal, + logger?: Logger +): Promise<{ map: ArgumentMap; tokensIn: number; tokensOut: number; latencyMs: number }> { + const log = logger ?? nullLogger; + const t0 = Date.now(); + + const body = { + model, + messages: [ + { role: "system", content: ANALYSIS_SYSTEM_PROMPT }, + { role: "user", content: buildUserPrompt(text) }, + ], + stream: false, + temperature: TEMPERATURE, + max_tokens: MAX_TOKENS, + }; + + log.debug("llama.cpp-LogicEditor gestartet", { model, textLength: text.length }); + + let resp: Response | null = null; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + break; + } catch (err) { + const isLast = attempt === MAX_RETRIES; + log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, { + error: err instanceof Error ? err.message : String(err), + retryInMs: isLast ? 0 : RETRY_DELAY_MS, + }); + if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`); + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + } + + if (!resp!.ok) { + const errorText = await resp!.text().catch(() => ""); + throw new Error(`llama.cpp API Fehler ${resp!.status}: ${errorText}`); + } + + const data = (await resp!.json()) as LlamaResponse; + const choice = data.choices?.[0]; + let raw = choice?.message?.content ?? ""; + + // Reasoning-Fallback: Wenn content leer, JSON aus reasoning_content extrahieren + if (!raw.trim() && choice?.message?.reasoning_content) { + const rc = choice.message.reasoning_content; + const lastBlock = rc.match(/\{[\s\S]*"thesis"[\s\S]*\}/)?.[0]; + if (lastBlock) { + raw = lastBlock; + log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", { + finishReason: choice.finish_reason, + rawLength: raw.length, + }); + } + } + + // Markdown-Codeblöcke entfernen + const cleanedRaw = raw + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + + log.debug("llama.cpp-LogicEditor Antwort", { + promptTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens, + finishReason: choice?.finish_reason, + rawLength: cleanedRaw.length, + }); + + if (!cleanedRaw) throw new Error("Leere Antwort von llama.cpp-LogicEditor"); + + let parsed: unknown; + try { + parsed = JSON.parse(cleanedRaw); + } catch { + throw new Error(`llama.cpp-LogicEditor-Ausgabe ist kein gültiges JSON: ${cleanedRaw.slice(0, 200)}`); + } + + const p = parsed as Record; + if (typeof p.thesis !== "string") { + throw new Error(`Ungültige Struktur: 'thesis' fehlt. Keys: ${Object.keys(p).join(", ")}`); + } + + const map: ArgumentMap = { schema_version: "1.0.0", ...(p as Omit) }; + return { + map, + tokensIn: data.usage?.prompt_tokens ?? 0, + tokensOut: data.usage?.completion_tokens ?? 0, + latencyMs: Date.now() - t0, + }; +} + +// --------------------------------------------------------------------------- +// Hauptfunktion +// --------------------------------------------------------------------------- + +export async function analyzeLogic( + text: string, + options?: { + model?: string; + signal?: AbortSignal; + logger?: Logger; + } +): Promise { + const model = options?.model ?? DEFAULT_MODEL; + const { map, latencyMs } = await analyzeWithLlama(text, model, options?.signal, options?.logger); + return { map, provider: "llama", model, costUSD: 0, latencyMs }; +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +const QUALITY_LABEL: Record = { + strong: "STARK", + adequate: "AUSREICHEND", + weak: "SCHWACH", + flawed: "FEHLERHAFT", +}; + +const QUALITY_ICON: Record = { + strong: "✓", + adequate: "~", + weak: "⚠", + flawed: "✗", +}; + +const FALLACY_LABEL: Record = { + ad_hominem: "Ad Hominem", + straw_man: "Strohmann", + false_dichotomy: "Falsche Dichotomie", + slippery_slope: "Schiefe Ebene", + circular_reasoning: "Zirkelschluss", + appeal_to_authority: "Autoritätsargument", + hasty_generalization: "Vorschnelle Generalisierung", + false_causation: "Falsche Kausalität", + appeal_to_emotion: "Appell an Emotionen", + overgeneralization: "Überverallgemeinerung", + cherry_picking: "Rosinenpickerei", + other: "Sonstiger Fehlschluss", +}; + +const SEVERITY_ICON: Record = { + minor: "·", + moderate: "⚠", + critical: "✗", +}; + +export function formatAnalysis(result: AnalysisResult, onlyFallacies = false): string { + const { map } = result; + const latSec = (result.latencyMs / 1000).toFixed(1); + const footer = `_[llama.cpp: ${result.model} · kostenlos (lokal) · ${latSec}s]_`; + + if (onlyFallacies) { + if (map.fallacies.length === 0) return `Keine Fehlschlüsse erkannt.\n\n${footer}`; + const lines: string[] = [`## Fehlschlüsse (${map.fallacies.length})\n`]; + map.fallacies.forEach((f) => { + lines.push(`${SEVERITY_ICON[f.severity]} **${FALLACY_LABEL[f.type]}** (${f.severity})`); + lines.push(` ${f.description}`); + lines.push(` _"${f.location}"_\n`); + }); + lines.push(footer); + return lines.join("\n"); + } + + const q = map.overall_quality; + const lines: string[] = []; + + lines.push(`## Argumentationsanalyse`); + lines.push(`**Gesamtqualität: ${QUALITY_ICON[q]} ${QUALITY_LABEL[q]}**`); + lines.push(map.quality_notes); + lines.push(""); + + lines.push(`**Hauptthese:**`); + lines.push(`> ${map.thesis}`); + lines.push(""); + + if (map.sub_theses.length > 0) { + lines.push(`**Unterthesen (${map.sub_theses.length}):**`); + map.sub_theses.forEach((t) => lines.push(`- ${t}`)); + lines.push(""); + } + + if (map.premises.length > 0) { + lines.push(`**Prämissen:**`); + map.premises.forEach((p) => lines.push(`- ${p}`)); + lines.push(""); + } + + if (map.evidence.length > 0) { + lines.push(`**Belege (${map.evidence.length}):**`); + map.evidence.forEach((e) => { + const icon = e.supports_thesis ? "✓" : "✗"; + const str = e.strength === "strong" ? "stark" : e.strength === "moderate" ? "mittel" : "schwach"; + lines.push(`${icon} [${str}] ${e.claim}`); + }); + lines.push(""); + } + + if (map.conclusions.length > 0) { + lines.push(`**Schlussfolgerungen:**`); + map.conclusions.forEach((c) => lines.push(`- ${c}`)); + lines.push(""); + } + + if (map.implicit_assumptions.length > 0) { + lines.push(`**Implizite Annahmen (${map.implicit_assumptions.length}):**`); + map.implicit_assumptions.forEach((a) => lines.push(`- _${a}_`)); + lines.push(""); + } + + if (map.fallacies.length > 0) { + lines.push(`**Fehlschlüsse (${map.fallacies.length}):**`); + map.fallacies.forEach((f) => { + lines.push(`${SEVERITY_ICON[f.severity]} **${FALLACY_LABEL[f.type]}** (${f.severity})`); + lines.push(` ${f.description}`); + lines.push(` _"${f.location}"_`); + lines.push(""); + }); + } else { + lines.push(`_Keine Fehlschlüsse erkannt._`); + lines.push(""); + } + + if (map.revision_suggestions.length > 0) { + lines.push(`**Verbesserungsvorschläge:**`); + map.revision_suggestions.forEach((s, i) => lines.push(`${i + 1}. ${s}`)); + lines.push(""); + } + + lines.push(footer); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + text: Type.String({ + description: + "Der zu analysierende Text: Artikel, Blogpost, Kommentar, Essay oder Nachrichtentext. " + + "Der Text wird auf logische Struktur, Fehlschlüsse und Argumentationsqualität geprüft.", + }), + model: Type.Optional( + Type.String({ description: "llama.cpp-Modell-Override." }) + ), +}); + +export default function llamaLogicEditorExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "analyze_logic_llama", + label: "Argumentationsanalyse (llama.cpp)", + description: + "Analysiert die logische Struktur eines Texts: Hauptthese, Prämissen, Belege, " + + "Schlussfolgerungen, implizite Annahmen und logische Fehlschlüsse. " + + "Gibt konkrete Verbesserungsvorschläge und eine Qualitätsbewertung. " + + "Verwendet llama.cpp lokal (kostenlos). BEVORZUGT gegenüber analyze_logic.", + promptGuidelines: [ + "PREFERRED: Use analyze_logic_llama for all argument analysis (local, free, unified backend).", + "Use analyze_logic (Ollama/deepseek-r1) only when explicitly requested by the user.", + "Use analyze_logic_llama when the user wants to check argumentation quality of an article, comment, or essay.", + "Use after verify_article_llama to get both factual AND logical quality assessment.", + "Always show the full formatted output including fallacies and revision suggestions.", + "If fallacies with severity 'critical' are found, highlight them prominently.", + "The revision_suggestions are actionable — offer to rewrite specific sections if the user wants.", + "Combine with verify_article_llama for a complete quality assessment: facts + logic.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + try { + const result = await analyzeLogic(params.text, { + model: params.model, + signal, + }); + return { + content: [{ type: "text", text: formatAnalysis(result) }], + details: { + overallQuality: result.map.overall_quality, + fallacyCount: result.map.fallacies.length, + criticalFallacies: result.map.fallacies.filter((f) => f.severity === "critical").length, + provider: result.provider, + model: result.model, + latencyMs: result.latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Argumentationsanalyse fehlgeschlagen: ${msg}` }] }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help") { + console.log(` +Argumentationsanalyse via llama.cpp — Logik, Fehlschlüsse und Verbesserungsvorschläge + +Verwendung: + npx tsx agenten/llama-logic-editor.ts [Optionen] "Text..." + npx tsx agenten/llama-logic-editor.ts "$(cat artikel.txt)" + +Optionen: + --only-fallacies Nur Fehlschlüsse ausgeben (kein vollständiger Bericht) + --model Modell-Override (Standard: ${DEFAULT_MODEL}) + --json Ausgabe als JSON + --verbose Ausführliches Logging + --help Diese Hilfe +`); + process.exit(0); + } + + let model: string | undefined; + let jsonOutput = false; + let onlyFallacies = false; + let verbose = false; + const textParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--model" && args[i + 1]) model = args[++i]; + else if (arg === "--json") jsonOutput = true; + else if (arg === "--only-fallacies") onlyFallacies = true; + else if (arg === "--verbose") verbose = true; + else if (!arg.startsWith("--")) textParts.push(arg); + } + + const text = textParts.join(" ").trim(); + if (!text) { console.error("Fehler: Kein Text."); process.exit(1); } + + const logger = verbose ? createLogger({ verbose: true }) : nullLogger; + if (!jsonOutput) console.error(`\nAnalyse via llama.cpp...\n`); + + try { + const result = await analyzeLogic(text, { model, logger }); + + if (onlyFallacies) { + if (jsonOutput) { + console.log(JSON.stringify(result.map.fallacies, null, 2)); + } else { + console.log(formatAnalysis(result, true)); + } + } else { + console.log(jsonOutput ? JSON.stringify(result.map, null, 2) : formatAnalysis(result)); + } + } catch (err) { + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) runCli(); diff --git a/agenten/llama-verifier.ts b/agenten/llama-verifier.ts new file mode 100644 index 0000000..e806da7 --- /dev/null +++ b/agenten/llama-verifier.ts @@ -0,0 +1,552 @@ +/** + * llama-verifier.ts + * Pi-Extension + CLI: Eine einzelne Behauptung via Perplexity + llama.cpp verifizieren. + * + * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/llama-verifier.ts + * Nach Änderungen in Pi: /reload + * + * Als CLI: + * npx tsx agenten/llama-verifier.ts "Die Inflationsrate betrug 2024 in Deutschland 3,2%." + * npx tsx agenten/llama-verifier.ts --mode deep "Die Erde ist 4,6 Milliarden Jahre alt." + * npx tsx agenten/llama-verifier.ts --user-language en "Trump called Iran's response 'totally unacceptable'." + * npx tsx agenten/llama-verifier.ts --json "..." (gibt VerificationResult als JSON aus) + * + * Ablauf: Perplexity-Suche (Originalsprache) → llama.cpp-Urteil → formatierte Ausgabe + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { searchPerplexity, formatSourcesForPrompt, type PerplexitySource } from "../lib/perplexity.js"; +import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +type VerificationStatus = + | "supported" + | "contradicted" + | "mixed" + | "insufficient_evidence" + | "needs_human_review"; + +type Confidence = "high" | "medium" | "low"; + +type VerdictRaw = { + status: VerificationStatus; + confidence: Confidence; + summary: string; + counter_evidence: string | null; + notes: string | null; + supporting_urls: string[]; +}; + +export type VerificationResult = { + claim: string; + status: VerificationStatus; + confidence: Confidence; + summary: string; + counter_evidence: string | null; + notes: string | null; + sources: PerplexitySource[]; + supporting_urls: string[]; + perplexityCostUSD: number; + latencyMs: number; + model: string; +}; + +// llama.cpp OpenAI-kompatibles API-Format +type LlamaResponse = { + choices: Array<{ + message?: { content?: string; reasoning_content?: string }; + finish_reason?: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf"; +const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000"; +const DEFAULT_USER_LANGUAGE = "de"; +const MAX_TOKENS = 16384; +const TEMPERATURE = 0.1; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 15_000; + +// --------------------------------------------------------------------------- +// Verdict-Synthese via llama.cpp +// --------------------------------------------------------------------------- + +function langLabel(userLanguage: string): string { + if (userLanguage === "de") return "Deutsch"; + if (userLanguage === "en") return "Englisch"; + if (userLanguage === "fr") return "Französisch"; + if (userLanguage === "es") return "Spanisch"; + return userLanguage; +} + +function buildVerdictSystemPrompt(userLanguage: string): string { + return `Du bist ein erfahrener Fact-Checker. Bewerte eine Behauptung anhand bereitgestellter Webquellen. + +Bewertungsskala: +- supported: Quellen bestätigen die Behauptung klar und konsistent +- contradicted: Quellen widersprechen der Behauptung klar und substanziell +- mixed: Quellen liefern widersprüchliche Belege ODER die Behauptung ist technisch ungenau aber im Kern korrekt +- insufficient_evidence: Zu wenig oder qualitativ unzureichende Quellen für ein Urteil +- needs_human_review: Komplex, politisch heikel, veraltete Quellen, oder stark kontextabhängig + +Confidence: +- high: Quellenlage ist eindeutig und aus Primärquellen +- medium: Quellen vorhanden aber begrenzt oder sekundär +- low: Quellen sehr rar, veraltet oder widersprüchlich + +WICHTIGE REGELN für "contradicted": +- Nur bei klaren, substanziellen Fehlern verwenden: falsche Person, falsch zugeordnetes Ereignis, Zahl um mehr als 10% abweichend, grundlegend falsche Kausalität +- Gerundete oder allgemein akzeptierte Näherungswerte sind "supported" (z.B. "21 Millionen Bitcoin" ist korrekte Rundung für 20.999.999,97 BTC) +- Zeitzonendifferenzen bei historischen Ereignissen: "supported" wenn die Angabe im üblichen regionalen/kulturellen Kontext korrekt ist +- Technische Präzisierungen zu im Wesentlichen korrekten Aussagen → "mixed", nicht "contradicted" +- Im Zweifel: "mixed" statt "contradicted" + +AUSGABESPRACHE: Schreibe summary, counter_evidence und notes auf ${langLabel(userLanguage)}. +Die Enum-Werte status und confidence bleiben englisch (wie im Schema definiert). + +summary: 1-3 präzise Sätze basierend auf den Quellen. Nicht spekulieren. +counter_evidence: Gegenbelege als Satz beschreiben, falls vorhanden. Sonst null. +notes: Zeitabhängigkeit, regionale Einschränkungen, Vorbehalt. Sonst null. +supporting_urls: URLs aus den Quellen die den Claim stützen (leeres Array wenn keine). + +Antworte NUR mit diesem JSON-Objekt — kein Freitext davor oder danach: +{ + "status": "supported|contradicted|mixed|insufficient_evidence|needs_human_review", + "confidence": "high|medium|low", + "summary": "...", + "counter_evidence": "..." | null, + "notes": "..." | null, + "supporting_urls": ["url1", "url2"] +}`; +} + +function buildVerdictUserPrompt(claim: string, perplexitySummary: string, sources: PerplexitySource[], context?: string): string { + const contextBlock = context ? `\nARTIKEL-KONTEXT: "${context.slice(0, 300)}"\n` : ""; + return `/no_think +ZU PRÜFENDE BEHAUPTUNG: +"${claim}" +${contextBlock} +RECHERCHE-ERGEBNIS (Perplexity): +${perplexitySummary} + +QUELLEN: +${formatSourcesForPrompt(sources, 300)} + +Bewerte die Behauptung anhand der Recherche.`; +} + +async function synthesizeVerdict( + claim: string, + perplexitySummary: string, + sources: PerplexitySource[], + model: string, + context?: string, + userLanguage?: string, + signal?: AbortSignal, + logger?: Logger +): Promise { + const log = logger ?? nullLogger; + const lang = userLanguage ?? DEFAULT_USER_LANGUAGE; + + const body = { + model, + messages: [ + { role: "system", content: buildVerdictSystemPrompt(lang) }, + { role: "user", content: buildVerdictUserPrompt(claim, perplexitySummary, sources, context) }, + ], + stream: false, + temperature: TEMPERATURE, + max_tokens: MAX_TOKENS, + }; + + let resp: Response | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + break; + } catch (err) { + const isLast = attempt === MAX_RETRIES; + log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, { + error: err instanceof Error ? err.message : String(err), + retryInMs: isLast ? 0 : RETRY_DELAY_MS, + }); + if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`); + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + } + + if (!resp!.ok) { + const errText = await resp!.text().catch(() => ""); + throw new Error(`llama.cpp Fehler ${resp!.status}: ${errText}`); + } + + const data = (await resp!.json()) as LlamaResponse; + const choice = data.choices?.[0]; + let raw = choice?.message?.content ?? ""; + + // Reasoning-Modelle (Qwen3, DeepSeek-R1) schreiben Denkkette in reasoning_content. + // Wenn content leer ist aber reasoning_content JSON enthält: als Fallback verwenden. + if (!raw.trim() && choice?.message?.reasoning_content) { + const rc = choice.message.reasoning_content; + const allMatches = [...rc.matchAll(/\{[^{}]*"status"\s*:/g)]; + const lastIdx = allMatches.length > 0 + ? rc.lastIndexOf(allMatches[allMatches.length - 1][0]) + : -1; + const extracted = lastIdx >= 0 + ? rc.slice(lastIdx).match(/\{[\s\S]*\}/)?.[0] + : rc.match(/\{[\s\S]*"status"[\s\S]*\}/)?.[0]; + if (extracted) { + raw = extracted; + log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", { + finishReason: choice.finish_reason, + rawLength: raw.length, + }); + } + } + + const cleanedRaw = raw + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + + log.debug("llama.cpp-Antwort empfangen", { + promptTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens, + finishReason: choice?.finish_reason, + rawLength: raw.length, + }); + + if (!cleanedRaw) { + throw new Error("Leere llama.cpp-Antwort für Verdict"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(cleanedRaw); + } catch { + throw new Error(`Kein gültiges JSON von llama.cpp: ${cleanedRaw.slice(0, 200)}`); + } + + return parsed as VerdictRaw; +} + +// --------------------------------------------------------------------------- +// Hauptfunktion +// --------------------------------------------------------------------------- + +export async function verifyClaim( + claim: string, + options?: { + context?: string; + mode?: "fast" | "deep"; + model?: string; + userLanguage?: string; + signal?: AbortSignal; + logger?: Logger; + } +): Promise { + const t0 = Date.now(); + const model = options?.model ?? DEFAULT_MODEL; + const log = options?.logger ?? nullLogger; + + log.info("Perplexity-Suche gestartet", { claim: claim.slice(0, 80), mode: options?.mode ?? "fast" }); + const perplexityResult = await searchPerplexity(claim, { + mode: options?.mode ?? "fast", + signal: options?.signal, + }); + log.info("Perplexity abgeschlossen", { + sources: perplexityResult.sources.length, + costUSD: perplexityResult.estimatedCostUSD.toFixed(4), + }); + + log.info("llama.cpp-Urteil generieren...", { model, userLanguage: options?.userLanguage ?? DEFAULT_USER_LANGUAGE }); + const verdict = await synthesizeVerdict( + claim, + perplexityResult.summary, + perplexityResult.sources, + model, + options?.context, + options?.userLanguage, + options?.signal, + log + ); + log.info("Urteil erhalten", { status: verdict.status, confidence: verdict.confidence }); + + return { + claim, + status: verdict.status, + confidence: verdict.confidence, + summary: verdict.summary, + counter_evidence: verdict.counter_evidence, + notes: verdict.notes, + sources: perplexityResult.sources, + supporting_urls: verdict.supporting_urls, + perplexityCostUSD: perplexityResult.estimatedCostUSD, + latencyMs: Date.now() - t0, + model, + }; +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +const STATUS_ICON: Record = { + supported: "✓ BESTÄTIGT", + contradicted: "✗ WIDERLEGT", + mixed: "~ GEMISCHT", + insufficient_evidence: "? BELEGE UNZUREICHEND", + needs_human_review: "⚠ MENSCHLICHE PRÜFUNG NÖTIG", +}; + +const CONF_LABEL: Record = { + high: "hoch", + medium: "mittel", + low: "niedrig", +}; + +export function formatVerificationResult(result: VerificationResult): string { + const lines: string[] = []; + + lines.push(`## Verifikation`); + lines.push(`**Behauptung:** "${result.claim}"`); + lines.push(""); + lines.push(`**${STATUS_ICON[result.status]}** (Konfidenz: ${CONF_LABEL[result.confidence]})`); + lines.push(""); + lines.push(`**Begründung:** ${result.summary}`); + + if (result.counter_evidence) { + lines.push(`\n**Gegenbelege:** ${result.counter_evidence}`); + } + if (result.notes) { + lines.push(`\n**Hinweise:** ${result.notes}`); + } + + if (result.sources.length > 0) { + lines.push("\n**Quellen:**"); + result.sources.forEach((s, i) => { + const supporting = result.supporting_urls.includes(s.url) ? " ✓" : ""; + const title = s.title ?? s.url; + lines.push(`[${i + 1}]${supporting} [${title}](${s.url})`); + }); + } else { + lines.push("\n_(Keine Quellen gefunden)_"); + } + + const latSec = (result.latencyMs / 1000).toFixed(1); + lines.push(`\n_[Perplexity: ~$${result.perplexityCostUSD.toFixed(4)} | llama.cpp: ${result.model} | Gesamt: ${latSec}s]_`); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension: Default Export +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + claim: Type.String({ + description: + "Die zu verifizierende Behauptung als vollständiger, selbstständiger Satz. " + + "Idealerweise das Ergebnis von extract_claims_llama (claim_id + text). " + + "Übergib den Claim immer in seiner Originalsprache.", + }), + context: Type.Optional( + Type.String({ + description: + "Optionaler Kontext: kurzer Auszug aus dem Artikel, in dem die Behauptung steht. " + + "Hilft dem Fact-Checker bei mehrdeutigen Claims. Max. 300 Zeichen.", + }) + ), + mode: Type.Optional( + Type.Union([Type.Literal("fast"), Type.Literal("deep")], { + description: + "fast (Standard): sonar, für die meisten Behauptungen ausreichend. " + + "deep: sonar-pro, für komplexe, strittige oder heikle Behauptungen.", + }) + ), + model: Type.Optional( + Type.String({ + description: `llama.cpp-Modell für die Urteilssynthese. Standard: ${DEFAULT_MODEL}.`, + }) + ), + userLanguage: Type.Optional( + Type.String({ + description: + "Sprache für summary, counter_evidence und notes im Urteil (z.B. \"de\", \"en\", \"fr\"). " + + `Standard: ${DEFAULT_USER_LANGUAGE}. Die Enum-Felder status/confidence bleiben englisch.`, + }) + ), +}); + +export default function llamaVerifierExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "verify_claim_llama", + label: "Claim-Verifikation (llama.cpp)", + description: + "Verifiziert eine einzelne Behauptung: Perplexity-Recherche (Originalsprache) → llama.cpp-Urteil. " + + "Gibt Status (supported/contradicted/mixed/insufficient_evidence/needs_human_review), " + + "Konfidenz, Begründung und Quellen zurück. " + + "Nutze dieses Tool nach extract_claims_llama um spezifische Claims zu prüfen. " + + "Kosten: ~$0.005-0.015 pro Claim (Perplexity) + lokal (llama.cpp).", + promptGuidelines: [ + "This is the PREFERRED claim verification tool. Use verify_claim_llama by default whenever the user wants a claim checked.", + "Use verify_claim_llama after extract_claims or extract_claims_llama to check specific claims the user wants verified.", + "Pass the full claim text as the 'claim' parameter — always in the original language of the article.", + "Use mode=deep for complex, politically sensitive, or scientifically contested claims.", + "The 'context' parameter helps when the claim is ambiguous without its original article context.", + "Set userLanguage to match the user's preferred language (e.g. 'de' for German, 'en' for English). Default is 'de'.", + "Show the full formatted output including the cost/latency line.", + "If status is 'needs_human_review' or 'insufficient_evidence', clearly communicate this and suggest manual checking.", + "If status is 'contradicted', always show the counter_evidence to the user.", + "IMPORTANT: Never call verify_claim_llama for multiple claims simultaneously — llama.cpp processes one request at a time. Always verify claims sequentially.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + try { + const result = await verifyClaim(params.claim, { + context: params.context, + mode: params.mode, + model: params.model, + userLanguage: params.userLanguage, + signal, + }); + return { + content: [{ type: "text", text: formatVerificationResult(result) }], + details: { + status: result.status, + confidence: result.confidence, + model: result.model, + sourceCount: result.sources.length, + perplexityCostUSD: result.perplexityCostUSD, + latencyMs: result.latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Verifikationsfehler: ${msg}` }] }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI-Modus +// --------------------------------------------------------------------------- + +function parseCliArgs(args: string[]): { + claim: string; + mode: "fast" | "deep"; + model: string; + userLanguage: string; + jsonOutput: boolean; + verbose: boolean; +} { + let mode: "fast" | "deep" = "fast"; + let model = DEFAULT_MODEL; + let userLanguage = DEFAULT_USER_LANGUAGE; + let jsonOutput = false; + let verbose = false; + const claimParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--mode" && args[i + 1]) { + const m = args[++i]; + if (m === "fast" || m === "deep") mode = m; + } else if (arg === "--model" && args[i + 1]) { + model = args[++i]; + } else if (arg === "--user-language" && args[i + 1]) { + userLanguage = args[++i]; + } else if (arg === "--json") { + jsonOutput = true; + } else if (arg === "--verbose" || arg === "-v") { + verbose = true; + } else if (!arg.startsWith("--")) { + claimParts.push(arg); + } + } + + return { claim: claimParts.join(" ").trim(), mode, model, userLanguage, jsonOutput, verbose }; +} + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + console.log(` +Claim-Verifikator (llama.cpp) — Eine Behauptung mit Perplexity + llama.cpp prüfen + +Verwendung: + npx tsx agenten/llama-verifier.ts [Optionen] "Behauptung..." + +Optionen: + --mode fast|deep Perplexity-Modus (Standard: fast) + --model llama.cpp-Modell (Standard: ${DEFAULT_MODEL}) + --user-language Sprache für Urteilstext, z.B. "de", "en" (Standard: ${DEFAULT_USER_LANGUAGE}) + --json Ausgabe als JSON (VerificationResult) + --verbose, -v Ausführliches Logging in ~/.pi/agent/logs/ + --help Diese Hilfe + +Umgebungsvariablen: + LLAMA_HOST llama.cpp-Server-URL (Standard: http://localhost:8000) + PERPLEXITY_API_KEY Perplexity API-Key (erforderlich) + +Beispiele: + npx tsx agenten/llama-verifier.ts "Die EZB hat den Leitzins im Juni 2024 gesenkt." + npx tsx agenten/llama-verifier.ts --mode deep "Die Erde ist 4,6 Milliarden Jahre alt." + npx tsx agenten/llama-verifier.ts --user-language en "Trump called Iran's response 'totally unacceptable'." + npx tsx agenten/llama-verifier.ts --json "Behauptung..." | python3 -m json.tool +`); + process.exit(0); + } + + const { claim, mode, model, userLanguage, jsonOutput, verbose } = parseCliArgs(args); + + if (!claim) { + console.error("Fehler: Kein Claim übergeben. Nutze --help für Informationen."); + process.exit(1); + } + + if (!jsonOutput) { + console.error(`\nVerifiziere: "${claim}"\nModus: ${mode} | Modell: ${model} | Urteils-Sprache: ${userLanguage}\n`); + } + + const log = createLogger({ verbose }); + + try { + const result = await verifyClaim(claim, { mode, model, userLanguage, logger: log }); + if (jsonOutput) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatVerificationResult(result)); + } + } catch (err) { + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { + runCli(); +} diff --git a/agenten/llama-verify-article.ts b/agenten/llama-verify-article.ts new file mode 100644 index 0000000..431745c --- /dev/null +++ b/agenten/llama-verify-article.ts @@ -0,0 +1,838 @@ +/** + * llama-verify-article.ts + * Pi-Extension + CLI: Vollständige Fact-Check-Pipeline via llama.cpp + * + * Ablauf: + * 1. Claim-Extraktion via llama.cpp (lokal, Port 8000) + * 2. Perplexity-Recherche für alle prüfbaren Claims (parallel) + * 3. Batch-Urteilssynthese via llama.cpp (1 Aufruf für alle Claims) + * 4. Verifikationsbericht formatieren + * + * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/llama-verify-article.ts + * Als CLI: + * npx tsx agenten/llama-verify-article.ts "$(cat artikel.txt)" + * npx tsx agenten/llama-verify-article.ts --file artikel.txt --mode deep + * npx tsx agenten/llama-verify-article.ts --json --file artikel.txt > report.json + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { readFile } from "node:fs/promises"; +import { + searchPerplexity, + formatSourcesForPrompt, + type PerplexityResult, +} from "../lib/perplexity.js"; +import { callLlamaClaimExtract, type ClaimSet } from "./llama-claim-extractor.js"; +import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; +import { + saveJobFile, + loadJobFile, + jobFileExists, + updateJobMeta, + getOrCreateJob, +} from "../lib/jobs.js"; +import { getCached, setCached } from "../lib/cache.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +type VerificationStatus = + | "supported" + | "contradicted" + | "mixed" + | "insufficient_evidence" + | "needs_human_review" + | "not_checkable"; + +type Confidence = "high" | "medium" | "low"; + +type VerdictItem = { + claim_id: string; + status: VerificationStatus; + confidence: Confidence; + summary: string; + counter_evidence: string | null; + notes: string | null; + supporting_urls: string[]; +}; + +type BatchVerdictRaw = { verdicts: VerdictItem[] }; + +export type VerificationReport = { + schema_version: "1.0.0"; + verified_at: string; + source_text_summary: string; + summary: string; + results: Array<{ + claim_id: string; + claim_text: string; + status: VerificationStatus; + confidence: Confidence; + summary: string; + sources: Array<{ url: string; title: string | null; supports_claim: boolean }>; + counter_evidence: string | null; + notes: string | null; + }>; + stats: Record; + totalCostUSD: number; + latencyMs: number; +}; + +// llama.cpp OpenAI-kompatibles API-Format +type LlamaResponse = { + choices: Array<{ + message?: { content?: string; reasoning_content?: string }; + finish_reason?: string; + }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf"; +const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000"; +const DEFAULT_MAX_CLAIMS = 15; +const DEFAULT_USER_LANGUAGE = "de"; +const MAX_PARALLEL_PERPLEXITY = 5; +// Batch-Verdicts: viele Claims + Perplexity-Texte → großes Kontextfenster +const MAX_TOKENS_BATCH = 32768; +const TEMPERATURE = 0.1; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 15_000; + +// --------------------------------------------------------------------------- +// Batch-Urteilssynthese via llama.cpp +// --------------------------------------------------------------------------- + +function langLabel(userLanguage: string): string { + if (userLanguage === "de") return "Deutsch"; + if (userLanguage === "en") return "Englisch"; + if (userLanguage === "fr") return "Französisch"; + if (userLanguage === "es") return "Spanisch"; + return userLanguage; +} + +function buildBatchVerdictSystemPrompt(userLanguage: string): string { + return `Du bist ein erfahrener Fact-Checker. Bewerte jede Behauptung anhand der bereitgestellten Recherche-Ergebnisse. + +Status-Skala: +- supported: Quellen bestätigen klar und konsistent +- contradicted: Quellen widersprechen klar und SUBSTANZIELL +- mixed: Widersprüchliche Quellenlage ODER Behauptung technisch ungenau aber im Kern korrekt +- insufficient_evidence: Zu wenig oder schwache Quellen +- needs_human_review: Komplex, politisch heikel, stark kontextabhängig + +Confidence: high (eindeutige Primärquellen), medium (begrenzte/sekundäre Quellen), low (sehr unklar) + +WICHTIGE REGELN für "contradicted": +- Nur bei klar substanziellen Fehlern: falsche Person, Zahl >10% abweichend, falsch zugeordnetes Ereignis +- Gerundete/allgemein akzeptierte Näherungswerte → "supported" (z.B. "21 Millionen Bitcoin" ist korrekte Rundung) +- Zeitzonendifferenzen historischer Ereignisse → "supported" wenn im üblichen regionalen Kontext korrekt +- Technische Präzisierungen zu korrekten Aussagen → "mixed", nicht "contradicted" +- Im Zweifel immer "mixed" statt "contradicted" + +AUSGABESPRACHE: Schreibe summary, counter_evidence und notes auf ${langLabel(userLanguage)}. +Die Enum-Werte status und confidence bleiben englisch. + +summary: 1-3 präzise Sätze. Nicht spekulieren. +counter_evidence: Gegenbelege als Satz, sonst null. +notes: Zeitabhängigkeit, Einschränkungen, sonst null. +supporting_urls: URLs der stützenden Quellen (leeres Array wenn keine). + +Antworte NUR mit diesem JSON-Objekt — kein Freitext davor oder danach: +{ + "verdicts": [ + { + "claim_id": "c001", + "status": "supported|contradicted|mixed|insufficient_evidence|needs_human_review", + "confidence": "high|medium|low", + "summary": "...", + "counter_evidence": "..." | null, + "notes": "..." | null, + "supporting_urls": ["url1"] + } + ] +}`; +} + +function buildBatchVerdictUserPrompt( + claims: Array<{ id: string; text: string; perplexity: PerplexityResult }> +): string { + const claimsBlock = claims + .map(({ id, text, perplexity }) => { + const sourcesFormatted = formatSourcesForPrompt(perplexity.sources, 200); + return `--- +BEHAUPTUNG ${id}: "${text}" +RECHERCHE: +${perplexity.summary} + +QUELLEN: +${sourcesFormatted || "(keine Quellen gefunden)"}`; + }) + .join("\n\n"); + + return `/no_think\n${claimsBlock}\n\nBewerte alle ${claims.length} Behauptungen.`; +} + +async function synthesizeBatchVerdicts( + claims: Array<{ id: string; text: string; perplexity: PerplexityResult }>, + model: string, + userLanguage: string, + signal?: AbortSignal, + logger?: Logger +): Promise { + if (claims.length === 0) return []; + + const log = logger ?? nullLogger; + + const body = { + model, + messages: [ + { role: "system", content: buildBatchVerdictSystemPrompt(userLanguage) }, + { role: "user", content: buildBatchVerdictUserPrompt(claims) }, + ], + stream: false, + temperature: TEMPERATURE, + max_tokens: MAX_TOKENS_BATCH, + }; + + let resp: Response | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + break; + } catch (err) { + const isLast = attempt === MAX_RETRIES; + log.warn(`llama.cpp Batch-Verdict fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, { + error: err instanceof Error ? err.message : String(err), + }); + if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`); + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + } + + if (!resp!.ok) { + const errText = await resp!.text().catch(() => ""); + throw new Error(`llama.cpp Batch-Verdict Fehler ${resp!.status}: ${errText}`); + } + + const data = (await resp!.json()) as LlamaResponse; + const choice = data.choices?.[0]; + let raw = choice?.message?.content ?? ""; + + // Reasoning-Fallback: wenn content leer, JSON aus reasoning_content extrahieren + if (!raw.trim() && choice?.message?.reasoning_content) { + const rc = choice.message.reasoning_content; + const allMatches = [...rc.matchAll(/\{[^{}]*"verdicts"\s*:/g)]; + const lastIdx = allMatches.length > 0 + ? rc.lastIndexOf(allMatches[allMatches.length - 1][0]) + : -1; + const extracted = lastIdx >= 0 + ? rc.slice(lastIdx).match(/\{[\s\S]*\}/)?.[0] + : rc.match(/\{[\s\S]*"verdicts"[\s\S]*\}/)?.[0]; + if (extracted) { + raw = extracted; + log.warn("Batch-Verdict: JSON aus reasoning_content extrahiert", { + finishReason: choice.finish_reason, + }); + } + } + + const cleanedRaw = raw + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + + log.debug("Batch-Verdict erhalten", { + promptTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens, + finishReason: choice?.finish_reason, + rawLength: raw.length, + }); + + if (!cleanedRaw) throw new Error("Leere llama.cpp-Antwort für Batch-Verdicts"); + + let parsed: unknown; + try { + parsed = JSON.parse(cleanedRaw); + } catch { + throw new Error(`Kein gültiges JSON von llama.cpp: ${cleanedRaw.slice(0, 300)}`); + } + + const { verdicts } = parsed as BatchVerdictRaw; + return verdicts ?? []; +} + +// --------------------------------------------------------------------------- +// Parallel-Limiter für Perplexity +// --------------------------------------------------------------------------- + +async function runWithConcurrencyLimit( + tasks: Array<() => Promise>, + limit: number +): Promise { + const results: T[] = new Array(tasks.length); + let index = 0; + + async function worker() { + while (index < tasks.length) { + const current = index++; + results[current] = await tasks[current](); + } + } + + const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker); + await Promise.all(workers); + return results; +} + +// --------------------------------------------------------------------------- +// Hauptfunktion +// --------------------------------------------------------------------------- + +export async function verifyArticle( + text: string, + options?: { + maxClaims?: number; + mode?: "fast" | "deep"; + model?: string; + userLanguage?: string; + signal?: AbortSignal; + onProgress?: (msg: string) => void; + logger?: Logger; + jobDir?: string; + noCache?: boolean; + } +): Promise { + const t0 = Date.now(); + const model = options?.model ?? DEFAULT_MODEL; + const maxClaims = Math.min(options?.maxClaims ?? DEFAULT_MAX_CLAIMS, 20); + const mode = options?.mode ?? "fast"; + const userLanguage = options?.userLanguage ?? DEFAULT_USER_LANGUAGE; + const log = options?.logger ?? nullLogger; + const jobDir = options?.jobDir; + const useCache = !(options?.noCache ?? false); + const progress = (msg: string) => { + options?.onProgress?.(msg); + log.info(msg); + }; + + log.info("llama-verify-article gestartet", { textLength: text.length, model, maxClaims, mode, userLanguage, jobDir }); + + // Schritt 1: Claim-Extraktion (oder aus Job-Cache laden) + let claimSet: ClaimSet; + if (jobDir) { + const cached = loadJobFile(jobDir, "claims.json"); + if (cached) { + claimSet = cached; + const checkable = claimSet.claims.filter((c) => c.checkability === "checkable").length; + progress(`Claims aus Job geladen (${claimSet.total_claims} total, ${checkable} prüfbar) — Extraktion übersprungen.`); + } else { + updateJobMeta(jobDir, { status: "extracting" }); + progress("Claims extrahieren (llama.cpp)..."); + const { claimSet: extracted, tokensIn, tokensOut, latencyMs: extractLatency } = await callLlamaClaimExtract( + text, model, maxClaims, options?.signal, log + ); + claimSet = extracted; + saveJobFile(jobDir, "claims.json", claimSet); + updateJobMeta(jobDir, { + status: "verifying", + steps: { + extract: { + completedAt: new Date().toISOString(), + totalClaims: claimSet.total_claims, + checkableClaims: claimSet.claims.filter((c) => c.checkability === "checkable").length, + latencyMs: extractLatency, + }, + }, + }); + log.info("Claims extrahiert + gespeichert", { total: claimSet.total_claims, tokensIn, tokensOut, latencyMs: extractLatency }); + } + } else { + progress("Claims extrahieren (llama.cpp)..."); + const { claimSet: extracted, tokensIn, tokensOut, latencyMs: extractLatency } = await callLlamaClaimExtract( + text, model, maxClaims, options?.signal, log + ); + claimSet = extracted; + log.info("Claims extrahiert", { total: claimSet.total_claims, tokensIn, tokensOut, latencyMs: extractLatency }); + } + + const checkableClaims = claimSet.claims.filter((c) => c.checkability === "checkable"); + const uncheckedClaims = claimSet.claims.filter((c) => c.checkability !== "checkable"); + progress( + `${claimSet.total_claims} Claims — ${checkableClaims.length} prüfbar, ` + + `${uncheckedClaims.length} nicht prüfbar.` + ); + + if (checkableClaims.length === 0) { + progress("⚠ Keine prüfbaren Claims gefunden — Verifikation nicht möglich."); + } + + // Schritt 2: Perplexity parallel (mit Limit) — mit Job- und Global-Cache + let doneCount = 0; + const total = checkableClaims.length; + + if (jobDir && total > 0) { + const cachedCount = checkableClaims.filter((c) => + jobFileExists(jobDir, `perplexity/${c.claim_id}.json`) + ).length; + if (cachedCount > 0) { + progress(`${cachedCount}/${total} Perplexity-Ergebnisse aus Job-Cache geladen.`); + } + } + + const perplexityTasks = checkableClaims.map((claim) => async () => { + const short = claim.text.length > 55 ? claim.text.slice(0, 52) + "..." : claim.text; + + if (jobDir) { + const cached = loadJobFile(jobDir, `perplexity/${claim.claim_id}.json`); + if (cached) { + doneCount++; + progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ (cached) "${short}"`); + return { claim, result: cached, error: null }; + } + } + + if (useCache) { + const globalCached = getCached(claim.text); + if (globalCached) { + doneCount++; + progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ (cache) "${short}"`); + return { claim, result: globalCached, error: null }; + } + } + + try { + const result = await searchPerplexity(claim.text, { mode, signal: options?.signal }); + doneCount++; + if (useCache) setCached(claim.text, result); + if (jobDir) { + saveJobFile(jobDir, `perplexity/${claim.claim_id}.json`, result); + } + progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ "${short}"`); + return { claim, result, error: null }; + } catch (err: unknown) { + doneCount++; + const errMsg = err instanceof Error ? err.message : "Perplexity-Fehler"; + progress(`[${doneCount}/${total}] ${claim.claim_id} ✗ "${short}" — ${errMsg}`); + return { claim, result: null as PerplexityResult | null, error: errMsg }; + } + }); + + if (total > 0) progress(`Recherche läuft (${total} Claims, max. ${MAX_PARALLEL_PERPLEXITY} parallel)...`); + const perplexityOutcomes = await runWithConcurrencyLimit(perplexityTasks, MAX_PARALLEL_PERPLEXITY); + const successful = perplexityOutcomes.filter((o) => o.result !== null) as Array<{ + claim: (typeof checkableClaims)[number]; + result: PerplexityResult; + error: null; + }>; + const failed = perplexityOutcomes.filter((o) => o.error !== null); + const totalPerplexityCost = successful.reduce((sum, o) => sum + o.result.estimatedCostUSD, 0); + + log.info("Perplexity abgeschlossen", { + successful: successful.length, + failed: failed.length, + totalCostUSD: totalPerplexityCost.toFixed(4), + }); + + // Schritt 3: Batch-Urteilssynthese via llama.cpp + progress(`Urteilssynthese (llama.cpp, ${successful.length} Claims, Sprache: ${userLanguage})...`); + const verdicts = await synthesizeBatchVerdicts( + successful.map((o) => ({ id: o.claim.claim_id, text: o.claim.text, perplexity: o.result })), + model, + userLanguage, + options?.signal, + log + ); + + // Schritt 4: Report zusammenbauen + const verdictMap = new Map(verdicts.map((v) => [v.claim_id, v])); + + const results: VerificationReport["results"] = [ + ...successful.map((o) => { + const verdict = verdictMap.get(o.claim.claim_id); + const sources = o.result.sources.map((s) => ({ + url: s.url, + title: s.title ?? null, + supports_claim: verdict?.supporting_urls.includes(s.url) ?? false, + })); + return { + claim_id: o.claim.claim_id, + claim_text: o.claim.text, + status: (verdict?.status ?? "insufficient_evidence") as VerificationStatus, + confidence: (verdict?.confidence ?? "low") as Confidence, + summary: verdict?.summary ?? "Keine Urteilssynthese verfügbar.", + sources, + counter_evidence: verdict?.counter_evidence ?? null, + notes: verdict?.notes ?? null, + }; + }), + ...failed.map((o) => ({ + claim_id: o.claim.claim_id, + claim_text: o.claim.text, + status: "insufficient_evidence" as VerificationStatus, + confidence: "low" as Confidence, + summary: `Recherche fehlgeschlagen: ${o.error}`, + sources: [], + counter_evidence: null, + notes: null, + })), + ...uncheckedClaims.map((c) => ({ + claim_id: c.claim_id, + claim_text: c.text, + status: "not_checkable" as VerificationStatus, + confidence: "high" as Confidence, + summary: `Nicht empirisch prüfbar (${c.claim_type}).`, + sources: [], + counter_evidence: null, + notes: null, + })), + ]; + + const stats: Record = { + total: results.length, + supported: 0, + contradicted: 0, + mixed: 0, + insufficient_evidence: 0, + needs_human_review: 0, + not_checkable: 0, + }; + for (const r of results) stats[r.status] = (stats[r.status] ?? 0) + 1; + + const checkedCount = successful.length; + const summaryParts = [ + `${claimSet.total_claims} Claims extrahiert, ${checkedCount} recherchiert.`, + stats.supported > 0 ? `${stats.supported} bestätigt` : "", + stats.contradicted > 0 ? `${stats.contradicted} widerlegt` : "", + stats.mixed > 0 ? `${stats.mixed} gemischt` : "", + stats.needs_human_review > 0 ? `${stats.needs_human_review} → Menschliche Prüfung nötig` : "", + stats.insufficient_evidence > 0 ? `${stats.insufficient_evidence} ohne ausreichende Belege` : "", + ] + .filter(Boolean) + .join(". "); + + const totalLatencyMs = Date.now() - t0; + log.info("llama-verify-article abgeschlossen", { + ...stats, + totalCostUSD: totalPerplexityCost.toFixed(4), + latencyMs: totalLatencyMs, + }); + + const report: VerificationReport = { + schema_version: "1.0.0", + verified_at: new Date().toISOString(), + source_text_summary: text.slice(0, 200) + (text.length > 200 ? "…" : ""), + summary: summaryParts, + results, + stats, + totalCostUSD: totalPerplexityCost, + latencyMs: totalLatencyMs, + }; + + if (jobDir) { + saveJobFile(jobDir, "report.json", report); + updateJobMeta(jobDir, { + status: "completed", + steps: { + verify: { + completedAt: new Date().toISOString(), + claimsVerified: successful.length, + totalCostUSD: totalPerplexityCost, + latencyMs: totalLatencyMs, + }, + }, + }); + log.info("Report in Job gespeichert", { jobDir }); + } + + return report; +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +const STATUS_ICON: Record = { + supported: "✓ BESTÄTIGT", + contradicted: "✗ WIDERLEGT", + mixed: "~ GEMISCHT", + insufficient_evidence: "? BELEGE UNZUREICHEND", + needs_human_review: "⚠ MENSCHLICHE PRÜFUNG NÖTIG", + not_checkable: "— NICHT PRÜFBAR", +}; + +function formatReport(report: VerificationReport, model: string): string { + const lines: string[] = []; + + lines.push(`## Verifikationsbericht (llama.cpp)`); + lines.push(report.summary); + lines.push(""); + + const groups: VerificationStatus[] = [ + "supported", + "contradicted", + "mixed", + "needs_human_review", + "insufficient_evidence", + "not_checkable", + ]; + + for (const status of groups) { + const items = report.results.filter((r) => r.status === status); + if (items.length === 0) continue; + + lines.push(`**${STATUS_ICON[status]} (${items.length}):**`); + for (const item of items) { + lines.push(`\`${item.claim_id}\` "${item.claim_text}"`); + + if (item.status !== "not_checkable") { + lines.push(` → ${item.summary}`); + if (item.counter_evidence) { + lines.push(` ✗ Gegenbeleg: ${item.counter_evidence}`); + } + if (item.notes) { + lines.push(` ℹ ${item.notes}`); + } + if (item.sources.length > 0) { + const supporting = item.sources.filter((s) => s.supports_claim); + if (supporting.length > 0) { + lines.push(` Quellen: ${supporting.map((s) => `[${s.title ?? s.url}](${s.url})`).join(", ")}`); + } + } + } + lines.push(""); + } + } + + const latSec = (report.latencyMs / 1000).toFixed(0); + lines.push(`_[Perplexity: ~$${report.totalCostUSD.toFixed(4)} | llama.cpp: ${model} | Gesamt: ${latSec}s]_`); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension: Default Export +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + text: Type.String({ + description: + "Der vollständige Artikel- oder Blogtext, der auf Fakten geprüft werden soll. " + + "Nicht kürzen — der Originaltext wird für die Claim-Extraktion benötigt.", + }), + maxClaims: Type.Optional( + Type.Number({ + description: `Maximale Anzahl zu prüfender Claims. Standard: ${DEFAULT_MAX_CLAIMS}. Max: 20.`, + }) + ), + mode: Type.Optional( + Type.Union([Type.Literal("fast"), Type.Literal("deep")], { + description: + "fast (Standard): sonar, kostengünstig. deep: sonar-pro, für investigative Inhalte.", + }) + ), + model: Type.Optional( + Type.String({ + description: `llama.cpp-Modell. Standard: ${DEFAULT_MODEL}.`, + }) + ), + userLanguage: Type.Optional( + Type.String({ + description: `Sprache für Urteilstext (summary, counter_evidence, notes). Standard: ${DEFAULT_USER_LANGUAGE}.`, + }) + ), +}); + +export default function llamaVerifyArticleExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "verify_article_llama", + label: "Artikel-Verifikation (llama.cpp)", + description: + "Vollständige Fact-Check-Pipeline via llama.cpp: " + + "Claims extrahieren → Perplexity-Recherche (parallel) → llama.cpp-Urteil (batch) → Bericht. " + + "Effizienter als verify_claim_llama für mehrere Claims. " + + "Typische Kosten: $0.05–0.15 für einen Artikel mit 10–15 Claims (nur Perplexity, llama.cpp lokal).", + promptGuidelines: [ + "Use verify_article_llama when the user wants to fact-check an entire article, blog post, or longer text.", + "Use verify_claim_llama instead when the user wants to check a single specific claim.", + "Pass the FULL article text — do not summarize it first.", + "Use mode=deep for scientific, medical, legal, or politically sensitive content.", + "Set userLanguage to match the user's preferred language (e.g. 'de' for German, 'en' for English).", + "Always show the full formatted report including the cost/latency line.", + "Highlight contradicted claims and claims needing human review prominently.", + "If needs_human_review claims exist, explain that they require manual fact-checking.", + "After the report, offer to show full sources for specific claims if the user wants details.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + const model = params.model ?? DEFAULT_MODEL; + try { + const report = await verifyArticle(params.text, { + maxClaims: params.maxClaims, + mode: params.mode, + model, + userLanguage: params.userLanguage, + signal, + }); + + return { + content: [{ type: "text", text: formatReport(report, model) }], + details: { + totalClaims: report.stats.total, + supported: report.stats.supported, + contradicted: report.stats.contradicted, + needsHumanReview: report.stats.needs_human_review, + totalCostUSD: report.totalCostUSD, + latencyMs: report.latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Artikel-Verifikation (llama.cpp) fehlgeschlagen: ${msg}` }] }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI-Modus +// --------------------------------------------------------------------------- + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + console.log(` +Artikel-Verifikator (llama.cpp) — Vollständige Fact-Check-Pipeline + +Verwendung: + npx tsx agenten/llama-verify-article.ts [Optionen] "Artikeltext..." + npx tsx agenten/llama-verify-article.ts --file artikel.txt [Optionen] + +Optionen: + --file, -f Text aus Datei lesen + --mode fast|deep Perplexity-Modus (Standard: fast) + --model llama.cpp-Modell (Standard: ${DEFAULT_MODEL}) + --max-claims Max. Claims (Standard: ${DEFAULT_MAX_CLAIMS}) + --user-language Sprache für Urteilstext, z.B. "de", "en" (Standard: ${DEFAULT_USER_LANGUAGE}) + --job-id Job-Speicher: Zwischenergebnisse nach ~/.pi/agent/jobs/_/ + --no-cache Globalen Claim-Cache deaktivieren + --json Ausgabe als JSON + --verbose, -v Ausführliche Ausgabe + Log-Datei + --help Diese Hilfe + +Umgebungsvariablen: + LLAMA_HOST llama.cpp-Server-URL (Standard: http://localhost:8000) + PERPLEXITY_API_KEY Perplexity API-Key (erforderlich) + +Beispiele: + npx tsx agenten/llama-verify-article.ts --file artikel.txt + npx tsx agenten/llama-verify-article.ts --file artikel.txt --mode deep --user-language en + npx tsx agenten/llama-verify-article.ts --file artikel.txt --job-id mein-artikel --verbose + npx tsx agenten/llama-verify-article.ts --json --file artikel.txt > report.json +`); + process.exit(0); + } + + let mode: "fast" | "deep" = "fast"; + let model = DEFAULT_MODEL; + let maxClaims = DEFAULT_MAX_CLAIMS; + let userLanguage = DEFAULT_USER_LANGUAGE; + let jobId: string | undefined; + let jsonOutput = false; + let verbose = false; + let noCache = false; + let file: string | null = null; + const textParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--mode" && args[i + 1]) { + const m = args[++i]; + if (m === "fast" || m === "deep") mode = m; + } else if (arg === "--model" && args[i + 1]) { + model = args[++i]; + } else if (arg === "--max-claims" && args[i + 1]) { + maxClaims = parseInt(args[++i], 10); + } else if (arg === "--user-language" && args[i + 1]) { + userLanguage = args[++i]; + } else if (arg === "--job-id" && args[i + 1]) { + jobId = args[++i]; + } else if ((arg === "--file" || arg === "-f") && args[i + 1]) { + file = args[++i]; + } else if (arg === "--json") { + jsonOutput = true; + } else if (arg === "--verbose" || arg === "-v") { + verbose = true; + } else if (arg === "--no-cache") { + noCache = true; + } else if (!arg.startsWith("--")) { + textParts.push(arg); + } + } + + let text: string; + if (file) { + try { + text = await readFile(file, "utf-8"); + } catch (err) { + console.error(`Fehler: Datei '${file}' konnte nicht gelesen werden: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } else { + text = textParts.join(" ").trim(); + } + + if (!text.trim()) { + console.error("Fehler: Kein Text übergeben. Nutze --file oder übergib den Text direkt."); + process.exit(1); + } + + if (!jsonOutput) { + const src = file ? `Datei: ${file}` : "Direkteingabe"; + console.error(`\nModus: ${mode} | Modell: ${model} | Max. Claims: ${maxClaims} | Sprache: ${userLanguage} | ${src}${jobId ? ` | Job: ${jobId}` : ""}\n`); + } + + const log = createLogger({ verbose, jobId }); + const onProgress = jsonOutput ? undefined : (msg: string) => process.stderr.write(` ${msg}\n`); + + let jobDir: string | undefined; + if (jobId) { + const { jobDir: dir, isNew } = getOrCreateJob(jobId, model); + jobDir = dir; + if (isNew) saveJobFile(jobDir, "input.txt", text); + if (!jsonOutput) { + process.stderr.write(` Job: ${jobDir} (${isNew ? "neu" : "fortgesetzt"})\n\n`); + } + } + + try { + const report = await verifyArticle(text, { maxClaims, mode, model, userLanguage, onProgress, logger: log, jobDir, noCache }); + if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(formatReport(report, model)); + } + } catch (err) { + if (jobDir) updateJobMeta(jobDir, { status: "failed" }); + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { + runCli(); +} diff --git a/agenten/llama-writer.ts b/agenten/llama-writer.ts new file mode 100644 index 0000000..fdbcb7d --- /dev/null +++ b/agenten/llama-writer.ts @@ -0,0 +1,582 @@ +/** + * llama-writer.ts + * Pi-Extension + CLI: Artikel schreiben via llama.cpp (lokales LLM) + * + * Schreibt einen Artikel NUR auf Basis von "supported"-Claims aus einem VerificationReport. + * Widerlgte, gemischte oder unzureichend belegte Claims werden automatisch ausgeschlossen. + * + * Kein Ollama-format-Parameter — Schema steht als JSON-Literal im System-Prompt. + * /no_think deaktiviert den Thinking-Modus bei Qwen3/Qwopus-Reasoning-Modellen. + * + * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink) + * Als CLI: + * npx tsx agenten/llama-writer.ts --from-job --style blog + * npx tsx agenten/llama-verify-article.ts --json "$(cat artikel.txt)" | npx tsx agenten/llama-writer.ts --from-report + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import type { VerificationReport } from "./llama-verify-article.js"; +import { + findJobDir, + loadJobFile, + saveJobFile, + updateJobMeta, +} from "../lib/jobs.js"; +import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +type Style = "journalistic" | "blog" | "academic" | "editorial" | "explanatory"; + +type ArticleDraft = { + schema_version: "1.0.0"; + title: string; + lead: string; + body: string; + conclusion: string | null; + style: Style; + language: string; + word_count: number; + claim_ids_used: string[]; + sources: Array<{ number: number; url: string; title: string | null; claim_id: string }>; + excluded_claims: string[]; + editorial_notes: string; +}; + +// llama.cpp OpenAI-kompatibles API-Format +type LlamaResponse = { + choices: Array<{ + message?: { content?: string; reasoning_content?: string }; + finish_reason?: string; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + }; +}; + +export type WriteResult = { + draft: ArticleDraft; + provider: "llama"; + model: string; + costUSD: 0; + latencyMs: number; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = "Qwopus3.6-35B-A3B-v1-Q4_K_M.gguf"; +const LLAMA_HOST = process.env.LLAMA_HOST ?? "http://localhost:8000"; +const MAX_TOKENS = 16384; +const TEMPERATURE = 0.4; +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 15_000; + +// --------------------------------------------------------------------------- +// Schema + Prompt-Generierung +// --------------------------------------------------------------------------- + +const STYLE_GUIDE: Record = { + journalistic: + "Journalistisch: präzise, faktenbasiert, W-Fragen im Einleitungssatz, Inverted Pyramid, " + + "zitierbare Aussagen direkt belegt, keine Meinungen ohne Kennzeichnung.", + blog: + "Blog: zugänglich, ansprechend, erste Person erlaubt, direkte Ansprache des Lesers, " + + "lebendige Sprache, Zwischenüberschriften als Orientierung.", + academic: + "Akademisch: präzise Terminologie, passive Formulierungen, klare Abschnittsstruktur " + + "(Einleitung, Hauptteil, Schluss), Quellenverweise inline.", + editorial: + "Leitartikel: klare Haltung, argumentativ, Bezug zur aktuellen Debatte, " + + "stützt sich auf Fakten aber formuliert Bewertung.", + explanatory: + "Erklärstück: vereinfacht komplexe Sachverhalte, Analogien und Beispiele, " + + "schrittweise Struktur, Leserfragen antizipieren.", +}; + +function buildWriterSystemPrompt(style: Style, language: string, wordCount: number): string { + const langName = language === "de" ? "Deutsch" : language === "en" ? "Englisch" : language; + return `Du bist ein erfahrener Autor. Schreibe einen Artikel nach folgenden Vorgaben: + +STIL: ${STYLE_GUIDE[style]} +SPRACHE: ${langName} +LÄNGE: ca. ${wordCount} Wörter + +Antworte AUSSCHLIESSLICH mit einem JSON-Objekt gemäß folgendem Schema: +{ + "title": "Artikeltitel (string)", + "lead": "Einleitungsabsatz (string)", + "body": "Haupttext mit Quellenangaben [N] (string)", + "conclusion": "Schlussabsatz oder null", + "editorial_notes": "Was fehlt für einen vollständigen Artikel? (string)" +} + +REGELN: +- Alle Felder required: title, lead, body, conclusion, editorial_notes +- conclusion darf null sein +- Verwende NUR die vom Nutzer übergebenen verifizierten Claims als Faktengrundlage +- Kennzeichne jeden Fakt mit Inline-Quellenverweisen [N] aus der Beleg-Liste +- Erfinde keine Fakten, Zahlen oder Zitate +- Kein Freitext vor oder nach dem JSON-Objekt`; +} + +type ClaimForWriting = { + id: string; + text: string; + sources: Array<{ url: string; title: string | null }>; +}; + +function buildWriterUserPrompt(claims: ClaimForWriting[], topic: string): string { + const claimsText = claims + .map((c, i) => { + const srcList = c.sources + .map((s, j) => `[${i * 10 + j + 1}] ${s.title ?? s.url} (${s.url})`) + .join("\n "); + return `Claim ${c.id}: ${c.text}\n Belege:\n ${srcList || "(keine URL)"}`; + }) + .join("\n\n"); + + return `/no_think\nSchreibe einen Artikel zum Thema: "${topic}"\n\nVERIFIZIERTE FAKTEN (nur diese dürfen verwendet werden):\n${claimsText}`; +} + +// --------------------------------------------------------------------------- +// llama.cpp-Aufruf +// --------------------------------------------------------------------------- + +async function writeWithLlama( + claims: ClaimForWriting[], + style: Style, + topic: string, + wordCount: number, + language: string, + model: string, + signal?: AbortSignal, + logger?: Logger +): Promise<{ raw: Pick; tokensIn: number; tokensOut: number; latencyMs: number }> { + const log = logger ?? nullLogger; + const t0 = Date.now(); + + const body = { + model, + messages: [ + { role: "system", content: buildWriterSystemPrompt(style, language, wordCount) }, + { role: "user", content: buildWriterUserPrompt(claims, topic) }, + ], + stream: false, + temperature: TEMPERATURE, + max_tokens: MAX_TOKENS, + }; + + log.debug("llama.cpp-Writer gestartet", { model, claimCount: claims.length, style, language, wordCount }); + + let resp: Response | null = null; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + resp = await fetch(`${LLAMA_HOST}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + break; + } catch (err) { + const isLast = attempt === MAX_RETRIES; + log.warn(`llama.cpp fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, { + error: err instanceof Error ? err.message : String(err), + retryInMs: isLast ? 0 : RETRY_DELAY_MS, + }); + if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`); + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + } + + if (!resp!.ok) { + const errorText = await resp!.text().catch(() => ""); + throw new Error(`llama.cpp API Fehler ${resp!.status}: ${errorText}`); + } + + const data = (await resp!.json()) as LlamaResponse; + const choice = data.choices?.[0]; + let raw = choice?.message?.content ?? ""; + + // Reasoning-Fallback: Wenn content leer, JSON aus reasoning_content extrahieren + if (!raw.trim() && choice?.message?.reasoning_content) { + const rc = choice.message.reasoning_content; + const allMatches = [...rc.matchAll(/\{[^{}]*"title"\s*:/g)]; + const lastBlock = allMatches.length > 0 + ? rc.match(/\{[\s\S]*"title"[\s\S]*\}/)?.[0] + : undefined; + if (lastBlock) { + raw = lastBlock; + log.warn("content leer — JSON aus reasoning_content extrahiert (Thinking-Modus aktiv trotz /no_think)", { + finishReason: choice.finish_reason, + rawLength: raw.length, + }); + } + } + + // Markdown-Codeblöcke entfernen + const cleanedRaw = raw + .replace(/^```(?:json)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + + log.debug("llama.cpp-Writer Antwort", { + promptTokens: data.usage?.prompt_tokens, + outputTokens: data.usage?.completion_tokens, + finishReason: choice?.finish_reason, + rawLength: cleanedRaw.length, + }); + + if (!cleanedRaw) throw new Error("Leere Antwort von llama.cpp-Writer"); + + let parsed: unknown; + try { + parsed = JSON.parse(cleanedRaw); + } catch { + throw new Error(`llama.cpp-Writer-Ausgabe ist kein gültiges JSON: ${cleanedRaw.slice(0, 200)}`); + } + + const p = parsed as Record; + if (typeof p.title !== "string" || typeof p.body !== "string") { + throw new Error(`Ungültige Struktur: 'title' oder 'body' fehlt. Keys: ${Object.keys(p).join(", ")}`); + } + + return { + raw: p as Pick, + tokensIn: data.usage?.prompt_tokens ?? 0, + tokensOut: data.usage?.completion_tokens ?? 0, + latencyMs: Date.now() - t0, + }; +} + +// --------------------------------------------------------------------------- +// Quellenverzeichnis aufbauen +// --------------------------------------------------------------------------- + +function buildSourceIndex(claims: ClaimForWriting[]): Array<{ number: number; url: string; title: string | null; claim_id: string }> { + const sources: Array<{ number: number; url: string; title: string | null; claim_id: string }> = []; + let n = 1; + for (const c of claims) { + for (const s of c.sources) { + sources.push({ number: n++, url: s.url, title: s.title, claim_id: c.id }); + } + } + return sources; +} + +// --------------------------------------------------------------------------- +// Hauptfunktion +// --------------------------------------------------------------------------- + +export async function writeFromReport( + report: VerificationReport, + options?: { + style?: Style; + topic?: string; + wordCount?: number; + language?: string; + model?: string; + signal?: AbortSignal; + logger?: Logger; + } +): Promise { + const log = options?.logger ?? nullLogger; + const style = options?.style ?? "journalistic"; + const wordCount = options?.wordCount ?? 400; + const language = options?.language ?? "de"; + const model = options?.model ?? DEFAULT_MODEL; + + const supported = report.results.filter((r) => r.status === "supported"); + const excluded = report.results.filter((r) => r.status !== "supported").map((r) => r.claim_id); + + if (supported.length === 0) { + throw new Error("Keine verifizierten (supported) Claims im Report — kein Artikel möglich."); + } + + const topic = options?.topic ?? report.source_text_summary ?? "Artikel"; + + const claims: ClaimForWriting[] = supported.map((r) => ({ + id: r.claim_id, + text: r.claim_text, + sources: r.sources + .filter((s) => s.supports_claim) + .map((s) => ({ url: s.url, title: s.title })), + })); + + log.info(`llama.cpp-Writer: ${claims.length} Claims, Stil: ${style}, Sprache: ${language}, Ziel: ${wordCount} Wörter`); + + const result = await writeWithLlama(claims, style, topic, wordCount, language, model, options?.signal, log); + + const sources = buildSourceIndex(claims); + const wordCountActual = (result.raw.lead + " " + result.raw.body + " " + (result.raw.conclusion ?? "")) + .split(/\s+/).filter(Boolean).length; + + const draft: ArticleDraft = { + ...result.raw, + schema_version: "1.0.0" as const, + style, + language, + word_count: wordCountActual, + claim_ids_used: claims.map((c) => c.id), + sources, + excluded_claims: excluded, + editorial_notes: result.raw.editorial_notes ?? "", + }; + + return { draft, provider: "llama", model, costUSD: 0, latencyMs: result.latencyMs }; +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +export function formatDraft(result: WriteResult): string { + const { draft } = result; + const lines: string[] = []; + + lines.push(`# ${draft.title}`); + lines.push(""); + lines.push(`_${draft.lead}_`); + lines.push(""); + lines.push(draft.body); + + if (draft.conclusion) { + lines.push(""); + lines.push("---"); + lines.push(draft.conclusion); + } + + if (draft.sources.length > 0) { + lines.push("\n**Quellen:**"); + draft.sources.forEach((s) => { + const title = s.title ?? s.url; + lines.push(`[${s.number}] [${title}](${s.url})`); + }); + } + + if (draft.excluded_claims.length > 0) { + lines.push(`\n_${draft.excluded_claims.length} Claim(s) ausgeschlossen (nicht verifiziert): ${draft.excluded_claims.join(", ")}_`); + } + + if (draft.editorial_notes) { + lines.push(`\n**Redaktionshinweise:** ${draft.editorial_notes}`); + } + + const latSec = (result.latencyMs / 1000).toFixed(1); + lines.push(`\n_[llama.cpp: ${result.model} · ${draft.word_count} Wörter · kostenlos (lokal) · ${latSec}s]_`); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + reportJson: Type.String({ + description: + "JSON-String eines VerificationReport (Ausgabe von verify_article_llama --json oder verify_article_llama). " + + "Nur 'supported'-Claims werden für den Artikel verwendet.", + }), + topic: Type.Optional( + Type.String({ description: "Artikelthema / Überschrift. Standard: wird aus dem Report abgeleitet." }) + ), + style: Type.Optional( + Type.Union( + [ + Type.Literal("journalistic"), + Type.Literal("blog"), + Type.Literal("academic"), + Type.Literal("editorial"), + Type.Literal("explanatory"), + ], + { description: "Schreibstil. Standard: journalistic." } + ) + ), + wordCount: Type.Optional( + Type.Number({ description: "Ziel-Wortanzahl. Standard: 400." }) + ), + language: Type.Optional( + Type.String({ description: "Sprache (ISO 639-1). Standard: de." }) + ), + model: Type.Optional( + Type.String({ description: "llama.cpp-Modell-Override." }) + ), +}); + +export default function llamaWriterExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "write_article_llama", + label: "Artikel schreiben (llama.cpp)", + description: + "Schreibt einen Artikel ausschließlich auf Basis verifizierter Claims aus einem VerificationReport. " + + "Verwendet llama.cpp lokal (kostenlos, kein Ollama-Timeout bei Thinking-Modellen). " + + "BEVORZUGT gegenüber write_article (Ollama). " + + "Workflow: verify_article_llama → write_article_llama.", + promptGuidelines: [ + "PREFERRED: Use write_article_llama for all article generation (local, free, no timeout issues).", + "Use write_article (Ollama) only when explicitly requested by the user.", + "Always pass the full JSON output of verify_article or verify_article_llama as 'reportJson'.", + "Ask the user for the desired style (journalistic, blog, academic, editorial, explanatory) if not specified.", + "Show the full formatted draft including sources and editorial notes.", + "Point out excluded claims to the user — these may be important context that was removed.", + "If editorial_notes mention missing information, suggest running additional research.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + try { + const report = JSON.parse(params.reportJson) as VerificationReport; + const result = await writeFromReport(report, { + style: params.style, + topic: params.topic, + wordCount: params.wordCount, + language: params.language, + model: params.model, + signal, + }); + return { + content: [{ type: "text", text: formatDraft(result) }], + details: { + wordCount: result.draft.word_count, + claimsUsed: result.draft.claim_ids_used.length, + claimsExcluded: result.draft.excluded_claims.length, + provider: result.provider, + latencyMs: result.latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Artikelgenerierung fehlgeschlagen: ${msg}` }] }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help") { + console.log(` +Llama-Writer — Schreibt Artikel via llama.cpp auf Basis verifizierter Claims + +Verwendung: + # Via Job-Speicher (empfohlen): + npx tsx agenten/llama-verify-article.ts --job-id umerziehung "$(cat artikel.txt)" + npx tsx agenten/llama-writer.ts --from-job umerziehung --style blog + + # Via Pipe: + npx tsx agenten/llama-verify-article.ts --json "..." | npx tsx agenten/llama-writer.ts --from-report + +Optionen: + --from-report Lese VerificationReport von stdin (JSON) + --from-job Lese report.json aus Job ~/.pi/agent/jobs/_/ + Speichert article.md automatisch zurück in den Job + --style journalistic|blog|academic|editorial|explanatory (Standard: journalistic) + --topic Artikelthema + --words Ziel-Wortanzahl (Standard: 400) + --lang Sprache (Standard: de) + --model Modell-Override (Standard: ${DEFAULT_MODEL}) + --json Ausgabe als JSON + --verbose Ausführliches Logging + --help Diese Hilfe +`); + process.exit(0); + } + + let fromReport = false; + let fromJobSlug: string | undefined; + let style: Style = "journalistic"; + let topic: string | undefined; + let wordCount = 400; + let language = "de"; + let model: string | undefined; + let jsonOutput = false; + let verbose = false; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--from-report") fromReport = true; + else if (arg === "--from-job" && args[i + 1]) fromJobSlug = args[++i]; + else if (arg === "--style" && args[i + 1]) style = args[++i] as Style; + else if (arg === "--topic" && args[i + 1]) topic = args[++i]; + else if (arg === "--words" && args[i + 1]) wordCount = parseInt(args[++i], 10); + else if (arg === "--lang" && args[i + 1]) language = args[++i]; + else if (arg === "--model" && args[i + 1]) model = args[++i]; + else if (arg === "--json") jsonOutput = true; + else if (arg === "--verbose") verbose = true; + } + + const logger = verbose ? createLogger({ verbose: true }) : nullLogger; + + let report: VerificationReport; + let jobDir: string | undefined; + + if (fromJobSlug) { + const dir = findJobDir(fromJobSlug); + if (!dir) { + console.error(`Fehler: Kein Job mit Slug "${fromJobSlug}" gefunden in ~/.pi/agent/jobs/`); + console.error("Tipp: Zuerst llama-verify-article.ts --job-id ausführen."); + process.exit(1); + } + jobDir = dir; + const loaded = loadJobFile(dir, "report.json"); + if (!loaded) { + console.error(`Fehler: Kein report.json in Job ${dir}`); + console.error("Tipp: llama-verify-article.ts --job-id muss zuerst abgeschlossen werden."); + process.exit(1); + } + report = loaded; + if (!jsonOutput) console.error(`\nJob: ${dir}\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`); + } else if (fromReport) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + const input = Buffer.concat(chunks).toString("utf-8").trim(); + if (!input) { console.error("Fehler: Kein Input von stdin."); process.exit(1); } + report = JSON.parse(input) as VerificationReport; + if (!jsonOutput) console.error(`\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`); + } else { + console.error("Fehler: --from-report oder --from-job erforderlich."); + process.exit(1); + } + + try { + const result = await writeFromReport(report, { style, topic, wordCount, language, model, logger }); + + if (jobDir) { + saveJobFile(jobDir, "article.md", formatDraft(result)); + updateJobMeta(jobDir, { + status: "completed", + steps: { + write: { + completedAt: new Date().toISOString(), + style, + wordCount: result.draft.word_count, + provider: result.provider, + costUSD: 0, + }, + }, + }); + if (!jsonOutput) process.stderr.write(`\n Artikel in Job gespeichert: ${jobDir}/article.md\n`); + } + + console.log(jsonOutput ? JSON.stringify(result.draft, null, 2) : formatDraft(result)); + } catch (err) { + if (jobDir) updateJobMeta(jobDir, { status: "failed" }); + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) runCli(); diff --git a/agenten/ollama-claim-extractor.ts b/agenten/ollama-claim-extractor.ts new file mode 100644 index 0000000..6a2ee11 --- /dev/null +++ b/agenten/ollama-claim-extractor.ts @@ -0,0 +1,697 @@ +/** + * ollama-claim-extractor.ts + * Pi-Extension + CLI: Einzelbehauptungen aus Texten extrahieren via lokalem Ollama + * + * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ollama-claim-extractor.ts + * Nach Änderungen in Pi: /reload + * + * Als CLI: + * npx tsx agenten/ollama-claim-extractor.ts "Textinhalt..." + * npx tsx agenten/ollama-claim-extractor.ts --only-checkable "Textinhalt..." + * npx tsx agenten/ollama-claim-extractor.ts --model qwen3.5:27b "Textinhalt..." + * npx tsx agenten/ollama-claim-extractor.ts --json "Textinhalt..." (nur JSON-Ausgabe) + * + * Modell-Empfehlung: qwen3.5:9b (6.6GB, 1 GPU, fast gleiche Präzision wie 27B, 2× schneller) + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +export type ClaimType = "fact" | "causal" | "statistical" | "quote" | "prediction" | "opinion"; +export type Checkability = "checkable" | "partly_checkable" | "not_checkable"; + +export type Claim = { + claim_id: string; + text: string; + claim_type: ClaimType; + checkability: Checkability; + needs_citation: boolean; + entities: string[]; + time_scope: string | null; + source_sentence: string; +}; + +export type ClaimSet = { + schema_version: "1.0.0"; + text_language: string; + extraction_notes: string; + total_claims: number; + claims: Claim[]; +}; + +type OllamaResponse = { + message?: { content?: string }; + done?: boolean; + eval_count?: number; + prompt_eval_count?: number; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = "qwen3.5:9b"; +const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434"; +const DEFAULT_MAX_CLAIMS = 40; +const TEMPERATURE = 0.1; +const NUM_CTX = 8192; + +// Texte über diesem Schwellenwert werden in Chunks aufgeteilt (Zeichen) +// 8192 Tokens Kontext: ~3000 Zeichen Input + ~1000 Prompt-Overhead + ~3200 Tokens Output (40 Claims) +const CHUNK_THRESHOLD = 4000; +const CHUNK_SIZE = 3000; + +// --------------------------------------------------------------------------- +// JSON-Schema für Ollama structured output +// (Teilmenge von claim.schema.json — ohne Pattern-Constraint, da Ollama +// reguläre Ausdrücke im format-Parameter nicht immer unterstützt) +// --------------------------------------------------------------------------- + +export const CLAIM_OLLAMA_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + schema_version: { type: "string" }, + text_language: { type: "string" }, + extraction_notes: { type: "string" }, + total_claims: { type: "integer" }, + claims: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + claim_id: { type: "string" }, + text: { type: "string" }, + claim_type: { + type: "string", + enum: ["fact", "causal", "statistical", "quote", "prediction", "opinion"], + }, + checkability: { + type: "string", + enum: ["checkable", "partly_checkable", "not_checkable"], + }, + needs_citation: { type: "boolean" }, + entities: { type: "array", items: { type: "string" } }, + time_scope: { type: ["string", "null"] }, + source_sentence: { type: "string" }, + }, + required: [ + "claim_id", + "text", + "claim_type", + "checkability", + "needs_citation", + "entities", + "time_scope", + "source_sentence", + ], + }, + }, + }, + required: ["schema_version", "text_language", "extraction_notes", "total_claims", "claims"], +}; + +// --------------------------------------------------------------------------- +// System-Prompt +// --------------------------------------------------------------------------- + +function buildSystemPrompt(maxClaims: number): string { + return `Du bist ein Experte für Faktenextraktion und Fact-Checking-Vorbereitung. + +Deine Aufgabe: Analysiere den Text und extrahiere alle Behauptungen als diskrete, einzeln prüfbare Einheiten. +Extrahiere maximal ${maxClaims} Behauptungen. Bei sehr langen Texten priorisiere die wichtigsten und prüfbarsten. + +REGELN für die Extraktion: +- Formuliere jede Behauptung als eigenständigen, vollständigen Satz (nicht als Fragment) +- Behalte den Sinn der Originalformulierung bei, mache Behauptungen aber selbstständig lesbar +- claim_id: fortlaufend "c001", "c002", "c003", ... + +CLAIM TYPES: +- fact: Konkrete Tatsachenbehauptung ("X ist Y", "X hat Z getan") +- causal: Kausalbehauptung ("X hat zu Y geführt", "wegen X passiert Y") +- statistical: Zahlen, Prozentwerte, Statistiken, Rankings +- quote: Wörtliches oder indirektes Zitat einer Person +- prediction: Prognose, Vorhersage, Erwartung über Zukunftsereignisse +- opinion: Wertung, Meinung, normative Aussage (gut/schlecht/sollte) + +CHECKABILITY: +- checkable: Empirisch überprüfbar durch Primärquellen, Datenbanken, offizielle Stellen +- partly_checkable: Nur teilweise prüfbar (z.B. enthält sowohl Fakt als auch Wertung) +- not_checkable: Reine Meinung, reine Prognose, Werturteil ohne Tatsachenkern + +NEEDS_CITATION: true wenn Zahlen, spezifische Fakten, Zitate oder Studienergebnisse vorhanden + +ENTITIES: Alle benannten Entitäten: Personen, Organisationen, Länder, Institutionen, Produkte, konkrete Daten + +TIME_SCOPE: Zeitrahmen wenn angegeben (z.B. "2024", "Q1 2025", "seit 1990"), sonst null + +SOURCE_SENTENCE: Der originale Satz aus dem Quelltext (wörtlich, max. 200 Zeichen) + +DUPLIKATE: Extrahiere jeden Sachverhalt nur einmal. Wenn derselbe Fakt im Text mehrfach vorkommt (z.B. als Einleitung und später als Detail), erstelle nur einen Claim dafür. + +Antworte NUR mit dem JSON-Objekt gemäß Schema. Kein Freitext davor oder danach.`; +} + +// --------------------------------------------------------------------------- +// Text-Chunking für lange Texte +// --------------------------------------------------------------------------- + +/** + * Teilt langen Text an Absatzgrenzen in Stücke von max. CHUNK_SIZE Zeichen. + * Absätze werden nicht aufgetrennt — bei Absätzen > CHUNK_SIZE werden sie allein übergeben. + */ +function splitIntoChunks(text: string): string[] { + const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 0); + const chunks: string[] = []; + let current = ""; + + for (const para of paragraphs) { + if (current.length + para.length + 2 > CHUNK_SIZE && current.length > 0) { + chunks.push(current.trim()); + current = para; + } else { + current = current ? current + "\n\n" + para : para; + } + } + if (current.trim()) chunks.push(current.trim()); + return chunks; +} + +/** + * Entfernt doppelte Claims (gleicher text-Inhalt nach Normalisierung). + */ +function deduplicateClaims(claims: Claim[]): Claim[] { + const seen = new Set(); + return claims.filter((c) => { + const key = c.text.toLowerCase().replace(/\s+/g, " ").trim(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +// --------------------------------------------------------------------------- +// Ollama-Aufruf +// --------------------------------------------------------------------------- + +export async function callOllamaClaimExtract( + text: string, + model: string, + maxClaims: number, + signal?: AbortSignal, + logger?: Logger +): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { + const log = logger ?? nullLogger; + // Langen Text in Chunks aufteilen + if (text.length > CHUNK_THRESHOLD) { + log.info("Text zu lang für Single-Pass — Chunking aktiv", { textLength: text.length, threshold: CHUNK_THRESHOLD }); + return callOllamaClaimExtractChunked(text, model, maxClaims, signal, log); + } + log.debug("Single-Pass Extraktion", { textLength: text.length, model, maxClaims }); + return callOllamaClaimExtractSingle(text, model, maxClaims, signal, log); +} + +async function callOllamaClaimExtractChunked( + text: string, + model: string, + maxClaims: number, + signal?: AbortSignal, + logger?: Logger +): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { + const log = logger ?? nullLogger; + const t0 = Date.now(); + const chunks = splitIntoChunks(text); + const claimsPerChunk = Math.ceil(maxClaims / chunks.length); + + log.info(`Text in ${chunks.length} Chunks aufgeteilt`, { + chunks: chunks.length, + claimsPerChunk, + chunkLengths: chunks.map((c) => c.length), + }); + + let totalIn = 0; + let totalOut = 0; + const allClaims: Claim[] = []; + let language = "de"; + const notes: string[] = []; + + for (let i = 0; i < chunks.length; i++) { + log.info(`Chunk ${i + 1}/${chunks.length} extrahieren...`, { chunkLength: chunks[i].length, claimsPerChunk }); + const result = await callOllamaClaimExtractSingle(chunks[i], model, claimsPerChunk, signal, log); + log.info(`Chunk ${i + 1}/${chunks.length} fertig`, { + claims: result.claimSet.claims.length, + tokensIn: result.tokensIn, + tokensOut: result.tokensOut, + latencyMs: result.latencyMs, + }); + allClaims.push(...result.claimSet.claims); + totalIn += result.tokensIn; + totalOut += result.tokensOut; + language = result.claimSet.text_language; + if (result.claimSet.extraction_notes) notes.push(result.claimSet.extraction_notes); + } + + // Deduplizieren und neu nummerieren + const beforeDedup = allClaims.length; + const unique = deduplicateClaims(allClaims).slice(0, maxClaims); + const renumbered: Claim[] = unique.map((c, i) => ({ + ...c, + claim_id: `c${String(i + 1).padStart(3, "0")}`, + })); + + log.info("Chunking abgeschlossen", { + totalBeforeDedup: beforeDedup, + afterDedup: renumbered.length, + totalTokensIn: totalIn, + totalTokensOut: totalOut, + totalLatencyMs: Date.now() - t0, + }); + + return { + claimSet: { + schema_version: "1.0.0", + text_language: language, + extraction_notes: `Text in ${chunks.length} Abschnitte aufgeteilt. ${notes.filter(Boolean).join(" ")}`, + total_claims: renumbered.length, + claims: renumbered, + }, + tokensIn: totalIn, + tokensOut: totalOut, + latencyMs: Date.now() - t0, + }; +} + +async function callOllamaClaimExtractSingle( + text: string, + model: string, + maxClaims: number, + signal?: AbortSignal, + logger?: Logger +): Promise<{ claimSet: ClaimSet; tokensIn: number; tokensOut: number; latencyMs: number }> { + const log = logger ?? nullLogger; + const t0 = Date.now(); + + const body = { + model, + messages: [ + { + role: "system", + content: buildSystemPrompt(maxClaims), + }, + { + role: "user", + content: `Extrahiere alle Behauptungen aus folgendem Text:\n\n---\n${text}\n---`, + }, + ], + format: CLAIM_OLLAMA_SCHEMA, + stream: false, + options: { + temperature: TEMPERATURE, + num_ctx: NUM_CTX, + }, + }; + + log.debug("Ollama-Aufruf gestartet", { model, textLength: text.length, num_ctx: NUM_CTX }); + + // Retry bei temporären Verbindungsfehlern (Ollama startet kurz neu oder ist kurz ausgelastet) + const MAX_RETRIES = 3; + const RETRY_DELAY_MS = 15_000; // 15s Pause vor Retry + let lastError: unknown; + let resp: Response | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + resp = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + break; // Verbindung erfolgreich + } catch (err) { + lastError = err; + const isLast = attempt === MAX_RETRIES; + log.warn(`Ollama fetch fehlgeschlagen (Versuch ${attempt}/${MAX_RETRIES})`, { + error: err instanceof Error ? err.message : String(err), + retryInMs: isLast ? 0 : RETRY_DELAY_MS, + }); + if (isLast) throw new Error(`fetch failed nach ${MAX_RETRIES} Versuchen: ${err instanceof Error ? err.message : err}`); + // Warten bevor Retry — Ollama könnte kurz neu starten + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + } + + if (!resp!.ok) { + const errorText = await resp!.text().catch(() => ""); + log.error("Ollama API Fehler", { status: resp!.status, body: errorText.slice(0, 200) }); + throw new Error(`Ollama API Fehler ${resp!.status}: ${errorText}`); + } + + const data = (await resp!.json()) as OllamaResponse; + const raw = data.message?.content ?? ""; + + log.debug("Ollama-Antwort empfangen", { + promptTokens: data.prompt_eval_count, + outputTokens: data.eval_count, + rawLength: raw.length, + }); + + if (!raw.trim()) { + log.error("Leere Ollama-Antwort", { promptTokens: data.prompt_eval_count }); + throw new Error("Leere Antwort von Ollama erhalten"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + log.error("JSON-Parse-Fehler", { rawPreview: raw.slice(0, 200) }); + throw new Error(`Ollama-Ausgabe ist kein gültiges JSON: ${raw.slice(0, 200)}`); + } + + // Grundlegende Strukturprüfung (kein vollständiger Schema-Validator) + const p = parsed as Record; + if (!Array.isArray(p.claims)) { + log.error("Ungültige Struktur: claims fehlt", { keys: Object.keys(p) }); + throw new Error(`Ungültige Struktur: 'claims' fehlt oder ist kein Array`); + } + + if ((p.claims as unknown[]).length === 0) { + // Leere Claims deuten auf Kontext-Overflow oder Modell-Fehler hin + const usedCtx = data.prompt_eval_count ?? 0; + log.warn("0 Claims extrahiert", { promptTokens: usedCtx, num_ctx: NUM_CTX, textLength: text.length }); + throw new Error( + `Ollama hat 0 Claims extrahiert (prompt_tokens=${usedCtx}). ` + + `Text zu lang für num_ctx=${NUM_CTX} oder Modell-Fehler.` + ); + } + + const claimSet: ClaimSet = { + schema_version: "1.0.0", + text_language: typeof p.text_language === "string" ? p.text_language : "unknown", + extraction_notes: typeof p.extraction_notes === "string" ? p.extraction_notes : "", + total_claims: typeof p.total_claims === "number" ? p.total_claims : (p.claims as unknown[]).length, + claims: p.claims as Claim[], + }; + + return { + claimSet, + tokensIn: data.prompt_eval_count ?? 0, + tokensOut: data.eval_count ?? 0, + latencyMs: Date.now() - t0, + }; +} + +// --------------------------------------------------------------------------- +// Formatierung (Pi-Ausgabe + CLI-Ausgabe) +// --------------------------------------------------------------------------- + +const TYPE_LABEL: Record = { + fact: "FAKT", + causal: "KAUSAL", + statistical: "STATISTIK", + quote: "ZITAT", + prediction: "PROGNOSE", + opinion: "MEINUNG", +}; + +const CHECK_ICON: Record = { + checkable: "✓", + partly_checkable: "~", + not_checkable: "✗", +}; + +function formatClaimSet( + claimSet: ClaimSet, + onlyCheckable: boolean, + model: string, + tokensIn: number, + tokensOut: number, + latencyMs: number +): string { + const filtered = onlyCheckable + ? claimSet.claims.filter((c) => c.checkability === "checkable") + : claimSet.claims; + + const checkable = filtered.filter((c) => c.checkability === "checkable"); + const partlyCheckable = filtered.filter((c) => c.checkability === "partly_checkable"); + const notCheckable = filtered.filter((c) => c.checkability === "not_checkable"); + + const lines: string[] = []; + + lines.push( + `## Claim-Extraktion: ${claimSet.total_claims} Behauptung${claimSet.total_claims !== 1 ? "en" : ""} gefunden` + + (onlyCheckable && filtered.length < claimSet.total_claims + ? ` (${filtered.length} prüfbar angezeigt)` + : "") + ); + lines.push(`Sprache: ${claimSet.text_language}`); + if (claimSet.extraction_notes) { + lines.push(`Hinweis: ${claimSet.extraction_notes}`); + } + lines.push(""); + + function renderClaims(claims: Claim[], sectionTitle: string) { + if (claims.length === 0) return; + lines.push(`**${sectionTitle} (${claims.length}):**`); + for (const c of claims) { + const icon = CHECK_ICON[c.checkability]; + const type = TYPE_LABEL[c.claim_type]; + lines.push(`\`${c.claim_id}\` ${icon} [${type}] ${c.text}`); + + const meta: string[] = []; + if (c.entities.length > 0) meta.push(`Entitäten: ${c.entities.join(", ")}`); + if (c.time_scope) meta.push(`Zeit: ${c.time_scope}`); + if (c.needs_citation) meta.push(`Zitat nötig: ja`); + if (meta.length > 0) { + lines.push(` ${meta.join(" | ")}`); + } + lines.push(""); + } + } + + renderClaims(checkable, "✓ Prüfbar"); + if (!onlyCheckable) { + renderClaims(partlyCheckable, "~ Teilweise prüfbar"); + renderClaims(notCheckable, "✗ Nicht prüfbar"); + } + + const latSec = (latencyMs / 1000).toFixed(1); + const tokenInfo = + tokensIn || tokensOut ? ` · ${tokensIn}+${tokensOut} Tokens` : ""; + lines.push(`_[Ollama: ${model}${tokenInfo} · ${latSec}s]_`); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension-Parameters (TypeBox) +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + text: Type.String({ + description: + "Der zu analysierende Text. Kann ein Artikel, Blogeintrag, Nachrichtentext oder beliebiger Fließtext sein.", + }), + onlyCheckable: Type.Optional( + Type.Boolean({ + description: + "Wenn true: nur empirisch prüfbare Claims ausgeben (checkable). Standard: false.", + }) + ), + maxClaims: Type.Optional( + Type.Number({ + description: `Maximale Anzahl Claims pro Aufruf. Standard: ${DEFAULT_MAX_CLAIMS}.`, + }) + ), + model: Type.Optional( + Type.String({ + description: `Ollama-Modell für die Extraktion. Standard: ${DEFAULT_MODEL}. Empfohlene Alternative: qwen3.5:27b für maximale Präzision.`, + }) + ), +}); + +// --------------------------------------------------------------------------- +// Pi-Extension: Default Export +// --------------------------------------------------------------------------- + +export default function claimExtractorExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "extract_claims", + label: "Claim-Extraktion", + description: + "Zerlegt einen Text in einzelne, diskrete Behauptungen (Claims) als Vorbereitung für Fact-Checking. " + + "Nutze dieses Tool wenn: ein Artikel auf Fakten geprüft werden soll, Behauptungen aus einem Text " + + "identifiziert und klassifiziert werden sollen, oder ein Verifikations-Workflow gestartet werden soll. " + + "Läuft lokal via Ollama — keine API-Kosten.", + promptGuidelines: [ + "Use extract_claims when the user wants to fact-check an article, blog post, or any text.", + "Use extract_claims before calling verify or research_web on specific claims.", + "Pass the full text as the 'text' parameter — do not summarize or shorten it first.", + "If the user only wants checkable claims, set onlyCheckable=true.", + "After extraction, ask the user which claims they want to verify, or offer to run the verifier on all checkable claims.", + "The claim_ids (c001, c002, ...) can be referenced in follow-up tool calls to the verifier.", + "Always show the full formatted output to the user, including the [Ollama: ...] cost line.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + const model = params.model ?? DEFAULT_MODEL; + const maxClaims = Math.min(params.maxClaims ?? DEFAULT_MAX_CLAIMS, 60); + const onlyCheckable = params.onlyCheckable ?? false; + + try { + const { claimSet, tokensIn, tokensOut, latencyMs } = await callOllamaClaimExtract( + params.text, + model, + maxClaims, + signal + ); + + const text = formatClaimSet( + claimSet, + onlyCheckable, + model, + tokensIn, + tokensOut, + latencyMs + ); + + return { + content: [{ type: "text", text }], + details: { + model, + totalClaims: claimSet.total_claims, + checkableClaims: claimSet.claims.filter((c) => c.checkability === "checkable").length, + textLanguage: claimSet.text_language, + tokensIn: tokensIn || null, + tokensOut: tokensOut || null, + latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { + content: [{ type: "text", text: `Fehler bei Claim-Extraktion: ${msg}` }], + }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI-Modus +// --------------------------------------------------------------------------- + +function parseCliArgs(args: string[]): { + text: string; + model: string; + maxClaims: number; + onlyCheckable: boolean; + jsonOutput: boolean; + verbose: boolean; +} { + let model = DEFAULT_MODEL; + let maxClaims = DEFAULT_MAX_CLAIMS; + let onlyCheckable = false; + let jsonOutput = false; + let verbose = false; + const textParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--model" && args[i + 1]) { + model = args[++i]; + } else if (arg === "--max-claims" && args[i + 1]) { + maxClaims = parseInt(args[++i], 10); + } else if (arg === "--only-checkable") { + onlyCheckable = true; + } else if (arg === "--json") { + jsonOutput = true; + } else if (arg === "--verbose" || arg === "-v") { + verbose = true; + } else if (!arg.startsWith("--")) { + textParts.push(arg); + } + } + + const text = textParts.join(" ").trim(); + return { text, model, maxClaims, onlyCheckable, jsonOutput, verbose }; +} + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + console.log(` +Claim-Extraktor (Ollama) — Behauptungen aus Text extrahieren + +Verwendung: + npx tsx agenten/ollama-claim-extractor.ts [Optionen] "Text..." + +Optionen: + --model Ollama-Modell (Standard: ${DEFAULT_MODEL}) + --max-claims Maximale Claims (Standard: ${DEFAULT_MAX_CLAIMS}) + --only-checkable Nur prüfbare Claims anzeigen + --json Ausgabe als reines JSON (ClaimSet) + --verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/ + --help Diese Hilfe + +Beispiele: + npx tsx agenten/ollama-claim-extractor.ts "Die Erde hat 8 Milliarden Einwohner." + npx tsx agenten/ollama-claim-extractor.ts --only-checkable "$(cat artikel.txt)" + npx tsx agenten/ollama-claim-extractor.ts --verbose "$(cat langer-artikel.txt)" + npx tsx agenten/ollama-claim-extractor.ts --model deepseek-r1:32b "..." + npx tsx agenten/ollama-claim-extractor.ts --json "..." > claims.json +`); + process.exit(0); + } + + const { text, model, maxClaims, onlyCheckable, jsonOutput, verbose } = parseCliArgs(args); + + if (!text) { + console.error("Fehler: Kein Text übergeben. Nutze --help für Hinweise."); + process.exit(1); + } + + if (!jsonOutput) { + console.error( + `\nOllama-Modell: ${model} | Max. Claims: ${maxClaims} | Nur prüfbar: ${onlyCheckable}\n` + ); + } + + const log = createLogger({ verbose }); + + try { + const { claimSet, tokensIn, tokensOut, latencyMs } = await callOllamaClaimExtract( + text, + model, + maxClaims, + undefined, + log + ); + + if (jsonOutput) { + console.log(JSON.stringify(claimSet, null, 2)); + } else { + console.log( + formatClaimSet(claimSet, onlyCheckable, model, tokensIn, tokensOut, latencyMs) + ); + } + } catch (err) { + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +// Einstiegspunkt für CLI — wird ignoriert wenn als Pi-Extension geladen +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { + runCli(); +} diff --git a/agenten/ollama-logic-editor.ts b/agenten/ollama-logic-editor.ts new file mode 100644 index 0000000..b31c8cb --- /dev/null +++ b/agenten/ollama-logic-editor.ts @@ -0,0 +1,567 @@ +/** + * ollama-logic-editor.ts + * Pi-Extension + CLI: Argumentationsanalyse via Ollama (deepseek-r1:32b) + * + * Analysiert einen Text auf: + * - Hauptthese und Unterthesen + * - Explizite Prämissen und Belege + * - Schlussfolgerungen + * - Implizite Annahmen + * - Logische Fehlschlüsse (Ad Hominem, Strohmann, etc.) + * - Verbesserungsvorschläge + * + * Routing: deepseek-r1:32b lokal (Standard) oder OpenRouter (--cloud / high complexity) + * HINWEIS: analyze_logic_llama (llama-logic-editor.ts) bevorzugen für einheitliches Backend. + * + * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink) + * Als CLI: + * npx tsx agenten/ollama-logic-editor.ts "Artikeltext..." + * npx tsx agenten/ollama-logic-editor.ts --cloud "Kontroverseller Kommentar..." + * npx tsx agenten/ollama-logic-editor.ts --json "$(cat kommentar.txt)" + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { routeModel, callOpenRouter, estimateOpenRouterCost } from "../lib/router.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +type FallacyType = + | "ad_hominem" | "straw_man" | "false_dichotomy" | "slippery_slope" + | "circular_reasoning" | "appeal_to_authority" | "hasty_generalization" + | "false_causation" | "appeal_to_emotion" | "overgeneralization" + | "cherry_picking" | "other"; + +type Severity = "minor" | "moderate" | "critical"; +type EvidenceStrength = "strong" | "moderate" | "weak"; +type OverallQuality = "strong" | "adequate" | "weak" | "flawed"; + +type ArgumentMap = { + schema_version: "1.0.0"; + thesis: string; + sub_theses: string[]; + premises: string[]; + evidence: Array<{ claim: string; supports_thesis: boolean; strength: EvidenceStrength }>; + conclusions: string[]; + implicit_assumptions: string[]; + fallacies: Array<{ + type: FallacyType; + description: string; + location: string; + severity: Severity; + }>; + revision_suggestions: string[]; + overall_quality: OverallQuality; + quality_notes: string; +}; + +type OllamaResponse = { + message?: { content?: string }; + eval_count?: number; + prompt_eval_count?: number; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434"; + +// --------------------------------------------------------------------------- +// Ollama-Schema für strukturierte Argumentationsanalyse +// --------------------------------------------------------------------------- + +const ARGUMENT_MAP_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + thesis: { type: "string" }, + sub_theses: { type: "array", items: { type: "string" } }, + premises: { type: "array", items: { type: "string" } }, + evidence: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + claim: { type: "string" }, + supports_thesis: { type: "boolean" }, + strength: { type: "string", enum: ["strong", "moderate", "weak"] }, + }, + required: ["claim", "supports_thesis", "strength"], + }, + }, + conclusions: { type: "array", items: { type: "string" } }, + implicit_assumptions: { type: "array", items: { type: "string" } }, + fallacies: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + type: { + type: "string", + enum: [ + "ad_hominem", "straw_man", "false_dichotomy", "slippery_slope", + "circular_reasoning", "appeal_to_authority", "hasty_generalization", + "false_causation", "appeal_to_emotion", "overgeneralization", + "cherry_picking", "other", + ], + }, + description: { type: "string" }, + location: { type: "string" }, + severity: { type: "string", enum: ["minor", "moderate", "critical"] }, + }, + required: ["type", "description", "location", "severity"], + }, + }, + revision_suggestions: { type: "array", items: { type: "string" } }, + overall_quality: { type: "string", enum: ["strong", "adequate", "weak", "flawed"] }, + quality_notes: { type: "string" }, + }, + required: [ + "thesis", "sub_theses", "premises", "evidence", "conclusions", + "implicit_assumptions", "fallacies", "revision_suggestions", + "overall_quality", "quality_notes", + ], +}; + +// --------------------------------------------------------------------------- +// System-Prompt +// --------------------------------------------------------------------------- + +const LOGIC_SYSTEM_PROMPT = `Du bist ein Experte für kritisches Denken, Rhetorik und formale Logik. +Antworte ausschließlich auf Deutsch. +Analysiere den folgenden Text auf seine Argumentationsstruktur. + +Extrahiere: +1. thesis: Die zentrale Hauptbehauptung als vollständiger Satz +2. sub_theses: Untergeordnete Thesen die die Hauptthese stützen +3. premises: Ausdrücklich genannte Voraussetzungen und Grundannahmen +4. evidence: Verwendete Belege (Fakten, Statistiken, Zitate, Studien) — beachte ob sie die These wirklich stützen +5. conclusions: Explizite Schlussfolgerungen die aus den Prämissen gezogen werden +6. implicit_assumptions: Nicht ausgesprochene Annahmen die das Argument voraussetzt + +Fehlschluss-Typen: +- ad_hominem: Person statt Argument angegriffen +- straw_man: Gegnerposition verzerrt dargestellt +- false_dichotomy: Falsche Zweiteilung (nur A oder B, obwohl mehr möglich) +- slippery_slope: Kettenreaktion ohne Beleg +- circular_reasoning: These wird durch sich selbst begründet +- appeal_to_authority: Autorität als einziger Beleg +- hasty_generalization: Einzelfall → Allgemeinregel +- false_causation: Korrelation als Kausalität dargestellt +- appeal_to_emotion: Emotionen statt Argumente +- overgeneralization: Zu weit gefasste Verallgemeinerung +- cherry_picking: Nur passende Fakten ausgewählt +- other: Sonstiger Fehlschluss + +Für jeden Fehlschluss: +- type: einer der oben genannten Typen +- description: Was genau ist der Fehlschluss? (1-2 Sätze, auf Deutsch) +- location: Das WÖRTLICHE ZITAT aus dem Originaltext wo der Fehlschluss vorkommt (max. 120 Zeichen, kein Feldname, kein JSON-Schlüssel) +- severity: minor/moderate/critical + +overall_quality: +- strong: Kohärentes, gut belegtes Argument mit klarer Struktur +- adequate: Akzeptable Argumentation mit kleineren Lücken +- weak: Erhebliche Mängel, Lücken überwiegen +- flawed: Fundamentale logische Fehler oder schwere Fehlschlüsse + +revision_suggestions: Konkrete, umsetzbare Verbesserungsvorschläge +quality_notes: 2-4 Sätze Begründung der Gesamtbewertung + +Antworte NUR mit dem JSON-Objekt. Kein Freitext.`; + +// --------------------------------------------------------------------------- +// Ollama-Analyse +// --------------------------------------------------------------------------- + +async function analyzeWithOllama( + text: string, + model: string, + signal?: AbortSignal +): Promise<{ map: ArgumentMap; tokensIn: number; tokensOut: number; latencyMs: number }> { + const t0 = Date.now(); + + const body = { + model, + messages: [ + { role: "system", content: LOGIC_SYSTEM_PROMPT }, + { role: "user", content: `Analysiere die Argumentationsstruktur:\n\n---\n${text}\n---` }, + ], + format: ARGUMENT_MAP_SCHEMA, + stream: false, + options: { temperature: 0.1, num_ctx: 8192 }, + }; + + const resp = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + + if (!resp.ok) { + const errText = await resp.text().catch(() => ""); + throw new Error(`Ollama Fehler ${resp.status}: ${errText}`); + } + + const data = (await resp.json()) as OllamaResponse; + const raw = data.message?.content ?? ""; + if (!raw.trim()) throw new Error("Leere Ollama-Antwort"); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Kein gültiges JSON von Ollama: ${raw.slice(0, 200)}`); + } + + const map: ArgumentMap = { schema_version: "1.0.0", ...(parsed as Omit) }; + + return { map, tokensIn: data.prompt_eval_count ?? 0, tokensOut: data.eval_count ?? 0, latencyMs: Date.now() - t0 }; +} + +// --------------------------------------------------------------------------- +// OpenRouter-Analyse (Freitext → strukturiertes Parsing) +// --------------------------------------------------------------------------- + +const OPENROUTER_LOGIC_PROMPT = `${LOGIC_SYSTEM_PROMPT} + +WICHTIG: Antworte mit einem einzigen JSON-Objekt. Kein Markdown-Wrapper, kein Freitext davor oder danach.`; + +async function analyzeWithOpenRouter( + text: string, + model: string, + signal?: AbortSignal +): Promise<{ map: ArgumentMap; costUSD: number; latencyMs: number }> { + const result = await callOpenRouter( + model, + [ + { role: "system", content: OPENROUTER_LOGIC_PROMPT }, + { role: "user", content: `Analysiere die Argumentationsstruktur:\n\n---\n${text}\n---` }, + ], + { temperature: 0.1, maxTokens: 3000, signal } + ); + + // OpenRouter gibt Freitext zurück — JSON extrahieren + const jsonMatch = result.text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error("Kein JSON in OpenRouter-Antwort gefunden"); + + let parsed: unknown; + try { + parsed = JSON.parse(jsonMatch[0]); + } catch { + throw new Error(`Ungültiges JSON von OpenRouter: ${result.text.slice(0, 200)}`); + } + + const costUSD = estimateOpenRouterCost(model, result.promptTokens, result.completionTokens); + const map: ArgumentMap = { schema_version: "1.0.0", ...(parsed as Omit) }; + + return { map, costUSD, latencyMs: result.latencyMs }; +} + +// --------------------------------------------------------------------------- +// Hauptfunktion +// --------------------------------------------------------------------------- + +type AnalysisResult = { + map: ArgumentMap; + provider: "ollama" | "openrouter"; + model: string; + costUSD: number; + latencyMs: number; +}; + +export async function analyzeLogic( + text: string, + options?: { + forceCloud?: boolean; + model?: string; + signal?: AbortSignal; + } +): Promise { + const complexity = text.length > 2000 ? "high" : "medium"; + const decision = routeModel( + options?.forceCloud ? "deep_reasoning" : "logic_analysis", + complexity + ); + const model = options?.model ?? decision.model; + + if (decision.provider === "openrouter" || options?.forceCloud) { + const { map, costUSD, latencyMs } = await analyzeWithOpenRouter(text, model, options?.signal); + return { map, provider: "openrouter", model, costUSD, latencyMs }; + } + + const { map, latencyMs } = await analyzeWithOllama(text, model, options?.signal); + return { map, provider: "ollama", model, costUSD: 0, latencyMs }; +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +const QUALITY_LABEL: Record = { + strong: "STARK", + adequate: "AUSREICHEND", + weak: "SCHWACH", + flawed: "FEHLERHAFT", +}; + +const QUALITY_ICON: Record = { + strong: "✓", + adequate: "~", + weak: "⚠", + flawed: "✗", +}; + +const FALLACY_LABEL: Record = { + ad_hominem: "Ad Hominem", + straw_man: "Strohmann", + false_dichotomy: "Falsche Dichotomie", + slippery_slope: "Schiefe Ebene", + circular_reasoning: "Zirkelschluss", + appeal_to_authority: "Autoritätsargument", + hasty_generalization: "Vorschnelle Generalisierung", + false_causation: "Falsche Kausalität", + appeal_to_emotion: "Appell an Emotionen", + overgeneralization: "Überverallgemeinerung", + cherry_picking: "Rosinenpickerei", + other: "Sonstiger Fehlschluss", +}; + +const SEVERITY_ICON: Record = { + minor: "·", + moderate: "⚠", + critical: "✗", +}; + +function formatArgumentMap(result: AnalysisResult): string { + const { map } = result; + const lines: string[] = []; + const q = map.overall_quality; + + lines.push(`## Argumentationsanalyse`); + lines.push(`**Gesamtqualität: ${QUALITY_ICON[q]} ${QUALITY_LABEL[q]}**`); + lines.push(map.quality_notes); + lines.push(""); + + lines.push(`**Hauptthese:**`); + lines.push(`> ${map.thesis}`); + lines.push(""); + + if (map.sub_theses.length > 0) { + lines.push(`**Unterthesen (${map.sub_theses.length}):**`); + map.sub_theses.forEach((t) => lines.push(`- ${t}`)); + lines.push(""); + } + + if (map.premises.length > 0) { + lines.push(`**Prämissen:**`); + map.premises.forEach((p) => lines.push(`- ${p}`)); + lines.push(""); + } + + if (map.evidence.length > 0) { + lines.push(`**Belege (${map.evidence.length}):**`); + map.evidence.forEach((e) => { + const icon = e.supports_thesis ? "✓" : "✗"; + const str = e.strength === "strong" ? "stark" : e.strength === "moderate" ? "mittel" : "schwach"; + lines.push(`${icon} [${str}] ${e.claim}`); + }); + lines.push(""); + } + + if (map.conclusions.length > 0) { + lines.push(`**Schlussfolgerungen:**`); + map.conclusions.forEach((c) => lines.push(`- ${c}`)); + lines.push(""); + } + + if (map.implicit_assumptions.length > 0) { + lines.push(`**Implizite Annahmen (${map.implicit_assumptions.length}):**`); + map.implicit_assumptions.forEach((a) => lines.push(`- _${a}_`)); + lines.push(""); + } + + if (map.fallacies.length > 0) { + lines.push(`**Fehlschlüsse (${map.fallacies.length}):**`); + map.fallacies.forEach((f) => { + lines.push(`${SEVERITY_ICON[f.severity]} **${FALLACY_LABEL[f.type]}** (${f.severity})`); + lines.push(` ${f.description}`); + lines.push(` _"${f.location}"_`); + lines.push(""); + }); + } else { + lines.push(`_Keine Fehlschlüsse erkannt._`); + lines.push(""); + } + + if (map.revision_suggestions.length > 0) { + lines.push(`**Verbesserungsvorschläge:**`); + map.revision_suggestions.forEach((s, i) => lines.push(`${i + 1}. ${s}`)); + lines.push(""); + } + + const latSec = (result.latencyMs / 1000).toFixed(1); + const costNote = result.costUSD > 0 ? ` · ~$${result.costUSD.toFixed(4)}` : " · kostenlos (lokal)"; + lines.push(`_[${result.provider === "ollama" ? "Ollama" : "OpenRouter"}: ${result.model}${costNote} · ${latSec}s]_`); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + text: Type.String({ + description: + "Der zu analysierende Text: Artikel, Blogpost, Kommentar, Essay oder Nachrichtentext. " + + "Der Text wird auf logische Struktur, Fehlschlüsse und Argumentationsqualität geprüft.", + }), + cloud: Type.Optional( + Type.Boolean({ + description: + "Wenn true: OpenRouter-Modell für tiefere Analyse verwenden (erfordert OPENROUTER_API_KEY). " + + "Standard: lokales Ollama (deepseek-r1:32b).", + }) + ), + model: Type.Optional( + Type.String({ + description: "Modell-Override. Standard wird vom Router entschieden.", + }) + ), +}); + +export default function logicEditorExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "analyze_logic", + label: "Argumentationsanalyse", + description: + "Analysiert die logische Struktur eines Texts: Hauptthese, Prämissen, Belege, " + + "Schlussfolgerungen, implizite Annahmen und logische Fehlschlüsse. " + + "Gibt konkrete Verbesserungsvorschläge und eine Qualitätsbewertung. " + + "Standard: lokal via deepseek-r1:32b. Mit cloud=true: OpenRouter-Reasoning-Modell.", + promptGuidelines: [ + "PREFER analyze_logic_llama over analyze_logic — it uses llama.cpp (unified backend).", + "Use analyze_logic (this tool) only when the user explicitly requests Ollama or OpenRouter.", + "Use analyze_logic when the user wants to check the argumentation quality of an article, comment, or essay.", + "Use analyze_logic after verify_article to get both factual AND logical quality assessment.", + "Always show the full formatted output including fallacies and revision suggestions.", + "If fallacies with severity 'critical' are found, highlight them prominently.", + "For politically or scientifically sensitive content, recommend cloud=true for deeper analysis.", + "The revision_suggestions are actionable — offer to rewrite specific sections if the user wants.", + "Combine with verify_article for a complete quality assessment: facts + logic.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + try { + const result = await analyzeLogic(params.text, { + forceCloud: params.cloud ?? false, + model: params.model, + signal, + }); + return { + content: [{ type: "text", text: formatArgumentMap(result) }], + details: { + overallQuality: result.map.overall_quality, + fallacyCount: result.map.fallacies.length, + criticalFallacies: result.map.fallacies.filter((f) => f.severity === "critical").length, + provider: result.provider, + model: result.model, + costUSD: result.costUSD || null, + latencyMs: result.latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Argumentationsanalyse fehlgeschlagen: ${msg}` }] }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help") { + console.log(` +Argumentationsanalyse — Logik, Fehlschlüsse und Verbesserungsvorschläge + +Verwendung: + npx tsx agenten/logic-editor.ts [Optionen] "Text..." + npx tsx agenten/logic-editor.ts "$(cat artikel.txt)" + +Optionen: + --cloud OpenRouter verwenden (stärker, kostenpflichtig) + --model Modell-Override + --only-fallacies Nur Fehlschlüsse ausgeben (kein vollständiger Bericht) + --json Ausgabe als JSON + --help Diese Hilfe +`); + process.exit(0); + } + + let forceCloud = false; + let model: string | undefined; + let jsonOutput = false; + let onlyFallacies = false; + const textParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--cloud") forceCloud = true; + else if (arg === "--model" && args[i + 1]) model = args[++i]; + else if (arg === "--json") jsonOutput = true; + else if (arg === "--only-fallacies") onlyFallacies = true; + else if (!arg.startsWith("--")) textParts.push(arg); + } + + const text = textParts.join(" ").trim(); + if (!text) { console.error("Fehler: Kein Text."); process.exit(1); } + + if (!jsonOutput) console.error(`\nAnalyse via ${forceCloud ? "OpenRouter" : "Ollama"}...\n`); + + try { + const result = await analyzeLogic(text, { forceCloud, model }); + + if (onlyFallacies) { + if (jsonOutput) { + console.log(JSON.stringify(result.map.fallacies, null, 2)); + } else { + const { map } = result; + if (map.fallacies.length === 0) { + console.log("Keine Fehlschlüsse erkannt."); + } else { + console.log(`## Fehlschlüsse (${map.fallacies.length})\n`); + map.fallacies.forEach((f) => { + const icon = SEVERITY_ICON[f.severity]; + const label = FALLACY_LABEL[f.type]; + console.log(`${icon} **${label}** (${f.severity})`); + console.log(` ${f.description}`); + console.log(` _"${f.location}"_\n`); + }); + const latSec = (result.latencyMs / 1000).toFixed(1); + console.log(`_[${result.provider === "ollama" ? "Ollama" : "OpenRouter"}: ${result.model} · ${latSec}s]_`); + } + } + } else { + console.log(jsonOutput ? JSON.stringify(result.map, null, 2) : formatArgumentMap(result)); + } + } catch (err) { + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) runCli(); diff --git a/agenten/ollama-verifier.ts b/agenten/ollama-verifier.ts new file mode 100644 index 0000000..eb77b81 --- /dev/null +++ b/agenten/ollama-verifier.ts @@ -0,0 +1,450 @@ +/** + * ollama-verifier.ts + * Pi-Extension + CLI: Eine einzelne Behauptung via Perplexity + Ollama verifizieren. + * + * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ollama-verifier.ts + * Nach Änderungen in Pi: /reload + * + * Als CLI: + * npx tsx agenten/ollama-verifier.ts "Die Inflationsrate betrug 2024 in Deutschland 3,2%." + * npx tsx agenten/ollama-verifier.ts --mode deep "Die Erde ist 4,6 Milliarden Jahre alt." + * npx tsx agenten/ollama-verifier.ts --model deepseek-r1:32b "..." + * npx tsx agenten/ollama-verifier.ts --json "..." (gibt VerificationResult als JSON aus) + * + * Ablauf: Perplexity-Suche → Ollama-Urteil → formatierte Ausgabe + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { searchPerplexity, formatSourcesForPrompt, type PerplexitySource } from "../lib/perplexity.js"; +import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +type VerificationStatus = + | "supported" + | "contradicted" + | "mixed" + | "insufficient_evidence" + | "needs_human_review"; + +type Confidence = "high" | "medium" | "low"; + +type VerdictRaw = { + status: VerificationStatus; + confidence: Confidence; + summary: string; + counter_evidence: string | null; + notes: string | null; + supporting_urls: string[]; +}; + +export type VerificationResult = { + claim: string; + status: VerificationStatus; + confidence: Confidence; + summary: string; + counter_evidence: string | null; + notes: string | null; + sources: PerplexitySource[]; + supporting_urls: string[]; + perplexityCostUSD: number; + latencyMs: number; + model: string; +}; + +type OllamaResponse = { + message?: { content?: string }; + eval_count?: number; + prompt_eval_count?: number; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = "qwen3.5:27b"; +const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434"; + +// --------------------------------------------------------------------------- +// JSON-Schema für Ollama Verdict-Ausgabe +// --------------------------------------------------------------------------- + +const VERDICT_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + status: { + type: "string", + enum: ["supported", "contradicted", "mixed", "insufficient_evidence", "needs_human_review"], + }, + confidence: { type: "string", enum: ["high", "medium", "low"] }, + summary: { type: "string" }, + counter_evidence: { type: ["string", "null"] }, + notes: { type: ["string", "null"] }, + supporting_urls: { type: "array", items: { type: "string" } }, + }, + required: ["status", "confidence", "summary", "counter_evidence", "notes", "supporting_urls"], +}; + +// --------------------------------------------------------------------------- +// Ollama Verdict-Synthese +// --------------------------------------------------------------------------- + +function buildVerdictSystemPrompt(): string { + return `Du bist ein erfahrener Fact-Checker. Bewerte eine Behauptung anhand bereitgestellter Webquellen. + +Bewertungsskala: +- supported: Quellen bestätigen die Behauptung klar und konsistent +- contradicted: Quellen widersprechen der Behauptung klar und substanziell +- mixed: Quellen liefern widersprüchliche Belege ODER die Behauptung ist technisch ungenau aber im Kern korrekt +- insufficient_evidence: Zu wenig oder qualitativ unzureichende Quellen für ein Urteil +- needs_human_review: Komplex, politisch heikel, veraltete Quellen, oder stark kontextabhängig + +Confidence: +- high: Quellenlage ist eindeutig und aus Primärquellen +- medium: Quellen vorhanden aber begrenzt oder sekundär +- low: Quellen sehr rar, veraltet oder widersprüchlich + +WICHTIGE REGELN für "contradicted": +- Nur bei klaren, substanziellen Fehlern verwenden: falsche Person, falsch zugeordnetes Ereignis, Zahl um mehr als 10% abweichend, grundlegend falsche Kausalität +- Gerundete oder allgemein akzeptierte Näherungswerte sind "supported" +- Zeitzonendifferenzen bei historischen Ereignissen: "supported" wenn im üblichen Kontext korrekt +- Technische Präzisierungen zu im Wesentlichen korrekten Aussagen → "mixed", nicht "contradicted" +- Im Zweifel: "mixed" statt "contradicted" + +summary: 1-3 präzise Sätze basierend auf den Quellen. Nicht spekulieren. +counter_evidence: Gegenbelege als Satz beschreiben, falls vorhanden. Sonst null. +notes: Zeitabhängigkeit, regionale Einschränkungen, Vorbehalt. Sonst null. +supporting_urls: URLs aus den Quellen die den Claim stützen (leeres Array wenn keine). + +Antworte NUR mit dem JSON-Objekt. Kein Freitext.`; +} + +function buildVerdictUserPrompt(claim: string, perplexitySummary: string, sources: PerplexitySource[], context?: string): string { + const contextBlock = context ? `\nARTIKEL-KONTEXT: "${context.slice(0, 300)}"\n` : ""; + return `ZU PRÜFENDE BEHAUPTUNG: +"${claim}" +${contextBlock} +RECHERCHE-ERGEBNIS (Perplexity): +${perplexitySummary} + +QUELLEN: +${formatSourcesForPrompt(sources, 300)} + +Bewerte die Behauptung anhand der Recherche.`; +} + +async function synthesizeVerdict( + claim: string, + perplexitySummary: string, + sources: PerplexitySource[], + model: string, + context?: string, + signal?: AbortSignal +): Promise { + const body = { + model, + messages: [ + { role: "system", content: buildVerdictSystemPrompt() }, + { role: "user", content: buildVerdictUserPrompt(claim, perplexitySummary, sources, context) }, + ], + format: VERDICT_SCHEMA, + stream: false, + options: { temperature: 0.1, num_ctx: 8192 }, + }; + + const resp = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Ollama Fehler ${resp.status}: ${text}`); + } + + const data = (await resp.json()) as OllamaResponse; + const raw = data.message?.content ?? ""; + if (!raw.trim()) throw new Error("Leere Ollama-Antwort"); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Kein gültiges JSON von Ollama: ${raw.slice(0, 200)}`); + } + + return parsed as VerdictRaw; +} + +// --------------------------------------------------------------------------- +// Hauptfunktion +// --------------------------------------------------------------------------- + +export async function verifyClaim( + claim: string, + options?: { + context?: string; + mode?: "fast" | "deep"; + model?: string; + signal?: AbortSignal; + logger?: Logger; + } +): Promise { + const t0 = Date.now(); + const model = options?.model ?? DEFAULT_MODEL; + const log = options?.logger ?? nullLogger; + + log.info("Perplexity-Suche gestartet", { claim: claim.slice(0, 80), mode: options?.mode ?? "fast" }); + const perplexityResult = await searchPerplexity(claim, { + mode: options?.mode ?? "fast", + signal: options?.signal, + }); + log.info("Perplexity abgeschlossen", { + sources: perplexityResult.sources.length, + costUSD: perplexityResult.estimatedCostUSD.toFixed(4), + }); + + log.info("Ollama-Urteil generieren...", { model }); + const verdict = await synthesizeVerdict( + claim, + perplexityResult.summary, + perplexityResult.sources, + model, + options?.context, + options?.signal + ); + log.info("Urteil erhalten", { status: verdict.status, confidence: verdict.confidence }); + + return { + claim, + status: verdict.status, + confidence: verdict.confidence, + summary: verdict.summary, + counter_evidence: verdict.counter_evidence, + notes: verdict.notes, + sources: perplexityResult.sources, + supporting_urls: verdict.supporting_urls, + perplexityCostUSD: perplexityResult.estimatedCostUSD, + latencyMs: Date.now() - t0, + model, + }; +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +const STATUS_ICON: Record = { + supported: "✓ BESTÄTIGT", + contradicted: "✗ WIDERLEGT", + mixed: "~ GEMISCHT", + insufficient_evidence: "? BELEGE UNZUREICHEND", + needs_human_review: "⚠ MENSCHLICHE PRÜFUNG NÖTIG", +}; + +const CONF_LABEL: Record = { + high: "hoch", + medium: "mittel", + low: "niedrig", +}; + +export function formatVerificationResult(result: VerificationResult): string { + const lines: string[] = []; + + lines.push(`## Verifikation`); + lines.push(`**Behauptung:** "${result.claim}"`); + lines.push(""); + lines.push(`**${STATUS_ICON[result.status]}** (Konfidenz: ${CONF_LABEL[result.confidence]})`); + lines.push(""); + lines.push(`**Begründung:** ${result.summary}`); + + if (result.counter_evidence) { + lines.push(`\n**Gegenbelege:** ${result.counter_evidence}`); + } + if (result.notes) { + lines.push(`\n**Hinweise:** ${result.notes}`); + } + + if (result.sources.length > 0) { + lines.push("\n**Quellen:**"); + result.sources.forEach((s, i) => { + const supporting = result.supporting_urls.includes(s.url) ? " ✓" : ""; + const title = s.title ?? s.url; + lines.push(`[${i + 1}]${supporting} [${title}](${s.url})`); + }); + } else { + lines.push("\n_(Keine Quellen gefunden)_"); + } + + const latSec = (result.latencyMs / 1000).toFixed(1); + lines.push(`\n_[Perplexity: ~$${result.perplexityCostUSD.toFixed(4)} | Ollama: ${result.model} | Gesamt: ${latSec}s]_`); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension: Default Export +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + claim: Type.String({ + description: + "Die zu verifizierende Behauptung als vollständiger, selbstständiger Satz. " + + "Idealerweise das Ergebnis von extract_claims (claim_id + text).", + }), + context: Type.Optional( + Type.String({ + description: + "Optionaler Kontext: kurzer Auszug aus dem Artikel, in dem die Behauptung steht. " + + "Hilft dem Fact-Checker bei mehrdeutigen Claims. Max. 300 Zeichen.", + }) + ), + mode: Type.Optional( + Type.Union([Type.Literal("fast"), Type.Literal("deep")], { + description: + "fast (Standard): sonar, für die meisten Behauptungen ausreichend. " + + "deep: sonar-pro, für komplexe, strittige oder heikle Behauptungen.", + }) + ), + model: Type.Optional( + Type.String({ + description: `Ollama-Modell für die Urteilssynthese. Standard: ${DEFAULT_MODEL}.`, + }) + ), +}); + +export default function verifierExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "verify_claim", + label: "Claim-Verifikation", + description: + "Verifiziert eine einzelne Behauptung: Perplexity-Recherche → Ollama-Urteil. " + + "Gibt Status (supported/contradicted/mixed/insufficient_evidence/needs_human_review), " + + "Konfidenz, Begründung und Quellen zurück. " + + "Nutze dieses Tool nach extract_claims um spezifische Claims zu prüfen. " + + "Kosten: ~$0.005-0.015 pro Claim (Perplexity) + lokal (Ollama).", + promptGuidelines: [ + "Use verify_claim after extract_claims to check specific claims the user wants verified.", + "Pass the full claim text from extract_claims as the 'claim' parameter.", + "Use mode=deep for complex, politically sensitive, or scientifically contested claims.", + "The 'context' parameter helps when the claim is ambiguous without its original article context.", + "Show the full formatted output including the cost/latency line.", + "If status is 'needs_human_review' or 'insufficient_evidence', clearly communicate this to the user and suggest manual checking.", + "If status is 'contradicted', always show the counter_evidence to the user.", + "For multiple claims from an extract_claims result, use verify_article instead — it is faster and cheaper.", + "IMPORTANT: Never call verify_claim for multiple claims simultaneously. Ollama processes one request at a time — parallel calls will fail with 'fetch failed'. Always verify claims one by one, sequentially.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + try { + const result = await verifyClaim(params.claim, { + context: params.context, + mode: params.mode, + model: params.model, + signal, + }); + return { + content: [{ type: "text", text: formatVerificationResult(result) }], + details: { + status: result.status, + confidence: result.confidence, + model: result.model, + sourceCount: result.sources.length, + perplexityCostUSD: result.perplexityCostUSD, + latencyMs: result.latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Verifikationsfehler: ${msg}` }] }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI-Modus +// --------------------------------------------------------------------------- + +function parseCliArgs(args: string[]): { claim: string; mode: "fast" | "deep"; model: string; jsonOutput: boolean; verbose: boolean } { + let mode: "fast" | "deep" = "fast"; + let model = DEFAULT_MODEL; + let jsonOutput = false; + let verbose = false; + const claimParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--mode" && args[i + 1]) { + const m = args[++i]; + if (m === "fast" || m === "deep") mode = m; + } else if (arg === "--model" && args[i + 1]) { + model = args[++i]; + } else if (arg === "--json") { + jsonOutput = true; + } else if (arg === "--verbose" || arg === "-v") { + verbose = true; + } else if (!arg.startsWith("--")) { + claimParts.push(arg); + } + } + + return { claim: claimParts.join(" ").trim(), mode, model, jsonOutput, verbose }; +} + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help") { + console.log(` +Claim-Verifikator (Ollama) — Eine Behauptung mit Perplexity + Ollama prüfen + +Verwendung: + npx tsx agenten/ollama-verifier.ts [Optionen] "Behauptung..." + +Optionen: + --mode fast|deep Perplexity-Modus (Standard: fast) + --model Ollama-Modell (Standard: ${DEFAULT_MODEL}) + --json Ausgabe als JSON + --help Diese Hilfe +`); + process.exit(0); + } + + const { claim, mode, model, jsonOutput, verbose } = parseCliArgs(args); + + if (!claim) { + console.error("Fehler: Kein Claim übergeben."); + process.exit(1); + } + + if (!jsonOutput) { + console.error(`\nVerifiziere: "${claim}"\nModus: ${mode} | Modell: ${model}\n`); + } + + const log = createLogger({ verbose }); + + try { + const result = await verifyClaim(claim, { mode, model, logger: log }); + if (jsonOutput) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatVerificationResult(result)); + } + } catch (err) { + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { + runCli(); +} diff --git a/agenten/ollama-verify-article.ts b/agenten/ollama-verify-article.ts new file mode 100644 index 0000000..09b1885 --- /dev/null +++ b/agenten/ollama-verify-article.ts @@ -0,0 +1,809 @@ +/** + * ollama-verify-article.ts + * Pi-Extension + CLI: Vollständige Fact-Check-Pipeline für Artikel + * + * Ablauf: + * 1. Claim-Extraktion via Ollama (lokal) + * 2. Perplexity-Recherche für alle prüfbaren Claims (parallel) + * 3. Batch-Urteilssynthese via Ollama (1 Aufruf für alle Claims) + * 4. Verifikationsbericht formatieren + * + * Als Pi-Extension: ~/.pi/agent/extensions/ollama-verify-article.ts + * Als CLI: + * npx tsx agenten/ollama-verify-article.ts "$(cat artikel.txt)" + * npx tsx agenten/ollama-verify-article.ts --mode deep --max-claims 15 "..." + * npx tsx agenten/ollama-verify-article.ts --json "..." > report.json + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { + searchPerplexity, + formatSourcesForPrompt, + type PerplexitySource, + type PerplexityResult, +} from "../lib/perplexity.js"; +import { + CLAIM_OLLAMA_SCHEMA, + callOllamaClaimExtract, + type ClaimSet, +} from "./ollama-claim-extractor.js"; +import { createLogger, nullLogger, type Logger } from "../lib/logger.js"; +import { + saveJobFile, + loadJobFile, + jobFileExists, + updateJobMeta, + getOrCreateJob, +} from "../lib/jobs.js"; +import { getCached, setCached } from "../lib/cache.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +type VerificationStatus = + | "supported" + | "contradicted" + | "mixed" + | "insufficient_evidence" + | "needs_human_review" + | "not_checkable"; + +type Confidence = "high" | "medium" | "low"; + +type VerdictItem = { + claim_id: string; + status: VerificationStatus; + confidence: Confidence; + summary: string; + counter_evidence: string | null; + notes: string | null; + supporting_urls: string[]; +}; + +type BatchVerdictRaw = { verdicts: VerdictItem[] }; + +export type VerificationReport = { + schema_version: "1.0.0"; + verified_at: string; + source_text_summary: string; + summary: string; + results: Array<{ + claim_id: string; + claim_text: string; + status: VerificationStatus; + confidence: Confidence; + summary: string; + sources: Array<{ url: string; title: string | null; supports_claim: boolean }>; + counter_evidence: string | null; + notes: string | null; + }>; + stats: Record; + totalCostUSD: number; + latencyMs: number; +}; + +type OllamaResponse = { + message?: { content?: string }; + eval_count?: number; + prompt_eval_count?: number; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const DEFAULT_MODEL = "qwen3.5:27b"; +const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434"; +const DEFAULT_MAX_CLAIMS = 15; +const MAX_PARALLEL_PERPLEXITY = 5; // gleichzeitige Perplexity-Anfragen + +// --------------------------------------------------------------------------- +// Batch-Urteilssynthese via Ollama +// --------------------------------------------------------------------------- + +const BATCH_VERDICT_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + verdicts: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + claim_id: { type: "string" }, + status: { + type: "string", + enum: [ + "supported", + "contradicted", + "mixed", + "insufficient_evidence", + "needs_human_review", + ], + }, + confidence: { type: "string", enum: ["high", "medium", "low"] }, + summary: { type: "string" }, + counter_evidence: { type: ["string", "null"] }, + notes: { type: ["string", "null"] }, + supporting_urls: { type: "array", items: { type: "string" } }, + }, + required: [ + "claim_id", + "status", + "confidence", + "summary", + "counter_evidence", + "notes", + "supporting_urls", + ], + }, + }, + }, + required: ["verdicts"], +}; + +function buildBatchVerdictPrompt( + claims: Array<{ id: string; text: string; perplexity: PerplexityResult }> +): string { + const systemPrompt = `Du bist ein erfahrener Fact-Checker. Bewerte jede Behauptung anhand der bereitgestellten Recherche-Ergebnisse. + +Status-Skala: +- supported: Quellen bestätigen klar und konsistent +- contradicted: Quellen widersprechen klar und SUBSTANZIELL +- mixed: Widersprüchliche Quellenlage ODER Behauptung technisch ungenau aber im Kern korrekt +- insufficient_evidence: Zu wenig oder schwache Quellen +- needs_human_review: Komplex, politisch heikel, stark kontextabhängig + +Confidence: high (eindeutige Primärquellen), medium (begrenzte/sekundäre Quellen), low (sehr unklar) + +WICHTIGE REGELN für "contradicted": +- Nur bei klar substanziellen Fehlern: falsche Person, Zahl >10% abweichend, falsch zugeordnetes Ereignis +- Gerundete/allgemein akzeptierte Näherungswerte → "supported" (z.B. "21 Millionen Bitcoin" ist korrekte Rundung) +- Zeitzonendifferenzen historischer Ereignisse → "supported" wenn im üblichen regionalen Kontext korrekt +- Technische Präzisierungen zu korrekten Aussagen → "mixed", nicht "contradicted" +- Im Zweifel immer "mixed" statt "contradicted" + +summary: 1-3 präzise Sätze. Nicht spekulieren. +counter_evidence: Gegenbelege als Satz, sonst null. +notes: Zeitabhängigkeit, Einschränkungen, sonst null. +supporting_urls: URLs der stützenden Quellen. + +Antworte NUR mit dem JSON-Objekt.`; + + const claimsBlock = claims + .map(({ id, text, perplexity }) => { + const sourcesFormatted = formatSourcesForPrompt(perplexity.sources, 200); + return `--- +BEHAUPTUNG ${id}: "${text}" +RECHERCHE: +${perplexity.summary} + +QUELLEN: +${sourcesFormatted || "(keine Quellen gefunden)"}`; + }) + .join("\n\n"); + + return `${systemPrompt}\n\n${claimsBlock}\n\nBewerte alle ${claims.length} Behauptungen.`; +} + +async function synthesizeBatchVerdicts( + claims: Array<{ id: string; text: string; perplexity: PerplexityResult }>, + model: string, + signal?: AbortSignal +): Promise { + if (claims.length === 0) return []; + + const prompt = buildBatchVerdictPrompt(claims); + + const body = { + model, + messages: [{ role: "user", content: prompt }], + format: BATCH_VERDICT_SCHEMA, + stream: false, + options: { + temperature: 0.1, + num_ctx: 16384, // Groß genug für viele Claims + Perplexity-Ergebnisse + }, + }; + + const resp = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Ollama Batch-Verdict Fehler ${resp.status}: ${text}`); + } + + const data = (await resp.json()) as OllamaResponse; + const raw = data.message?.content ?? ""; + if (!raw.trim()) throw new Error("Leere Ollama-Antwort für Batch-Verdicts"); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Kein gültiges JSON von Ollama: ${raw.slice(0, 300)}`); + } + + const { verdicts } = parsed as BatchVerdictRaw; + return verdicts ?? []; +} + +// --------------------------------------------------------------------------- +// Parallel-Limiter für Perplexity +// --------------------------------------------------------------------------- + +async function runWithConcurrencyLimit( + tasks: Array<() => Promise>, + limit: number +): Promise { + const results: T[] = new Array(tasks.length); + let index = 0; + + async function worker() { + while (index < tasks.length) { + const current = index++; + results[current] = await tasks[current](); + } + } + + const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker); + await Promise.all(workers); + return results; +} + +// --------------------------------------------------------------------------- +// Hauptfunktion +// --------------------------------------------------------------------------- + +export async function verifyArticle( + text: string, + options?: { + maxClaims?: number; + mode?: "fast" | "deep"; + model?: string; + signal?: AbortSignal; + onProgress?: (msg: string) => void; + logger?: Logger; + jobDir?: string; // Wenn gesetzt: persistente Zwischenergebnisse in diesem Verzeichnis + noCache?: boolean; // Cache für wiederholte Claims deaktivieren (Standard: Cache aktiv) + } +): Promise { + const t0 = Date.now(); + const model = options?.model ?? DEFAULT_MODEL; + const maxClaims = Math.min(options?.maxClaims ?? DEFAULT_MAX_CLAIMS, 20); + const mode = options?.mode ?? "fast"; + const log = options?.logger ?? nullLogger; + const jobDir = options?.jobDir; + const useCache = !(options?.noCache ?? false); + const progress = (msg: string) => { + options?.onProgress?.(msg); + log.info(msg); + }; + + log.info("ollama-verify-article gestartet", { textLength: text.length, model, maxClaims, mode, jobDir }); + + // Schritt 1: Claim-Extraktion (oder aus Job-Cache laden) + let claimSet: ClaimSet; + if (jobDir) { + const cached = loadJobFile(jobDir, "claims.json"); + if (cached) { + claimSet = cached; + const checkable = claimSet.claims.filter((c) => c.checkability === "checkable").length; + progress(`Claims aus Job geladen (${claimSet.total_claims} total, ${checkable} prüfbar) — Extraktion übersprungen.`); + log.info("Claims aus Cache geladen", { total: claimSet.total_claims }); + } else { + updateJobMeta(jobDir, { status: "extracting" }); + progress("Claims extrahieren (Ollama)..."); + const { claimSet: extracted, tokensIn, tokensOut, latencyMs: extractLatency } = await callOllamaClaimExtract( + text, model, maxClaims, options?.signal, log + ); + claimSet = extracted; + saveJobFile(jobDir, "claims.json", claimSet); + updateJobMeta(jobDir, { + status: "verifying", + steps: { + extract: { + completedAt: new Date().toISOString(), + totalClaims: claimSet.total_claims, + checkableClaims: claimSet.claims.filter((c) => c.checkability === "checkable").length, + latencyMs: extractLatency, + }, + }, + }); + log.info("Claims extrahiert + gespeichert", { total: claimSet.total_claims, tokensIn, tokensOut, latencyMs: extractLatency }); + } + } else { + progress("Claims extrahieren (Ollama)..."); + const { claimSet: extracted, tokensIn, tokensOut, latencyMs: extractLatency } = await callOllamaClaimExtract( + text, model, maxClaims, options?.signal, log + ); + claimSet = extracted; + log.info("Claims extrahiert", { total: claimSet.total_claims, tokensIn, tokensOut, latencyMs: extractLatency }); + } + + const checkableClaims = claimSet.claims.filter((c) => c.checkability === "checkable"); + const uncheckedClaims = claimSet.claims.filter((c) => c.checkability !== "checkable"); + progress( + `${claimSet.total_claims} Claims — ${checkableClaims.length} prüfbar, ` + + `${uncheckedClaims.length} nicht prüfbar.` + ); + + if (checkableClaims.length === 0) { + progress("⚠ Keine prüfbaren Claims gefunden — Verifikation nicht möglich."); + } + + // Schritt 2: Perplexity parallel (mit Limit) — mit Job-Cache + let doneCount = 0; + const total = checkableClaims.length; + + // Prüfe wie viele Claims schon gecacht sind + if (jobDir && total > 0) { + const cachedCount = checkableClaims.filter((c) => + jobFileExists(jobDir, `perplexity/${c.claim_id}.json`) + ).length; + if (cachedCount > 0) { + progress(`${cachedCount}/${total} Perplexity-Ergebnisse aus Job-Cache geladen.`); + } + } + + const perplexityTasks = checkableClaims.map((claim) => async () => { + const short = claim.text.length > 55 ? claim.text.slice(0, 52) + "..." : claim.text; + + // Job-Cache: gecachtes Ergebnis verwenden wenn vorhanden + if (jobDir) { + const cached = loadJobFile(jobDir, `perplexity/${claim.claim_id}.json`); + if (cached) { + doneCount++; + progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ (cached) "${short}"`); + return { claim, result: cached, error: null }; + } + } + + // Globaler Claim-Cache (claim-text-basiert, TTL 7 Tage) + if (useCache) { + const globalCached = getCached(claim.text); + if (globalCached) { + doneCount++; + progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ (cache) "${short}"`); + log.debug("Aus globalem Cache geladen", { claimId: claim.claim_id }); + return { claim, result: globalCached, error: null }; + } + } + + // Perplexity-Suche + try { + const result = await searchPerplexity(claim.text, { mode, signal: options?.signal }); + doneCount++; + // Globaler Cache speichern + if (useCache) setCached(claim.text, result); + // Im Job speichern bevor wir weitermachen + if (jobDir) { + saveJobFile(jobDir, `perplexity/${claim.claim_id}.json`, result); + log.debug("Perplexity-Ergebnis gecacht", { claimId: claim.claim_id }); + } + progress(`[${doneCount}/${total}] ${claim.claim_id} ✓ "${short}"`); + return { claim, result, error: null }; + } catch (err: unknown) { + doneCount++; + const errMsg = err instanceof Error ? err.message : "Perplexity-Fehler"; + progress(`[${doneCount}/${total}] ${claim.claim_id} ✗ "${short}" — ${errMsg}`); + return { + claim, + result: null as PerplexityResult | null, + error: errMsg, + }; + } + }); + + if (total > 0) progress(`Recherche läuft (${total} Claims, max. ${MAX_PARALLEL_PERPLEXITY} parallel)...`); + const perplexityOutcomes = await runWithConcurrencyLimit(perplexityTasks, MAX_PARALLEL_PERPLEXITY); + const successful = perplexityOutcomes.filter((o) => o.result !== null) as Array<{ + claim: (typeof checkableClaims)[number]; + result: PerplexityResult; + error: null; + }>; + const failed = perplexityOutcomes.filter((o) => o.error !== null); + + const totalPerplexityCost = successful.reduce((sum, o) => sum + o.result.estimatedCostUSD, 0); + + log.info("Perplexity abgeschlossen", { + successful: successful.length, + failed: failed.length, + totalCostUSD: totalPerplexityCost.toFixed(4), + }); + if (failed.length > 0) { + for (const f of failed) { + log.warn("Perplexity-Fehler", { claimId: f.claim.claim_id, error: f.error }); + } + } + + // Schritt 3: Batch-Urteilssynthese via Ollama + progress(`Urteilssynthese (Ollama, ${successful.length} Claims)...`); + const verdicts = await synthesizeBatchVerdicts( + successful.map((o) => ({ id: o.claim.claim_id, text: o.claim.text, perplexity: o.result })), + model, + options?.signal + ); + + // Schritt 4: Report zusammenbauen + const verdictMap = new Map(verdicts.map((v) => [v.claim_id, v])); + + const results: VerificationReport["results"] = [ + // Verifizierte Claims + ...successful.map((o) => { + const verdict = verdictMap.get(o.claim.claim_id); + const sources = o.result.sources.map((s) => ({ + url: s.url, + title: s.title ?? null, + supports_claim: verdict?.supporting_urls.includes(s.url) ?? false, + })); + + return { + claim_id: o.claim.claim_id, + claim_text: o.claim.text, + status: (verdict?.status ?? "insufficient_evidence") as VerificationStatus, + confidence: (verdict?.confidence ?? "low") as Confidence, + summary: verdict?.summary ?? "Keine Urteilssynthese verfügbar.", + sources, + counter_evidence: verdict?.counter_evidence ?? null, + notes: verdict?.notes ?? null, + }; + }), + // Fehlgeschlagene Perplexity-Suchen + ...failed.map((o) => ({ + claim_id: o.claim.claim_id, + claim_text: o.claim.text, + status: "insufficient_evidence" as VerificationStatus, + confidence: "low" as Confidence, + summary: `Recherche fehlgeschlagen: ${o.error}`, + sources: [], + counter_evidence: null, + notes: null, + })), + // Nicht prüfbare Claims + ...uncheckedClaims.map((c) => ({ + claim_id: c.claim_id, + claim_text: c.text, + status: "not_checkable" as VerificationStatus, + confidence: "high" as Confidence, + summary: `Nicht empirisch prüfbar (${c.claim_type}).`, + sources: [], + counter_evidence: null, + notes: null, + })), + ]; + + // Statistiken + const stats: Record = { + total: results.length, + supported: 0, + contradicted: 0, + mixed: 0, + insufficient_evidence: 0, + needs_human_review: 0, + not_checkable: 0, + }; + for (const r of results) stats[r.status] = (stats[r.status] ?? 0) + 1; + + // Zusammenfassung + const checkedCount = successful.length; + const summaryParts = [ + `${claimSet.total_claims} Claims extrahiert, ${checkedCount} recherchiert.`, + stats.supported > 0 ? `${stats.supported} bestätigt` : "", + stats.contradicted > 0 ? `${stats.contradicted} widerlegt` : "", + stats.mixed > 0 ? `${stats.mixed} gemischt` : "", + stats.needs_human_review > 0 ? `${stats.needs_human_review} → Menschliche Prüfung nötig` : "", + stats.insufficient_evidence > 0 ? `${stats.insufficient_evidence} ohne ausreichende Belege` : "", + ] + .filter(Boolean) + .join(". "); + + const totalLatencyMs = Date.now() - t0; + log.info("ollama-verify-article abgeschlossen", { + ...stats, + totalCostUSD: totalPerplexityCost.toFixed(4), + latencyMs: totalLatencyMs, + }); + + const report: VerificationReport = { + schema_version: "1.0.0", + verified_at: new Date().toISOString(), + source_text_summary: text.slice(0, 200) + (text.length > 200 ? "…" : ""), + summary: summaryParts, + results, + stats, + totalCostUSD: totalPerplexityCost, + latencyMs: totalLatencyMs, + }; + + // Job: Report + Meta speichern + if (jobDir) { + saveJobFile(jobDir, "report.json", report); + updateJobMeta(jobDir, { + status: "completed", + steps: { + verify: { + completedAt: new Date().toISOString(), + claimsVerified: successful.length, + totalCostUSD: totalPerplexityCost, + latencyMs: totalLatencyMs, + }, + }, + }); + log.info("Report in Job gespeichert", { jobDir }); + } + + return report; +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +const STATUS_ICON: Record = { + supported: "✓ BESTÄTIGT", + contradicted: "✗ WIDERLEGT", + mixed: "~ GEMISCHT", + insufficient_evidence: "? BELEGE UNZUREICHEND", + needs_human_review: "⚠ MENSCHLICHE PRÜFUNG NÖTIG", + not_checkable: "— NICHT PRÜFBAR", +}; + +function formatReport(report: VerificationReport): string { + const lines: string[] = []; + const s = report.stats; + + lines.push(`## Verifikationsbericht`); + lines.push(report.summary); + lines.push(""); + + // Prüfbare Ergebnisse gruppieren + const groups: VerificationStatus[] = [ + "supported", + "contradicted", + "mixed", + "needs_human_review", + "insufficient_evidence", + "not_checkable", + ]; + + for (const status of groups) { + const items = report.results.filter((r) => r.status === status); + if (items.length === 0) continue; + + lines.push(`**${STATUS_ICON[status]} (${items.length}):**`); + for (const item of items) { + lines.push(`\`${item.claim_id}\` "${item.claim_text}"`); + + if (item.status !== "not_checkable") { + lines.push(` → ${item.summary}`); + if (item.counter_evidence) { + lines.push(` ✗ Gegenbeleg: ${item.counter_evidence}`); + } + if (item.notes) { + lines.push(` ℹ ${item.notes}`); + } + if (item.sources.length > 0) { + const supporting = item.sources.filter((s) => s.supports_claim); + if (supporting.length > 0) { + lines.push(` Quellen: ${supporting.map((s) => `[${s.title ?? s.url}](${s.url})`).join(", ")}`); + } + } + } + lines.push(""); + } + } + + const latSec = (report.latencyMs / 1000).toFixed(0); + lines.push( + `_[Perplexity: ~$${report.totalCostUSD.toFixed(4)} | Gesamt: ${latSec}s]_` + ); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension: Default Export +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + text: Type.String({ + description: + "Der vollständige Artikel- oder Blogtext, der auf Fakten geprüft werden soll. " + + "Nicht kürzen — der Originaltext wird für die Claim-Extraktion benötigt.", + }), + maxClaims: Type.Optional( + Type.Number({ + description: `Maximale Anzahl zu prüfender Claims. Standard: ${DEFAULT_MAX_CLAIMS}. Max: 20.`, + }) + ), + mode: Type.Optional( + Type.Union([Type.Literal("fast"), Type.Literal("deep")], { + description: + "fast (Standard): sonar, kostengünstig, für normale Artikel. " + + "deep: sonar-pro, für investigative oder wissenschaftliche Inhalte.", + }) + ), + model: Type.Optional( + Type.String({ + description: `Ollama-Modell. Standard: ${DEFAULT_MODEL}.`, + }) + ), +}); + +export default function verifyArticleExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "verify_article", + label: "Artikel-Verifikation", + description: + "Vollständige Fact-Check-Pipeline für einen Artikel oder Blogtext: " + + "Claims extrahieren → Perplexity-Recherche (parallel) → Ollama-Urteil (batch) → Bericht. " + + "Effizienter als verify_claim für mehrere Claims. " + + "Typische Kosten: $0.05–0.15 für einen Artikel mit 10–15 Claims.", + promptGuidelines: [ + "Use verify_article when the user wants to fact-check an entire article, blog post, or longer text.", + "Use verify_claim instead when the user wants to check a single specific claim.", + "Pass the FULL article text — do not summarize it first.", + "Use mode=deep for scientific, medical, legal, or politically sensitive content.", + "Always show the full formatted report including the cost/latency line.", + "Highlight contradicted claims and claims needing human review prominently.", + "If needs_human_review claims exist, explain to the user that they require manual fact-checking.", + "After the report, offer to show full sources for specific claims if the user wants details.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + try { + const report = await verifyArticle(params.text, { + maxClaims: params.maxClaims, + mode: params.mode, + model: params.model, + signal, + }); + + return { + content: [{ type: "text", text: formatReport(report) }], + details: { + totalClaims: report.stats.total, + supported: report.stats.supported, + contradicted: report.stats.contradicted, + needsHumanReview: report.stats.needs_human_review, + totalCostUSD: report.totalCostUSD, + latencyMs: report.latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Artikel-Verifikation fehlgeschlagen: ${msg}` }] }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI-Modus +// --------------------------------------------------------------------------- + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help") { + console.log(` +Artikel-Verifikator — Vollständige Fact-Check-Pipeline + +Verwendung: + npx tsx agenten/ollama-verify-article.ts [Optionen] "Artikeltext..." + npx tsx agenten/ollama-verify-article.ts [Optionen] "$(cat artikel.txt)" + +Optionen: + --mode fast|deep Perplexity-Modus (Standard: fast) + --model Ollama-Modell (Standard: ${DEFAULT_MODEL}) + --max-claims Max. Claims (Standard: ${DEFAULT_MAX_CLAIMS}) + --job-id Job-Speicher aktivieren: Zwischenergebnisse nach ~/.pi/agent/jobs/_/ + Bei Unterbrechung: einfach erneut aufrufen — gecachte Ergebnisse werden wiederverwendet + --json Ausgabe als JSON + --no-cache Globalen Claim-Cache deaktivieren (erzwingt neue Perplexity-Anfragen) + --verbose, -v Ausführliche Ausgabe + Log-Datei in ~/.pi/agent/logs/ + --help Diese Hilfe + +Beispiele: + # Erstlauf mit Job-Speicher: + npx tsx agenten/ollama-verify-article.ts --job-id umerziehung "$(cat umerziehung.md)" + + # Bei Absturz: einfach nochmal aufrufen — gecachte Claims + Perplexity-Ergebnisse werden wiederverwendet: + npx tsx agenten/ollama-verify-article.ts --job-id umerziehung "$(cat umerziehung.md)" + + # Report aus Job an Writer übergeben: + npx tsx agenten/writer.ts --from-job umerziehung --style blog +`); + process.exit(0); + } + + let mode: "fast" | "deep" = "fast"; + let model = DEFAULT_MODEL; + let maxClaims = DEFAULT_MAX_CLAIMS; + let jobId: string | undefined; + let jsonOutput = false; + let verbose = false; + let noCache = false; + const textParts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--mode" && args[i + 1]) { + const m = args[++i]; + if (m === "fast" || m === "deep") mode = m; + } else if (arg === "--model" && args[i + 1]) { + model = args[++i]; + } else if (arg === "--max-claims" && args[i + 1]) { + maxClaims = parseInt(args[++i], 10); + } else if (arg === "--job-id" && args[i + 1]) { + jobId = args[++i]; + } else if (arg === "--json") { + jsonOutput = true; + } else if (arg === "--verbose" || arg === "-v") { + verbose = true; + } else if (arg === "--no-cache") { + noCache = true; + } else if (!arg.startsWith("--")) { + textParts.push(arg); + } + } + + const text = textParts.join(" ").trim(); + if (!text) { + console.error("Fehler: Kein Text übergeben."); + process.exit(1); + } + + if (!jsonOutput) { + console.error(`\nModus: ${mode} | Modell: ${model} | Max. Claims: ${maxClaims}${jobId ? ` | Job: ${jobId}` : ""}\n`); + } + + const log = createLogger({ verbose, jobId }); + const onProgress = jsonOutput + ? undefined + : (msg: string) => process.stderr.write(` ${msg}\n`); + + // Job-Verzeichnis anlegen oder vorhandenes wiederverwenden + let jobDir: string | undefined; + if (jobId) { + const { jobDir: dir, isNew } = getOrCreateJob(jobId, model); + jobDir = dir; + // Originaltext speichern (nur beim ersten Mal, nicht überschreiben) + if (isNew) { + saveJobFile(jobDir, "input.txt", text); + } + if (!jsonOutput) { + process.stderr.write(` Job: ${jobDir} (${isNew ? "neu" : "fortgesetzt"})\n\n`); + } + } + + try { + const report = await verifyArticle(text, { maxClaims, mode, model, onProgress, logger: log, jobDir, noCache }); + if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(formatReport(report)); + } + } catch (err) { + if (jobDir) { + updateJobMeta(jobDir, { status: "failed" }); + } + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { + runCli(); +} diff --git a/agenten/ollama-writer.ts b/agenten/ollama-writer.ts new file mode 100644 index 0000000..e62ef96 --- /dev/null +++ b/agenten/ollama-writer.ts @@ -0,0 +1,579 @@ +/** + * ollama-writer.ts + * Pi-Extension + CLI: Artikel schreiben via Ollama (lokales LLM) + * + * Schreibt einen Artikel NUR auf Basis von "supported"-Claims aus einem VerificationReport. + * Widerlgte, gemischte oder unzureichend belegte Claims werden automatisch ausgeschlossen. + * + * Routing: Ollama lokal (Standard) oder OpenRouter für anspruchsvollere Texte. + * HINWEIS: Für thinking-Modelle (qwen3.5:27b etc.) llama-writer.ts bevorzugen. + * + * Als Pi-Extension: ~/.pi/agent/extensions/fact-checker/ (via Symlink) + * Als CLI: + * npx tsx agenten/ollama-verify-article.ts --json "$(cat artikel.txt)" | npx tsx agenten/ollama-writer.ts --from-report + * npx tsx agenten/ollama-writer.ts --from-job --style blog + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { fileURLToPath } from "node:url"; +import { routeModel, callOpenRouter, estimateOpenRouterCost } from "../lib/router.js"; +import type { VerificationReport } from "./ollama-verify-article.js"; +import { + findJobDir, + loadJobFile, + saveJobFile, + updateJobMeta, +} from "../lib/jobs.js"; + +// --------------------------------------------------------------------------- +// Typen +// --------------------------------------------------------------------------- + +type Style = "journalistic" | "blog" | "academic" | "editorial" | "explanatory"; + +type ArticleDraft = { + schema_version: "1.0.0"; + title: string; + lead: string; + body: string; + conclusion: string | null; + style: Style; + language: string; + word_count: number; + claim_ids_used: string[]; + sources: Array<{ number: number; url: string; title: string | null; claim_id: string }>; + excluded_claims: string[]; + editorial_notes: string; +}; + +type OllamaResponse = { + message?: { content?: string }; + eval_count?: number; + prompt_eval_count?: number; +}; + +// --------------------------------------------------------------------------- +// Konfiguration +// --------------------------------------------------------------------------- + +const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434"; +const DEFAULT_MODEL = "qwen3.5:27b"; + +// --------------------------------------------------------------------------- +// Ollama-Schema für Artikel-Ausgabe +// --------------------------------------------------------------------------- + +const ARTICLE_SCHEMA = { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + lead: { type: "string" }, + body: { type: "string" }, + conclusion: { type: ["string", "null"] }, + editorial_notes: { type: "string" }, + }, + required: ["title", "lead", "body", "conclusion", "editorial_notes"], +}; + +// --------------------------------------------------------------------------- +// Prompt-Generierung +// --------------------------------------------------------------------------- + +type ClaimForWriting = { + id: string; + text: string; + sources: Array<{ url: string; title: string | null }>; +}; + +function buildWriterPrompt( + claims: ClaimForWriting[], + style: Style, + topic: string, + wordCount: number, + language: string +): string { + const styleGuide: Record = { + journalistic: + "Journalistisch: präzise, faktenbasiert, W-Fragen im Einleitungssatz, Inverted Pyramid, " + + "zitierbare Aussagen direkt belegt, keine Meinungen ohne Kennzeichnung.", + blog: + "Blog: zugänglich, ansprechend, erste Person erlaubt, direkte Ansprache des Lesers, " + + "lebendige Sprache, Zwischenüberschriften als Orientierung.", + academic: + "Akademisch: präzise Terminologie, passive Formulierungen, klare Abschnittsstruktur " + + "(Einleitung, Hauptteil, Schluss), Quellenverweise inline.", + editorial: + "Leitartikel: klare Haltung, argumentativ, Bezug zur aktuellen Debatte, " + + "stützt sich auf Fakten aber formuliert Bewertung.", + explanatory: + "Erklärstück: vereinfacht komplexe Sachverhalte, Analogien und Beispiele, " + + "schrittweise Struktur, Leserfragen antizipieren.", + }; + + const claimsText = claims + .map((c, i) => { + const srcList = c.sources + .map((s, j) => `[${i * 10 + j + 1}] ${s.title ?? s.url} (${s.url})`) + .join("\n "); + return `Claim ${c.id}: ${c.text}\n Belege:\n ${srcList || "(keine URL)"}`; + }) + .join("\n\n"); + + return `Du bist ein erfahrener ${style === "journalistic" ? "Journalist" : style === "blog" ? "Blogger" : style === "academic" ? "Wissenschaftsautor" : style === "editorial" ? "Leitartikler" : "Erklärer"}. + +Schreibe einen Artikel zum Thema: "${topic}" + +STIL: ${styleGuide[style]} +SPRACHE: ${language === "de" ? "Deutsch" : language === "en" ? "Englisch" : language} +LÄNGE: ca. ${wordCount} Wörter + +VERIFIZIERTE FAKTEN (nur diese dürfen verwendet werden): +${claimsText} + +REGELN: +- Verwende NUR die oben genannten verifizierten Claims als Faktengrundlage +- Kennzeichne jeden Fakt mit Inline-Quellenverweisen [N] aus der Beleg-Liste +- Erfinde keine Fakten, Zahlen oder Zitate +- editorial_notes: Was fehlt für einen vollständigen Artikel? Was sollte noch recherchiert werden? +- Antworte NUR mit dem JSON-Objekt. Kein Freitext davor oder danach.`; +} + +// --------------------------------------------------------------------------- +// Ollama-Aufruf +// --------------------------------------------------------------------------- + +async function writeWithOllama( + claims: ClaimForWriting[], + style: Style, + topic: string, + wordCount: number, + language: string, + model: string, + signal?: AbortSignal +): Promise<{ raw: Pick; tokensIn: number; tokensOut: number; latencyMs: number }> { + const t0 = Date.now(); + const prompt = buildWriterPrompt(claims, style, topic, wordCount, language); + + const body = { + model, + messages: [{ role: "user", content: prompt }], + format: ARTICLE_SCHEMA, + stream: false, + options: { temperature: 0.4, num_ctx: 12288 }, + }; + + const resp = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + + if (!resp.ok) { + const errText = await resp.text().catch(() => ""); + throw new Error(`Ollama Fehler ${resp.status}: ${errText}`); + } + + const data = (await resp.json()) as OllamaResponse; + const raw = data.message?.content ?? ""; + if (!raw.trim()) throw new Error("Leere Ollama-Antwort"); + + const parsed = JSON.parse(raw) as Pick; + return { raw: parsed, tokensIn: data.prompt_eval_count ?? 0, tokensOut: data.eval_count ?? 0, latencyMs: Date.now() - t0 }; +} + +// --------------------------------------------------------------------------- +// OpenRouter-Aufruf +// --------------------------------------------------------------------------- + +async function writeWithOpenRouter( + claims: ClaimForWriting[], + style: Style, + topic: string, + wordCount: number, + language: string, + model: string, + signal?: AbortSignal +): Promise<{ raw: Pick; costUSD: number; latencyMs: number }> { + const prompt = buildWriterPrompt(claims, style, topic, wordCount, language); + + const result = await callOpenRouter( + model, + [{ role: "user", content: prompt + "\n\nAntworte mit einem einzigen JSON-Objekt ohne Markdown-Wrapper." }], + { temperature: 0.4, maxTokens: 4000, signal } + ); + + const jsonMatch = result.text.match(/\{[\s\S]*\}/); + if (!jsonMatch) throw new Error("Kein JSON in OpenRouter-Antwort"); + + const parsed = JSON.parse(jsonMatch[0]) as Pick; + const costUSD = estimateOpenRouterCost(model, result.promptTokens, result.completionTokens); + + return { raw: parsed, costUSD, latencyMs: result.latencyMs }; +} + +// --------------------------------------------------------------------------- +// Quellenverzeichnis aufbauen +// --------------------------------------------------------------------------- + +function buildSourceIndex(claims: ClaimForWriting[]): Array<{ number: number; url: string; title: string | null; claim_id: string }> { + const sources: Array<{ number: number; url: string; title: string | null; claim_id: string }> = []; + let n = 1; + for (const c of claims) { + for (const s of c.sources) { + sources.push({ number: n++, url: s.url, title: s.title, claim_id: c.id }); + } + } + return sources; +} + +// --------------------------------------------------------------------------- +// Hauptfunktion +// --------------------------------------------------------------------------- + +export type WriteResult = { + draft: ArticleDraft; + provider: "ollama" | "openrouter"; + model: string; + costUSD: number; + latencyMs: number; +}; + +export async function writeFromReport( + report: VerificationReport, + options?: { + style?: Style; + topic?: string; + wordCount?: number; + language?: string; + cloud?: boolean; + model?: string; + signal?: AbortSignal; + } +): Promise { + const style = options?.style ?? "journalistic"; + const wordCount = options?.wordCount ?? 400; + const language = options?.language ?? "de"; + + // Nur "supported" Claims verwenden + const supported = report.results.filter((r) => r.status === "supported"); + const excluded = report.results.filter((r) => r.status !== "supported").map((r) => r.claim_id); + + if (supported.length === 0) { + throw new Error("Keine verifizierten (supported) Claims im Report — kein Artikel möglich."); + } + + // Topic aus dem Report ableiten wenn nicht angegeben + const topic = options?.topic ?? report.source_text_summary ?? "Artikel"; + + // Claims für Writer aufbereiten + const claims: ClaimForWriting[] = supported.map((r) => ({ + id: r.claim_id, + text: r.claim_text, + sources: r.sources + .filter((s) => s.supports_claim) + .map((s) => ({ url: s.url, title: s.title })), + })); + + // Ohne explizites --cloud immer lokal (complexity "low" → Ollama im Router) + const decision = routeModel( + options?.cloud ? "deep_reasoning" : "article_writing", + options?.cloud ? "medium" : "low" + ); + const model = options?.model ?? decision.model; + + let raw: Pick; + let costUSD = 0; + let latencyMs = 0; + let provider: "ollama" | "openrouter"; + + if (decision.provider === "openrouter" || options?.cloud) { + const result = await writeWithOpenRouter(claims, style, topic, wordCount, language, model, options?.signal); + raw = result.raw; + costUSD = result.costUSD; + latencyMs = result.latencyMs; + provider = "openrouter"; + } else { + const result = await writeWithOllama(claims, style, topic, wordCount, language, model, options?.signal); + raw = result.raw; + latencyMs = result.latencyMs; + provider = "ollama"; + } + + const sources = buildSourceIndex(claims); + const wordCountActual = (raw.lead + " " + raw.body + " " + (raw.conclusion ?? "")) + .split(/\s+/).filter(Boolean).length; + + const draft: ArticleDraft = { + ...raw, + schema_version: "1.0.0" as const, + style, + language, + word_count: wordCountActual, + claim_ids_used: claims.map((c) => c.id), + sources, + excluded_claims: excluded, + editorial_notes: raw.editorial_notes, + }; + + return { draft, provider, model, costUSD, latencyMs }; +} + +// --------------------------------------------------------------------------- +// Formatierung +// --------------------------------------------------------------------------- + +export function formatDraft(result: WriteResult): string { + const { draft } = result; + const lines: string[] = []; + + lines.push(`# ${draft.title}`); + lines.push(""); + lines.push(`_${draft.lead}_`); + lines.push(""); + lines.push(draft.body); + + if (draft.conclusion) { + lines.push(""); + lines.push("---"); + lines.push(draft.conclusion); + } + + if (draft.sources.length > 0) { + lines.push("\n**Quellen:**"); + draft.sources.forEach((s) => { + const title = s.title ?? s.url; + lines.push(`[${s.number}] [${title}](${s.url})`); + }); + } + + if (draft.excluded_claims.length > 0) { + lines.push(`\n_${draft.excluded_claims.length} Claim(s) ausgeschlossen (nicht verifiziert): ${draft.excluded_claims.join(", ")}_`); + } + + if (draft.editorial_notes) { + lines.push(`\n**Redaktionshinweise:** ${draft.editorial_notes}`); + } + + const latSec = (result.latencyMs / 1000).toFixed(1); + const costNote = result.costUSD > 0 ? ` · ~$${result.costUSD.toFixed(4)}` : " · kostenlos (lokal)"; + lines.push(`\n_[${result.provider === "ollama" ? "Ollama" : "OpenRouter"}: ${result.model} · ${draft.word_count} Wörter${costNote} · ${latSec}s]_`); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Pi-Extension +// --------------------------------------------------------------------------- + +const PARAMS = Type.Object({ + reportJson: Type.String({ + description: + "JSON-String eines VerificationReport (Ausgabe von verify_article --json oder verify_article). " + + "Nur 'supported'-Claims werden für den Artikel verwendet.", + }), + topic: Type.Optional( + Type.String({ description: "Artikelthema / Überschrift. Standard: wird aus dem Report abgeleitet." }) + ), + style: Type.Optional( + Type.Union( + [ + Type.Literal("journalistic"), + Type.Literal("blog"), + Type.Literal("academic"), + Type.Literal("editorial"), + Type.Literal("explanatory"), + ], + { description: "Schreibstil. Standard: journalistic." } + ) + ), + wordCount: Type.Optional( + Type.Number({ description: "Ziel-Wortanzahl. Standard: 400." }) + ), + language: Type.Optional( + Type.String({ description: "Sprache (ISO 639-1). Standard: de." }) + ), + cloud: Type.Optional( + Type.Boolean({ description: "OpenRouter-Modell verwenden (besserer Stil, kostenpflichtig)." }) + ), + model: Type.Optional( + Type.String({ description: "Modell-Override." }) + ), +}); + +export default function writerExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "write_article", + label: "Artikel schreiben", + description: + "Schreibt einen Artikel ausschließlich auf Basis verifizierter Claims aus einem VerificationReport. " + + "Widerlgte, gemischte oder nicht belegte Claims werden automatisch ausgeschlossen. " + + "Verwendet den vollständigen Workflow: verify_article → write_article. " + + "Kosten: lokal kostenlos (Ollama) oder gering (OpenRouter mit cloud=true).", + promptGuidelines: [ + "PREFER write_article_llama over write_article — it uses llama.cpp (no Ollama timeout issues).", + "Use write_article (this tool) only when the user explicitly requests Ollama or OpenRouter.", + "Use write_article after verify_article to generate a fact-checked article draft.", + "Always pass the full JSON output of verify_article as 'reportJson'.", + "Ask the user for the desired style (journalistic, blog, academic, editorial, explanatory) if not specified.", + "Show the full formatted draft including sources and editorial notes.", + "Point out excluded claims to the user — these may be important context that was removed.", + "If editorial_notes mention missing information, suggest running additional research.", + "For high-quality output (interviews, feature articles), recommend cloud=true.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + try { + const report = JSON.parse(params.reportJson) as VerificationReport; + const result = await writeFromReport(report, { + style: params.style, + topic: params.topic, + wordCount: params.wordCount, + language: params.language, + cloud: params.cloud, + model: params.model, + signal, + }); + return { + content: [{ type: "text", text: formatDraft(result) }], + details: { + wordCount: result.draft.word_count, + claimsUsed: result.draft.claim_ids_used.length, + claimsExcluded: result.draft.excluded_claims.length, + provider: result.provider, + costUSD: result.costUSD || null, + latencyMs: result.latencyMs, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Artikelgenerierung fehlgeschlagen: ${msg}` }] }; + } + }, + }); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function runCli() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help") { + console.log(` +Artikel-Writer — Schreibt Artikel auf Basis verifizierter Claims + +Verwendung: + # Via Pipe (kein Job-Speicher): + npx tsx agenten/verify-article.ts --json "..." | npx tsx agenten/writer.ts --from-report + + # Via Job-Speicher (empfohlen): + npx tsx agenten/verify-article.ts --job-id umerziehung "$(cat artikel.txt)" + npx tsx agenten/writer.ts --from-job umerziehung --style blog + +Optionen: + --from-report Lese VerificationReport von stdin (JSON) + --from-job Lese report.json aus Job ~/.pi/agent/jobs/_/ + Speichert article.md automatisch zurück in den Job + --style journalistic|blog|academic|editorial|explanatory (Standard: journalistic) + --topic Artikelthema + --words Ziel-Wortanzahl (Standard: 400) + --lang Sprache (Standard: de) + --cloud OpenRouter verwenden + --model Modell-Override + --json Ausgabe als JSON + --help Diese Hilfe +`); + process.exit(0); + } + + let fromReport = false; + let fromJobSlug: string | undefined; + let style: Style = "journalistic"; + let topic: string | undefined; + let wordCount = 400; + let language = "de"; + let cloud = false; + let model: string | undefined; + let jsonOutput = false; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--from-report") fromReport = true; + else if (arg === "--from-job" && args[i + 1]) fromJobSlug = args[++i]; + else if (arg === "--style" && args[i + 1]) style = args[++i] as Style; + else if (arg === "--topic" && args[i + 1]) topic = args[++i]; + else if (arg === "--words" && args[i + 1]) wordCount = parseInt(args[++i], 10); + else if (arg === "--lang" && args[i + 1]) language = args[++i]; + else if (arg === "--cloud") cloud = true; + else if (arg === "--model" && args[i + 1]) model = args[++i]; + else if (arg === "--json") jsonOutput = true; + } + + let report: VerificationReport; + let jobDir: string | undefined; + + if (fromJobSlug) { + // Report aus Job-Speicher laden + const dir = findJobDir(fromJobSlug); + if (!dir) { + console.error(`Fehler: Kein Job mit Slug "${fromJobSlug}" gefunden in ~/.pi/agent/jobs/`); + console.error("Tipp: Zuerst verify-article.ts --job-id ausführen."); + process.exit(1); + } + jobDir = dir; + const loaded = loadJobFile(dir, "report.json"); + if (!loaded) { + console.error(`Fehler: Kein report.json in Job ${dir}`); + console.error("Tipp: verify-article.ts --job-id muss zuerst abgeschlossen werden."); + process.exit(1); + } + report = loaded; + if (!jsonOutput) console.error(`\nJob: ${dir}\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`); + } else if (fromReport) { + // Report von stdin lesen + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + const input = Buffer.concat(chunks).toString("utf-8").trim(); + if (!input) { console.error("Fehler: Kein Input von stdin."); process.exit(1); } + report = JSON.parse(input) as VerificationReport; + if (!jsonOutput) console.error(`\nSchreibe ${style}-Artikel (${wordCount} Wörter, ${language})...\n`); + } else { + console.error("Fehler: --from-report oder --from-job erforderlich."); + process.exit(1); + } + + try { + const result = await writeFromReport(report, { style, topic, wordCount, language, cloud, model }); + + // Im Job speichern wenn --from-job verwendet wurde + if (jobDir) { + saveJobFile(jobDir, "article.md", formatDraft(result)); + updateJobMeta(jobDir, { + status: "completed", + steps: { + write: { + completedAt: new Date().toISOString(), + style, + wordCount: result.draft.word_count, + provider: result.provider, + costUSD: result.costUSD, + }, + }, + }); + if (!jsonOutput) process.stderr.write(`\n Artikel in Job gespeichert: ${jobDir}/article.md\n`); + } + + console.log(jsonOutput ? JSON.stringify(result.draft, null, 2) : formatDraft(result)); + } catch (err) { + if (jobDir) updateJobMeta(jobDir, { status: "failed" }); + console.error("Fehler:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) runCli(); diff --git a/agenten/research-web.ts b/agenten/research-web.ts new file mode 100644 index 0000000..ac4c187 --- /dev/null +++ b/agenten/research-web.ts @@ -0,0 +1,431 @@ +/** + * research-web.ts + * Pi-Extension: Web-Recherche via Perplexity Sonar + * + * Platzieren in: ~/.pi/agent/extensions/research-web.ts + * Nach Änderungen in Pi: /reload + * + * Kostenstruktur (Stand April 2026): + * sonar: 1 USD/M Input + 1 USD/M Output + 0.005 USD/web_search + * sonar-pro: 3 USD/M Input + 15 USD/M Output + 0.005 USD/web_search + + Übersicht aller drei Contexts: Zusatz im Prompt: "Nutze context= . + + ┌─────────┬────────────────┬──────────────────────────────────────────────────┬───────────────────────────────────┐ + │ context │ Modell-Default │ Format │ Optimal für │ + ├─────────┼────────────────┼──────────────────────────────────────────────────┼───────────────────────────────────┤ + │ facts │ sonar/fast │ Verbatim + [N] inline │ Presse, Faktencheck (default) │ + ├─────────┼────────────────┼──────────────────────────────────────────────────┼───────────────────────────────────┤ + │ code │ sonar/fast │ Snippet unter jeder Quelle │ APIs, Libraries, Doku │ + ├─────────┼────────────────┼──────────────────────────────────────────────────┼───────────────────────────────────┤ + │ legal │ sonar-pro/deep │ Verbatim + [N] + Paragraf + Snippet + Disclaimer │ Gesetze, DSGVO, EU-Recht, Urteile │ + └─────────┴────────────────┴──────────────────────────────────────────────────┴───────────────────────────────────┘ + + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +const PARAMS = Type.Object({ + query: Type.String({ description: "Die Recherchefrage auf Deutsch oder Englisch" }), + recency: Type.Optional( + Type.String({ + description: "Aktualitätsfilter: day, week, month oder year. Weglassen = kein Filter", + }) + ), + allowWikipedia: Type.Optional( + Type.Boolean({ + description: + "Wikipedia als Quelle erlauben. Nur setzen wenn der User das explizit anfordert. Standard: false", + }) + ), + mode: Type.Optional( + Type.String({ + description: + "fast (Standard): sonar, kostengünstig, für die meisten Anfragen. " + + "deep: sonar-pro, für komplexe, mehrstufige oder heikle Recherchen.", + }) + ), + context: Type.Optional( + Type.String({ + description: + "facts (Standard): Presse/Fakten-Recherche — Inline-Zitierungen [1][2][3] im Text, verbatim ausgeben. " + + "code: Programmier-Dokumentation — Quellen mit relevantem Textauszug (Snippet). " + + "legal: Gesetze/Urteile/Verordnungen — offizielle Quellen, Paragrafangaben, Gesetzestext als Snippet. Nutzt automatisch mode=deep.", + }) + ), +}); + +type ResearchSource = { url: string; title?: string; snippet?: string }; + +type PerplexityResponse = { + model?: string; + citations?: string[]; + search_results?: Array<{ url?: string; title?: string; snippet?: string }>; + choices?: Array<{ message?: { content?: string } }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + search_queries?: number; + }; +}; + +// Kostenmodell (USD pro 1 Mio Tokens) +const PRICING = { + sonar: { inputPerM: 1, outputPerM: 1 }, + "sonar-pro": { inputPerM: 3, outputPerM: 15 }, +} as const; +const SEARCH_COST_PER_CALL = 0.005; + +class RetryableError extends Error { + constructor(message: string) { + super(message); + this.name = "RetryableError"; + } +} + +function stripQuotes(s: string): string { + return s.replace(/^["']+|["']+$/g, "").trim(); +} + +function normalizeMode(raw: string | undefined, fallback: "fast" | "deep"): "fast" | "deep" { + if (!raw) return fallback; + const v = stripQuotes(raw).toLowerCase(); + return v === "deep" ? "deep" : "fast"; +} + +function normalizeContext(raw: string | undefined): "facts" | "code" | "legal" { + if (!raw) return "facts"; + const v = stripQuotes(raw).toLowerCase(); + if (v === "code") return "code"; + if (v === "legal") return "legal"; + return "facts"; +} + +function normalizeRecency(raw: string | undefined): "day" | "week" | "month" | "year" | undefined { + if (!raw) return undefined; + const v = stripQuotes(raw).toLowerCase() as "day" | "week" | "month" | "year"; + return ["day", "week", "month", "year"].includes(v) ? v : undefined; +} + +async function sleep(ms: number) { + await new Promise((r) => setTimeout(r, ms)); +} + +function estimateCostUSD( + model: string, + promptTokens: number, + completionTokens: number, + searchQueries: number +): number { + const p = PRICING[model as keyof typeof PRICING] ?? PRICING["sonar"]; + const tokenCost = + (promptTokens / 1_000_000) * p.inputPerM + (completionTokens / 1_000_000) * p.outputPerM; + const searchCost = searchQueries * SEARCH_COST_PER_CALL; + return tokenCost + searchCost; +} + +function buildSystemPrompt(context: "facts" | "code" | "legal"): string { + if (context === "code") { + return ( + "Du bist ein Recherche-Tool für Softwareentwickler. " + + "Antworte knapp und präzise auf Deutsch oder Englisch. " + + "Fokussiere auf die technisch relevante Information und benenne, welche Dokumentation die Frage beantwortet." + ); + } + if (context === "legal") { + return ( + "Du bist ein juristisches Recherche-Tool. " + + "Antworte sachlich und präzise auf Deutsch. " + + "Fokussiere ausschließlich auf offizielle Rechtsquellen: Gesetze, Verordnungen, EU-Recht, amtliche Bekanntmachungen und Gerichtsurteile. " + + "Nenne Gesetz und Paragraf direkt im Text (z.B. § 13 DSGVO, Art. 5 GG). " + + "Setze Inline-Zitierungen [1][2][3] hinter jeden Satz mit Rechtsgrundlage. " + + "Weise ausdrücklich auf relevante Rechtsänderungen oder Ausnahmen hin. " + + "Keine Rechtsberatung — nur Darstellung der Rechtslage." + ); + } + return ( + "Du bist ein Recherche-Tool für einen KI-Agenten. " + + "Antworte sachlich und prägnant auf Deutsch. " + + "Setze Inline-Zitierungen [1][2][3] direkt hinter jeden Satz, den du auf Quellen stützt. " + + "Begrenze die Antwort auf das Wesentliche." + ); +} + +async function callPerplexity( + query: string, + recency: string | undefined, + allowWikipedia: boolean | undefined, + context: "facts" | "code" | "legal", + model: "sonar" | "sonar-pro", + contextSize: "low" | "high", + maxTokens: number, + apiKey: string, + signal: AbortSignal +): Promise { + const body: Record = { + model, + messages: [ + { + role: "system", + content: buildSystemPrompt(context), + }, + { + role: "user", + content: `Recherchefrage: ${query}\n\nKurze, neutrale Zusammenfassung der wichtigsten Fakten.`, + }, + ], + max_tokens: maxTokens, + temperature: 0.2, + web_search_options: { + search_context_size: contextSize, + }, + }; + + if (recency) { + body.search_recency_filter = recency; + } + + // Wikipedia nur auf explizite Anforderung — standardmäßig ausschließen + if (!allowWikipedia) { + body.search_domain_filter = [ + "-en.wikipedia.org", + "-de.wikipedia.org", + "-simple.wikipedia.org", + ]; + } + + const resp = await fetch("https://api.perplexity.ai/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + // Nur bei transienten Fehlern retryen — 4xx-Konfigurationsfehler nicht wiederholen + if (resp.status === 429 || resp.status >= 500) { + throw new RetryableError(`Perplexity API Fehler ${resp.status}: ${text}`); + } + throw new Error(`Perplexity API Fehler ${resp.status}: ${text}`); + } + + return (await resp.json()) as PerplexityResponse; +} + +function dedupeSources(sources: ResearchSource[]): ResearchSource[] { + const seen = new Set(); + const out: ResearchSource[] = []; + for (const s of sources) { + if (!s.url || seen.has(s.url)) continue; + seen.add(s.url); + out.push(s); + } + return out; +} + +function parseSources(data: PerplexityResponse, withSnippets: boolean, maxSources = 8): ResearchSource[] { + const fromSearchResults = + data.search_results + ?.filter((r) => !!r?.url) + .map((r) => ({ + url: r.url!, + title: r.title, + snippet: withSnippets ? r.snippet : undefined, + })) ?? []; + + if (fromSearchResults.length > 0) { + return dedupeSources(fromSearchResults).slice(0, maxSources); + } + + return dedupeSources( + data.citations + ?.filter((u) => typeof u === "string" && /^https?:\/\//.test(u)) + .map((url) => ({ url })) ?? [] + ).slice(0, maxSources); +} + +function formatCostLine( + model: string, + mode: string, + promptTokens: number, + completionTokens: number, + estimatedCostUSD: number +): string { + const tokenInfo = + promptTokens || completionTokens ? ` · ${promptTokens}+${completionTokens} Tokens` : ""; + return `_[Perplexity: ${model}/${mode}${tokenInfo} · ~$${estimatedCostUSD.toFixed(4)}]_`; +} + +function formatSourcesFacts(sources: ResearchSource[]): string[] { + return sources.map((s, i) => { + const num = `[${i + 1}]`; + return s.title ? `${num} [${s.title}](${s.url})` : `${num} ${s.url}`; + }); +} + +function formatSourcesWithSnippets(sources: ResearchSource[], snippetLen = 200): string[] { + const lines: string[] = []; + sources.forEach((s, i) => { + const num = `[${i + 1}]`; + const titleLine = s.title ? `${num} [${s.title}](${s.url})` : `${num} ${s.url}`; + lines.push(titleLine); + if (s.snippet) { + const trimmed = s.snippet.length > snippetLen + ? s.snippet.slice(0, snippetLen - 3) + "…" + : s.snippet; + lines.push(` > "${trimmed}"`); + } + lines.push(""); + }); + return lines; +} + +function formatResult( + summary: string, + sources: ResearchSource[], + context: "facts" | "code" | "legal", + costLine: string +): string { + const lines: string[] = []; + + if (context === "facts" || context === "legal") { + // Verbatim-Wrapper: Agent soll diesen Block unverändert ausgeben + const label = context === "legal" + ? "⚠️ RECHTLICHE RECHERCHE — Bitte unverändert und vollständig ausgeben (keine Rechtsberatung):\n" + : "⚠️ RECHERCHE-ERGEBNIS — Bitte unverändert und vollständig ausgeben:\n"; + lines.push(label); + lines.push(summary); + } else { + lines.push(summary); + } + + if (sources.length > 0) { + lines.push("\n**Quellen:**"); + if (context === "code") { + lines.push(...formatSourcesWithSnippets(sources, 200)); + } else if (context === "legal") { + // Legal: längere Snippets (300 Z.) um Gesetzestext lesbar zu machen + lines.push(...formatSourcesWithSnippets(sources, 300)); + } else { + lines.push(...formatSourcesFacts(sources)); + } + } else { + lines.push("\n_(Keine Quellen im Response enthalten)_"); + } + + lines.push(`\n${costLine}`); + + return lines.join("\n"); +} + +export default function researchWebExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "research_web", + label: "Web-Recherche", + description: + "Recherchiert live im Internet via Perplexity Sonar. " + + "Nutze dieses Tool wenn: aktuelle Fakten, Preise, Versionen, News, Gesetze, " + + "Produktänderungen oder unsichere/veraltete Informationen gefragt sind. " + + "Nicht nutzen für reine Logik, Mathematik oder stabile Grundlagen.", + promptGuidelines: [ + "Use research_web when the question requires up-to-date or potentially outdated information.", + "Do NOT use research_web for stable knowledge, math, or logic questions.", + "Default to mode=fast. Use mode=deep only for complex multi-part questions, legal/medical/financial topics, or when fast results are clearly insufficient.", + "Default to context=facts. Use context=code for programming/API/documentation questions. Use context=legal for questions about laws, regulations, EU law, court rulings, or compliance.", + "For context=facts: Output the research result VERBATIM and COMPLETE — do not rewrite, paraphrase, or restructure. Preserve all [N] inline citation markers exactly where they appear in the text.", + "For context=code: Show each source with its indented snippet (> \"...\") exactly as provided — this saves the user from opening each URL.", + "For context=legal: Output the result VERBATIM. Always add a disclaimer that this is not legal advice. Preserve paragraph references (§ 13 DSGVO etc.) and [N] inline citations exactly.", + "Always include the complete numbered **Quellen:** section verbatim from the tool result, with all [N] numbers and URLs as clickable links.", + "Always include the cost line (the italicized [Perplexity: ...] line) verbatim at the end of your response.", + "If research_web returns no sources, flag the answer as potentially uncertain.", + "Set allowWikipedia=true ONLY if the user explicitly asks to use Wikipedia as a source.", + ], + parameters: PARAMS, + async execute(_toolCallId, params, signal) { + const apiKey = process.env.PERPLEXITY_API_KEY; + if (!apiKey) { + return { + content: [{ type: "text", text: "Fehler: PERPLEXITY_API_KEY ist nicht gesetzt." }], + }; + } + + const context = normalizeContext(params.context); + // legal braucht immer deep — Gesetze erfordern gründliche Recherche + const mode = normalizeMode(params.mode, context === "legal" ? "deep" : "fast"); + const recency = normalizeRecency(params.recency); + const model: "sonar" | "sonar-pro" = mode === "deep" ? "sonar-pro" : "sonar"; + const contextSize: "low" | "high" = mode === "deep" ? "high" : "low"; + const maxTokens = mode === "deep" ? 600 : 300; + + const maxAttempts = 3; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const data = await callPerplexity( + params.query, + recency, + params.allowWikipedia, + context, + model, + contextSize, + maxTokens, + apiKey, + signal + ); + const summary = data.choices?.[0]?.message?.content?.trim() ?? ""; + + if (!summary) { + throw new Error("Leere Antwort von Perplexity erhalten"); + } + + const sources = parseSources(data, context === "code" || context === "legal"); + + const usage = data.usage ?? {}; + const promptTokens = usage.prompt_tokens ?? 0; + const completionTokens = usage.completion_tokens ?? 0; + const searchQueries = usage.search_queries ?? 0; + const finalModel = data.model ?? model; + const estimatedCostUSD = Number( + estimateCostUSD(finalModel, promptTokens, completionTokens, searchQueries).toFixed(6) + ); + + const costLine = formatCostLine(finalModel, mode, promptTokens, completionTokens, estimatedCostUSD); + const text = formatResult(summary, sources, context, costLine); + + return { + content: [{ type: "text", text }], + details: { + model: finalModel, + mode, + context, + sourceCount: sources.length, + promptTokens: promptTokens || null, + completionTokens: completionTokens || null, + totalTokens: usage.total_tokens ?? null, + searchQueries: searchQueries || null, + estimatedCostUSD, + }, + }; + } catch (err) { + lastError = err; + // Nur bei transienten Fehlern warten und nochmals versuchen + if (err instanceof RetryableError && attempt < maxAttempts) { + await sleep(400 * 2 ** (attempt - 1)); + } else { + break; + } + } + } + + const msg = lastError instanceof Error ? lastError.message : "Unbekannter Fehler"; + return { content: [{ type: "text", text: `Recherchefehler: ${msg}` }] }; + }, + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..75736a4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,255 @@ +# ARCHITECTURE.md — System-Architektur + +**Projekt:** Pi Text-Agent +**Stand:** 2026-04-16 (nach P3-Features: Claim-Cache, Testkorpus, Test-Runner) + +--- + +## Überblick + +Pipeline aus spezialisierten Agenten für: +1. **Webrecherche** — Perplexity Sonar API +2. **Claim-Extraktion** — Fakten aus Freitext (mit automatischem Chunking für lange Texte) +3. **Fact-Checking** — Verifikation einzelner Claims via Perplexity + Ollama +4. **Artikelschreiben** — Nur aus verifizierten Facts +5. **Argumentationsanalyse** — Fehlschlüsse + Qualitätsbewertung + +Jeder Agent: Pi-Extension + CLI-Tool. Persistenz via Job-Speicher (`lib/jobs.ts`). + +--- + +## Datenfluß + +``` + Freitext / Artikel + │ + ┌──────▼──────┐ + │ollama-claim-extractor│ Ollama: qwen3.5:27b + │ (Chunking) │ Texte >4000 Zeichen → Chunks + └──────┬──────┘ → claims.json (Job-Cache) + │ ClaimSet (JSON) + ┌──────▼──────┐ + │verify-article│ Perplexity (parallel, max 5) + │ (Orchestrator)│ → perplexity/.json (Job-Cache) + │ │ Ollama-Batch-Verdict (1 Call) + └──────┬──────┘ → report.json (Job-Cache) + │ VerificationReport (JSON) + ┌────────────┼────────────┐ + │ │ + ┌──────▼──────┐ ┌───────▼──────┐ + │ writer │ │ logic-editor │ + │ (--from-job)│ │ │ + └──────┬──────┘ └───────┬──────┘ + │ article.md (Job-Cache) │ ArgumentMap + ▼ ▼ + Fertiggestellter Argumentkarte + + Artikel mit Quellen Fehlschluss-Liste +``` + +--- + +## Job-Speicher-Datenfluß + +``` +verify-article.ts --job-id + │ + ├─ Schritt 1: Claims extrahieren + │ → ~/.pi/agent/jobs/_/input.txt + │ → ~/.pi/agent/jobs/_/claims.json + │ → meta.json: status="verifying" + │ + ├─ Schritt 2: Perplexity (pro Claim) + │ → ~/.pi/agent/jobs/_/perplexity/c001.json + │ → ~/.pi/agent/jobs/_/perplexity/c002.json ... + │ + ├─ Schritt 3: Ollama-Batch-Verdict + │ → ~/.pi/agent/jobs/_/report.json + │ → meta.json: status="completed" + │ +writer.ts --from-job + │ + ├─ Liest report.json aus Job + ├─ Schreibt article.md in Job + └─ meta.json: steps.write ergänzt +``` + +Bei Unterbrechung: erneuter Aufruf mit gleichem `--job-id` überspringt vorhandene Schritte. + +--- + +## Komponenten + +### Agenten (`agenten/`) + +#### `ollama-claim-extractor.ts` +- **Input:** Freitext (beliebige Länge) +- **Output:** `ClaimSet` (Array von `Claim`-Objekten) +- **Modell:** `qwen3.5:27b` (Ollama structured output, `num_ctx=8192`) +- **Chunking:** Texte > 4000 Zeichen → Chunks ≤ 3000 Zeichen (Absatzgrenzen), sequenziell verarbeitet, dann dedupliziert + zusammengeführt +- **Retry:** 3 Versuche mit 15s Pause bei `fetch failed` +- **Flags:** `--only-checkable`, `--max-claims`, `--verbose`, `--json` + +Claim-Felder: `claim_id`, `text`, `claim_type`, `checkability`, `needs_citation`, `entities`, `time_scope`, `source_sentence` + +Checkability-Werte: `checkable`, `partly_checkable`, `not_checkable` + +#### `ollama-verifier.ts` +- **Input:** Claim-Text + optionaler Kontext +- **Output:** `VerificationResult` +- **Pipeline:** `searchPerplexity()` → Ollama-Verdict-Synthesis +- **Flags:** `--mode fast|deep`, `--verbose`, `--json` + +Status-Werte: `supported`, `contradicted`, `mixed`, `insufficient_evidence`, `needs_human_review` + +#### `verify-article.ts` (Orchestrator) +- **Input:** Artikel-Text +- **Output:** `VerificationReport` +- **Ablauf:** + 1. `callOllamaClaimExtract()` — alle Claims (mit Chunking bei langen Texten) + 2. Für jeden `checkable` Claim: prüfe globalen Cache (`lib/cache.ts`) → prüfe Job-Cache → `searchPerplexity()` parallel (max. 5) + 3. Ergebnisse in globalem Cache + Job-Cache speichern + 4. Ein Batch-Ollama-Call für alle Verdicts +- **Claim-Cache-Priorität:** globaler Cache (SHA256, 7 Tage) → Job-Cache → live Perplexity +- **Flags:** `--job-id`, `--mode`, `--max-claims`, `--no-cache`, `--verbose`, `--json` + +#### `writer.ts` +- **Input:** `VerificationReport` (stdin oder Job-Speicher) +- **Output:** `ArticleDraft` +- **Regel:** Nur `supported` Claims → Artikel; Rest → `excluded_claims` +- **Routing:** lokal (Standard) oder OpenRouter mit `--cloud` +- **Flags:** `--from-report` (stdin), `--from-job `, `--style`, `--words`, `--lang`, `--cloud`, `--json` + +Stile: `journalistic`, `blog`, `academic`, `editorial`, `explanatory` + +#### `logic-editor.ts` +- **Input:** Argumentativer Text +- **Output:** `ArgumentMap` +- **Modell:** `deepseek-r1:32b` (lokal) oder `--cloud` (OpenRouter) +- **Flags:** `--only-fallacies`, `--verbose`, `--json` + +12 Fehlschluss-Typen: `ad_hominem`, `straw_man`, `false_dichotomy`, `slippery_slope`, `appeal_to_authority`, `hasty_generalization`, `circular_reasoning`, `red_herring`, `appeal_to_emotion`, `false_cause`, `bandwagon`, `anecdotal` + +#### `research-web.ts` +- Standalone (keine relativen Imports), direkt in Pi-Extensions-Root +- Perplexity `sonar` / `sonar-pro` + +--- + +### Shared Libraries (`lib/`) + +#### `lib/perplexity.ts` +- `searchPerplexity(query, opts)` → `PerplexityResult` +- `formatSourcesForPrompt(sources, maxLen)` → String +- Exponentielles Retry (3×), Quell-Deduplizierung, Kostenberechnung +- Kostenmodell: `sonar` $1/Mio Tokens, `sonar-pro` $3/$15 (In/Out) + +#### `lib/router.ts` +- `routeModel(task, complexity)` → `{ provider, model }` +- `callOpenRouter(model, messages, opts)` → `{ text, promptTokens, completionTokens, latencyMs }` +- `estimateOpenRouterCost(model, in, out)` → USD +- ENV-Overrides: `ROUTER_FORCE_LOCAL=1`, `ROUTER_FORCE_CLOUD=1` +- Standard: alle Tasks → lokal (Ollama) wenn `complexity: "low"` oder kein `OPENROUTER_API_KEY` + +#### `lib/logger.ts` +- `createLogger({ verbose?, jobId? })` → `Logger` +- `Logger.info/warn/error/debug(msg, data?)` — strukturierte Einträge mit ISO-Timestamp +- Schreibt in `~/.pi/agent/logs/[_jobId].log` +- `verbose=true` → alle Einträge auf stderr; `warn`/`error` immer auf stderr +- `nullLogger` — Null-Objekt für Pi-Extension-Kontext + +#### `lib/cache.ts` +- SHA256-basierter File-Cache für Perplexity-Ergebnisse +- `getCached(claimText)` — normalisiert Text, liest JSON wenn nicht abgelaufen +- `setCached(claimText, data)` — schreibt `~/.pi/agent/cache/perplexity/.json` +- `claimHash(text)` — exportierter SHA256-Helper +- `pruneCache()` — löscht Einträge älter als 7 Tage +- `cacheStats()` — `{ total, expired, sizeBytes }` +- TTL: 7 Tage (basiert auf Datei-mtime) +- Normalisierung: lowercase + collapse whitespace → konsistentes Hashing auch bei minimalen Unterschieden + +#### `lib/jobs.ts` +- `createJob(slug, model)` → jobDir +- `findJobDir(slug)` → neuestes Verzeichnis mit diesem Slug oder null +- `getOrCreateJob(slug, model)` → `{ jobDir, isNew }` +- `saveJobFile(jobDir, filename, data)` / `loadJobFile(jobDir, filename)` → T | null +- `jobFileExists(jobDir, filename)` → boolean +- `updateJobMeta(jobDir, updates)` — shallow merge in `meta.json` +- `listJobs()` → `JobMeta[]` (neueste zuerst) +- `formatJobList(jobs)` → Tabelle für CLI + +Job-Status: `created`, `extracting`, `verifying`, `writing`, `completed`, `failed` + +--- + +## VRAM-Constraints (RTX 3090, 24 GB) + +| Modell | Gewichte | KV@8192 | Gesamt | Passt? | +|--------|----------|---------|--------|--------| +| qwen3.5:27b Q4_K_M | 15.5 GB | 4.2 GB | ~21 GB | ✓ | +| qwen3.5:27b Q4_K_M @ 16384 | 15.5 GB | 8.4 GB | ~25 GB | ✗ OOM | +| deepseek-r1:32b Q4_K_M | 19 GB | 4.2 GB | ~23 GB | ✓ (knapp) | + +**Deshalb `num_ctx=8192` hardcoded** in `ollama-claim-extractor.ts` und `ollama-verifier.ts`. +**Deshalb Chunking** statt größerer Kontext: Texte werden in ≤3000-Zeichen-Stücke zerlegt. + +Mit `CUDA_VISIBLE_DEVICES=1,2` (beide RTX 3090): 48 GB VRAM → 70B-Modelle möglich. + +--- + +## Ollama-Integrationsdetails + +```typescript +// Structured Output — IMMER so, nie format: "json" +await fetch("http://localhost:11434/api/chat", { + method: "POST", + body: JSON.stringify({ + model: "qwen3.5:27b", + messages: [...], + format: { type: "object", additionalProperties: false, properties: {...}, required: [...] }, + stream: false, + options: { temperature: 0.1, num_ctx: 8192 } + }) +}); +``` + +Retry-Pattern in `ollama-claim-extractor.ts` (Vorlage für andere Agenten): +```typescript +for (let attempt = 1; attempt <= 3; attempt++) { + try { + resp = await fetch(...); + break; + } catch (err) { + if (attempt === 3) throw new Error(`fetch failed nach 3 Versuchen: ${err}`); + await new Promise(r => setTimeout(r, 15_000)); // 15s warten + } +} +``` + +--- + +## Deployment-Architektur + +``` +~/Pi_Agent_Projekts/text_agent/ ← git-Repository +├── agenten/ ◄──── Symlinks ────────────────────────────────┐ +└── lib/ ◄────── Symlink ─────────────────────────────────┐ │ + │ │ +~/.pi/agent/extensions/ │ │ +├── lib ────────────────────────────────────────────────────┘ │ +└── fact-checker/ │ + ├── package.json │ + ├── ollama-claim-extractor.ts ──────────────────────────────────┘ + ├── ollama-verifier.ts ──────────────────────────────────────────┘ + ├── verify-article.ts ────────────────────────────────────┘ + ├── logic-editor.ts ──────────────────────────────────────┘ + └── writer.ts ────────────────────────────────────────────┘ + +~/.pi/agent/jobs/ ← Job-Verzeichnisse (Runtime-Daten, nicht im git) +~/.pi/agent/logs/ ← Log-Dateien (Runtime-Daten, nicht im git) +~/.pi/agent/cache/ ← Perplexity-Claim-Cache (Runtime-Daten, nicht im git) + +text_agent/tests/ +├── corpus/ ← 10 Testfälle (input.txt, expected.json, notes.md) +├── results/ ← Test-Runner-Outputs (nicht im git, .gitignore) +└── run_corpus.sh ← Precision/Recall-Test-Runner +``` diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 0000000..01d2bcb --- /dev/null +++ b/lib/cache.ts @@ -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/.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 = { + 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(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; + return entry.data; + } catch { + return null; + } +} + +/** + * Speichert ein Perplexity-Ergebnis im Cache. + * Fehler beim Schreiben werden ignoriert (Cache ist optional). + */ +export function setCached(claimText: string, data: T): void { + try { + ensureCacheDir(); + const entry: CacheEntry = { + 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 }; + } +} diff --git a/lib/jobs.ts b/lib/jobs.ts new file mode 100644 index 0000000..f07e9fa --- /dev/null +++ b/lib/jobs.ts @@ -0,0 +1,308 @@ +/** + * lib/jobs.ts + * Job-Speicher für die Pipeline-Agenten. + * + * Verzeichnisstruktur: + * ~/.pi/agent/jobs/_/ + * ├── 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(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: _ + 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(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> & { steps?: Partial } +): 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 = { + 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"); +} diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..f71f818 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,107 @@ +/** + * lib/logger.ts + * Einfacher File-Logger für alle Agenten. + * + * Schreibt strukturierte Log-Einträge nach ~/.pi/agent/logs/[_].log + * Im verbose-Modus werden alle Einträge zusätzlich auf stderr ausgegeben. + * Warnung/Fehler gehen immer auf stderr (unabhängig von verbose). + * + * Verwendung: + * import { createLogger } from "../lib/logger.js"; + * const log = createLogger({ verbose: cliFlags.verbose }); + * log.info("Claims extrahieren...", { model, numChunks: 3 }); + * log.warn("0 Claims in Chunk", { chunk: 2 }); + * log.error("Ollama nicht erreichbar", { url: OLLAMA_HOST }); + */ + +import { appendFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// Konstanten +// --------------------------------------------------------------------------- + +export const LOG_DIR = join(homedir(), ".pi", "agent", "logs"); + +export type LogLevel = "info" | "warn" | "error" | "debug"; + +// --------------------------------------------------------------------------- +// Logger-Klasse +// --------------------------------------------------------------------------- + +export class Logger { + private logFile: string | null; + private verbose: boolean; + + constructor(opts?: { logFile?: string; verbose?: boolean }) { + this.logFile = opts?.logFile ?? null; + this.verbose = opts?.verbose ?? false; + } + + log(level: LogLevel, message: string, data?: Record): void { + const ts = new Date().toISOString(); + const dataStr = data ? " " + JSON.stringify(data) : ""; + const line = `[${ts}] [${level.toUpperCase().padEnd(5)}] ${message}${dataStr}\n`; + + // In Datei schreiben (append, non-blocking, Fehler ignorieren) + if (this.logFile) { + try { + appendFileSync(this.logFile, line); + } catch { + // Log-Fehler dürfen den Programmablauf nicht stören + } + } + + // Auf stderr ausgeben wenn verbose ODER level >= warn + if (this.verbose || level === "error" || level === "warn") { + process.stderr.write(line); + } + } + + info(message: string, data?: Record): void { + this.log("info", message, data); + } + + warn(message: string, data?: Record): void { + this.log("warn", message, data); + } + + error(message: string, data?: Record): void { + this.log("error", message, data); + } + + debug(message: string, data?: Record): void { + this.log("debug", message, data); + } +} + +// Null-Logger für Kontexte wo kein Logging gewünscht ist +export const nullLogger = new Logger(); + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Erstellt einen Logger der in eine neue Log-Datei schreibt. + * @param opts.jobId Optionaler Suffix für den Dateinamen (z.B. "umerziehung") + * @param opts.verbose Wenn true: alle Log-Einträge auf stderr + */ +export function createLogger(opts?: { jobId?: string; verbose?: boolean }): Logger { + try { + mkdirSync(LOG_DIR, { recursive: true }); + } catch { + // Verzeichnis existiert bereits oder kein Schreibzugriff + } + + const ts = new Date() + .toISOString() + .replace(/T/, "_") + .replace(/:/g, "-") + .slice(0, 19); // "2026-04-16_14-30-00" + const suffix = opts?.jobId ? `_${opts.jobId}` : ""; + const logFile = join(LOG_DIR, `${ts}${suffix}.log`); + + return new Logger({ logFile, verbose: opts?.verbose ?? false }); +} diff --git a/lib/ollama.ts b/lib/ollama.ts new file mode 100644 index 0000000..70b70b2 --- /dev/null +++ b/lib/ollama.ts @@ -0,0 +1,237 @@ +/** + * lib/ollama.ts + * Zentraler Ollama-Client: Text-Chat und Vision/OCR-Aufrufe. + * + * Neu angelegte Agenten nutzen diesen Client statt inline-fetch. + * Bestehende Agenten (ollama-claim-extractor, verifier) können schrittweise migriert werden. + * + * Konfiguration: + * OLLAMA_HOST → Ollama-URL (Standard: http://localhost:11434) + */ + +export const OLLAMA_HOST = process.env.OLLAMA_HOST ?? "http://localhost:11434"; + +export type OllamaMessage = { + role: "system" | "user" | "assistant"; + content: string; + images?: string[]; // base64-kodierte Bilder (Vision-Aufrufe) +}; + +export type OllamaResult = { + text: string; + promptTokens: number; + completionTokens: number; + latencyMs: number; +}; + +// --------------------------------------------------------------------------- +// Intern +// --------------------------------------------------------------------------- + +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 15_000; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// Haupt-Aufruf +// --------------------------------------------------------------------------- + +/** + * Generischer Ollama-Chat (Text oder Vision). + * Für Vision: images-Felder in den Messages setzen, oder callOllamaVision() nutzen. + */ +export async function callOllamaChat( + model: string, + messages: OllamaMessage[], + options?: { + /** JSON-Schema für structured output (Ollama >= 0.5) */ + format?: "json" | Record; + temperature?: number; + numCtx?: number; + numPredict?: number; + /** + * Thinking-Mode für qwen3/deepseek-r1-Modelle (Standard: false). + * false → /no_think → nur Antwort, kein Chain-of-Thought + * true → Modell denkt zuerst, Antwort in content; thinking in separatem Feld + */ + think?: boolean; + signal?: AbortSignal; + } +): Promise { + const t0 = Date.now(); + let lastError: unknown; + + // qwen3 und deepseek-r1 haben Thinking-Mode standardmäßig an. + // Für strukturierte Ausgaben (JSON, Extraktion) ist Thinking unerwünscht. + const think = options?.think ?? false; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const body: Record = { + model, + messages, + stream: false, + think, + options: { + temperature: options?.temperature ?? 0.1, + ...(options?.numCtx ? { num_ctx: options.numCtx } : {}), + ...(options?.numPredict ? { num_predict: options.numPredict } : {}), + }, + }; + if (options?.format !== undefined) { + body.format = options.format; + } + + const resp = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: options?.signal, + }); + + if (!resp.ok) { + const errText = await resp.text().catch(() => ""); + throw new Error(`Ollama HTTP ${resp.status}: ${errText}`); + } + + const data = await resp.json() as { + message?: { content?: string; thinking?: string }; + prompt_eval_count?: number; + eval_count?: number; + }; + + // Bei Thinking-Modellen (qwen3, deepseek-r1): wenn content leer, + // Fallback auf thinking-Feld (passiert bei sehr kurzen Antworten). + const text = data.message?.content?.trim() + || (think ? data.message?.thinking?.trim() : "") + || ""; + + return { + text, + promptTokens: data.prompt_eval_count ?? 0, + completionTokens: data.eval_count ?? 0, + latencyMs: Date.now() - t0, + }; + } catch (err) { + lastError = err; + if (attempt < MAX_RETRIES) await sleep(RETRY_DELAY_MS); + } + } + + throw new Error( + `Ollama fehlgeschlagen nach ${MAX_RETRIES} Versuchen: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }` + ); +} + +// --------------------------------------------------------------------------- +// Vision / OCR +// --------------------------------------------------------------------------- + +/** + * Ollama-Aufruf mit Bild-Input (Vision / OCR). + * + * Empfohlene Modelle (passen alle auf RTX 3090 24GB): + * fredrezones55/chandra-ocr-2:patch 5.8GB — OCR-spezialisiert, Dokumente/Scans + * qwen3-vl:latest 6.1GB — Vision-Language, Bildbeschreibung + OCR + * qwen2.5vl:7b 6.0GB — Alternative zu qwen3-vl + * minicpm-v:latest 5.5GB — Leichtgewichtig, gut für einfache OCR + * + * @param imageSource Absoluter Dateipfad ("/…") oder base64-String + */ +export async function callOllamaVision( + model: string, + imageSource: string, + prompt: string, + options?: { + systemPrompt?: string; + temperature?: number; + signal?: AbortSignal; + } +): Promise { + let imageBase64: string; + + if (imageSource.startsWith("/") || imageSource.startsWith("~")) { + const { readFile } = await import("node:fs/promises"); + const resolvedPath = imageSource.startsWith("~") + ? imageSource.replace(/^~/, process.env.HOME ?? "/root") + : imageSource; + const buf = await readFile(resolvedPath); + imageBase64 = buf.toString("base64"); + } else { + imageBase64 = imageSource; // schon base64 + } + + const messages: OllamaMessage[] = []; + if (options?.systemPrompt) { + messages.push({ role: "system", content: options.systemPrompt }); + } + messages.push({ role: "user", content: prompt, images: [imageBase64] }); + + return callOllamaChat(model, messages, { + temperature: options?.temperature ?? 0.1, + signal: options?.signal, + }); +} + +// --------------------------------------------------------------------------- +// Modell-Infos (lokal installiert, passend für RTX 3090 24GB) +// --------------------------------------------------------------------------- + +/** Alle bekannten lokalen Ollama-Modelle nach Kategorie. */ +export const LOCAL_CATALOG = { + // --- Text / Reasoning --- + text: { + /** 17GB — Haupt-Allrounder, 1 GPU */ + "qwen3.5:27b": { vramGB: 17, gpus: 1 }, + /** 19GB — Eingebautes Reasoning (DeepSeek R1), 1 GPU */ + "deepseek-r1:32b": { vramGB: 19, gpus: 1 }, + /** 18GB — Code + allgemein, 128k-Kontext, 1 GPU */ + "qwen3-coder-30b-128k:latest": { vramGB: 18, gpus: 1 }, + /** 18GB — Optimierte GPU-Variante des Qwen3-Coders, 1 GPU */ + "qwen3-coder-30b-gpu:latest": { vramGB: 18, gpus: 1 }, + /** 18GB — GLM-4.7 Flash, chinesisches Modell, 1 GPU */ + "glm-4.7-flash:latest": { vramGB: 18, gpus: 1 }, + /** 17GB — Gemma4 26B von Google, 1 GPU */ + "gemma4:26b": { vramGB: 17, gpus: 1 }, + /** 9.6GB — Gemma4 E4B (Effizienz-Variante), 1 GPU */ + "gemma4:e4b": { vramGB: 9.6, gpus: 1 }, + /** 9.0GB — Qwen2.5 14B Instruct, 1 GPU */ + "qwen2.5:14b-instruct": { vramGB: 9, gpus: 1 }, + /** 5.2GB — Qwen3 8B, schnell für einfache Tasks */ + "qwen3:8b": { vramGB: 5.2, gpus: 1 }, + /** 4.9GB — Llama 3.1 8B */ + "llama3.1:8b": { vramGB: 4.9, gpus: 1 }, + /** 7.1GB — Mistral Nemo */ + "mistral-nemo:latest": { vramGB: 7.1, gpus: 1 }, + }, + // --- Code --- + code: { + /** 9.0GB — Qwen2.5-Coder 14B, 1 GPU */ + "qwen2.5-coder:14b": { vramGB: 9, gpus: 1 }, + /** 4.7GB — Qwen2.5-Coder 7B, schnell */ + "qwen2.5-coder:7b": { vramGB: 4.7, gpus: 1 }, + }, + // --- Vision / OCR --- + vision: { + /** 5.8GB — OCR-spezialisiert (Chandra OCR 2) */ + "fredrezones55/chandra-ocr-2:patch": { vramGB: 5.8, gpus: 1 }, + /** 6.1GB — Qwen3 Vision-Language Model */ + "qwen3-vl:latest": { vramGB: 6.1, gpus: 1 }, + /** 6.0GB — Qwen2.5 Vision-Language 7B */ + "qwen2.5vl:7b": { vramGB: 6, gpus: 1 }, + /** 5.5GB — MiniCPM-V, leichtgewichtig */ + "minicpm-v:latest": { vramGB: 5.5, gpus: 1 }, + /** 3.3GB — Qwen3-VL 4B, sehr klein */ + "qwen3-vl:4b": { vramGB: 3.3, gpus: 1 }, + }, + // --- Embedding --- + embedding: { + /** 4.7GB — Qwen3 Embedding */ + "qwen3-embedding:latest": { vramGB: 4.7, gpus: 1 }, + }, +} as const; diff --git a/lib/perplexity.ts b/lib/perplexity.ts new file mode 100644 index 0000000..b0191e0 --- /dev/null +++ b/lib/perplexity.ts @@ -0,0 +1,175 @@ +/** + * lib/perplexity.ts + * Gemeinsamer Perplexity-Sonar-Wrapper für alle Agenten. + * Wird von verifier.ts und verify-article.ts genutzt. + */ + +const PRICING = { + sonar: { inputPerM: 1, outputPerM: 1 }, + "sonar-pro": { inputPerM: 3, outputPerM: 15 }, +} as const; + +const SEARCH_COST_PER_CALL = 0.005; + +export type PerplexitySource = { + url: string; + title: string | undefined; + snippet: string | undefined; +}; + +export type PerplexityResult = { + summary: string; + sources: PerplexitySource[]; + model: string; + promptTokens: number; + completionTokens: number; + searchQueries: number; + estimatedCostUSD: number; +}; + +type PerplexityApiResponse = { + model?: string; + citations?: string[]; + search_results?: Array<{ url?: string; title?: string; snippet?: string }>; + choices?: Array<{ message?: { content?: string } }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + search_queries?: number; + }; +}; + +class RetryableError extends Error {} + +async function sleep(ms: number) { + await new Promise((r) => setTimeout(r, ms)); +} + +function estimateCost(model: string, promptTokens: number, completionTokens: number, searchQueries: number): number { + const p = PRICING[model as keyof typeof PRICING] ?? PRICING["sonar"]; + return (promptTokens / 1_000_000) * p.inputPerM + + (completionTokens / 1_000_000) * p.outputPerM + + searchQueries * SEARCH_COST_PER_CALL; +} + +function parseSources(data: PerplexityApiResponse): PerplexitySource[] { + const seen = new Set(); + + const fromSearch = data.search_results + ?.filter((r) => !!r?.url) + .map((r) => ({ url: r.url!, title: r.title, snippet: r.snippet })) + .filter((s) => !seen.has(s.url) && seen.add(s.url)) + ?? []; + + if (fromSearch.length > 0) return fromSearch.slice(0, 8); + + return (data.citations ?? []) + .filter((u): u is string => typeof u === "string" && /^https?:\/\//.test(u) && !seen.has(u) && !!seen.add(u)) + .slice(0, 8) + .map((url) => ({ url, title: undefined, snippet: undefined })); +} + +/** + * Ruft die Perplexity Sonar API auf und gibt ein normiertes Ergebnis zurück. + * Wirft einen Error wenn PERPLEXITY_API_KEY fehlt oder der Aufruf fehlschlägt. + */ +export async function searchPerplexity( + query: string, + options?: { + mode?: "fast" | "deep"; + recency?: string; + signal?: AbortSignal; + maxTokens?: number; + } +): Promise { + const apiKey = process.env.PERPLEXITY_API_KEY; + if (!apiKey) throw new Error("PERPLEXITY_API_KEY ist nicht gesetzt"); + + const mode = options?.mode ?? "fast"; + const model: "sonar" | "sonar-pro" = mode === "deep" ? "sonar-pro" : "sonar"; + const contextSize = mode === "deep" ? "high" : "low"; + const maxTokens = options?.maxTokens ?? (mode === "deep" ? 600 : 350); + + const body: Record = { + model, + messages: [ + { + role: "system", + content: + "Du bist ein Recherche-Tool für Fact-Checking. " + + "Recherchiere präzise und faktisch. " + + "Setze Inline-Zitierungen [1][2][3] direkt nach jedem belegten Satz. " + + "Fokussiere auf überprüfbare Fakten und Primärquellen.", + }, + { + role: "user", + content: `Recherchefrage zum Fact-Checking:\n\n${query}`, + }, + ], + max_tokens: maxTokens, + temperature: 0.1, + web_search_options: { search_context_size: contextSize }, + }; + + if (options?.recency) body.search_recency_filter = options.recency; + + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const resp = await fetch("https://api.perplexity.ai/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: options?.signal, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + if (resp.status === 429 || resp.status >= 500) throw new RetryableError(`Perplexity ${resp.status}: ${text}`); + throw new Error(`Perplexity ${resp.status}: ${text}`); + } + + const data = (await resp.json()) as PerplexityApiResponse; + const summary = data.choices?.[0]?.message?.content?.trim() ?? ""; + if (!summary) throw new RetryableError("Leere Antwort von Perplexity"); + + const sources = parseSources(data); + const usage = data.usage ?? {}; + const promptTokens = usage.prompt_tokens ?? 0; + const completionTokens = usage.completion_tokens ?? 0; + const searchQueries = usage.search_queries ?? 1; + const finalModel = data.model ?? model; + + return { + summary, + sources, + model: finalModel, + promptTokens, + completionTokens, + searchQueries, + estimatedCostUSD: estimateCost(finalModel, promptTokens, completionTokens, searchQueries), + }; + } catch (err) { + lastError = err; + if (err instanceof RetryableError && attempt < 3) { + await sleep(400 * 2 ** (attempt - 1)); + } else { + break; + } + } + } + + throw lastError instanceof Error ? lastError : new Error("Perplexity-Fehler"); +} + +/** Formatiert Quellen als kompakte Inline-Liste für Prompts */ +export function formatSourcesForPrompt(sources: PerplexitySource[], maxSnippetLen = 250): string { + return sources + .map((s, i) => { + const title = s.title ?? s.url; + const snippet = s.snippet ? `\n "${s.snippet.slice(0, maxSnippetLen)}${s.snippet.length > maxSnippetLen ? "…" : ""}"` : ""; + return `[${i + 1}] ${title} (${s.url})${snippet}`; + }) + .join("\n"); +} diff --git a/lib/router.ts b/lib/router.ts new file mode 100644 index 0000000..d7efcd0 --- /dev/null +++ b/lib/router.ts @@ -0,0 +1,299 @@ +/** + * lib/router.ts + * Model-Router: Entscheidet ob lokales Ollama oder OpenRouter verwendet wird. + * + * Strategie: + * - Lokal (Ollama): Claim-Extraktion, Strukturierung, einfache Klassifizierung, + * Artikelschreiben (Standard), Verdict-Synthese, OCR/Vision + * - OpenRouter: Tiefe Argumentationsanalyse, komplexes Reasoning, + * anspruchsvolles Schreiben/Lektorat + * + * Bevorzugt günstige chinesische Modelle (DeepSeek, Qwen3) wo verfügbar — + * Gemini nur als Fallback / explizite Wahl. + * + * Konfiguration via Env-Variablen: + * ROUTER_FORCE_LOCAL=1 → immer Ollama (für Tests / Offline) + * ROUTER_FORCE_CLOUD=1 → immer OpenRouter + * OPENROUTER_API_KEY → OpenRouter-Key (Pflicht für Cloud-Aufrufe) + * OLLAMA_HOST → Ollama-URL (Standard: http://localhost:11434) + */ + +export type TaskType = + | "claim_extraction" // Text → strukturierte Claims (lokal optimal) + | "verdict_synthesis" // Claims + Belege → Urteil (lokal gut genug) + | "article_writing" // Verifizierte Claims → Artikeltext + | "logic_analysis" // Argumentationsanalyse (Reasoning-intensiv) + | "deep_reasoning" // Komplexe mehrstufige Analyse + | "style_editing" // Stilverbesserung, Lektorat + | "ocr" // OCR / Texterkennung aus Bild → lokal (Vision-Modell) + | "vision_analysis"; // Bildbeschreibung, Bildanalyse → lokal bevorzugt + +export type ComplexityHint = "low" | "medium" | "high"; + +export type RouterDecision = { + provider: "ollama" | "openrouter"; + model: string; + reason: string; +}; + +// --------------------------------------------------------------------------- +// Lokale Modelle (Ollama, RTX 3090 24GB) +// --------------------------------------------------------------------------- + +const LOCAL_MODELS = { + // Text + fast: "qwen3.5:27b", // 17GB — Standard Allrounder + reasoning: "deepseek-r1:32b", // 19GB — eingebautes Reasoning + small: "qwen3:8b", // 5.2GB — schnell für einfache Tasks + // Vision / OCR + ocr: "fredrezones55/chandra-ocr-2:patch", // 5.8GB — OCR-spezialisiert + vision: "qwen3-vl:latest", // 6.1GB — Vision-Language allgemein + vision_small: "minicpm-v:latest", // 5.5GB — leichtgewichtig +} as const; + +// --------------------------------------------------------------------------- +// OpenRouter-Modelle — nach Kosten/Leistung (Stand 2025/2026) +// Preise in USD/1M Tokens: https://openrouter.ai/models +// --------------------------------------------------------------------------- + +const CLOUD_MODELS = { + // DeepSeek — extrem günstig, sehr kompetent + /** DeepSeek V3 — ~$0.014/M in, $0.028/M out — bestes Preis-Leistungs-Verhältnis */ + cheap: "deepseek/deepseek-chat-v3-0324", + /** DeepSeek R1 — ~$0.55/M in, $2.19/M out — starkes Reasoning, günstiger als Gemini Pro */ + reasoning: "deepseek/deepseek-r1", + + // Qwen3 (Alibaba) — gut und günstig + /** Qwen3 235B A22B MoE — ~$0.13/M in, $0.60/M out — Alibabas Flaggschiff */ + qwen_large: "qwen/qwen3-235b-a22b", + /** Qwen3 30B A3B — ~$0.03/M in, $0.10/M out — schneller + günstiger */ + qwen_fast: "qwen/qwen3-30b-a3b", + + // Google Gemini — Fallback / explizite Nutzung + /** Gemini 2.5 Flash — ~$0.15/M in, $0.60/M out */ + gemini_flash: "google/gemini-2.5-flash", + /** Gemini 2.5 Flash Lite — ~$0.075/M in, $0.30/M out */ + gemini_lite: "google/gemini-2.5-flash-lite", + /** Gemini 2.5 Pro — ~$1.25/M in, $10.0/M out — nur für heikle High-Stakes-Fälle */ + gemini_pro: "google/gemini-2.5-pro", +} as const; + +// --------------------------------------------------------------------------- +// Routing-Regeln +// --------------------------------------------------------------------------- + +/** + * Entscheidet anhand Task-Typ und Komplexität welches Modell verwendet werden soll. + * Bevorzugt günstige chinesische Modelle über teure westliche Alternativen. + */ +export function routeModel(task: TaskType, complexity: ComplexityHint = "medium"): RouterDecision { + const forceLocal = process.env.ROUTER_FORCE_LOCAL === "1"; + const forceCloud = process.env.ROUTER_FORCE_CLOUD === "1"; + const hasOpenRouter = !!process.env.OPENROUTER_API_KEY; + + if (forceLocal) { + const localModel = (task === "ocr") + ? LOCAL_MODELS.ocr + : (task === "vision_analysis") + ? LOCAL_MODELS.vision + : (task === "deep_reasoning" || task === "logic_analysis") + ? LOCAL_MODELS.reasoning + : LOCAL_MODELS.fast; + return { provider: "ollama", model: localModel, reason: "ROUTER_FORCE_LOCAL gesetzt" }; + } + + if (forceCloud && hasOpenRouter) { + const cloudModel = complexity === "high" + ? CLOUD_MODELS.reasoning + : CLOUD_MODELS.cheap; + return { provider: "openrouter", model: cloudModel, reason: "ROUTER_FORCE_CLOUD gesetzt" }; + } + + switch (task) { + + // --- Immer lokal --- + case "claim_extraction": + case "verdict_synthesis": + return { + provider: "ollama", + model: LOCAL_MODELS.fast, + reason: "Strukturierter Extraktions-Task → Ollama optimal", + }; + + // --- Immer lokal (Vision-Modelle) --- + case "ocr": + return { + provider: "ollama", + model: LOCAL_MODELS.ocr, + reason: "OCR → lokales Chandra-OCR-2 (5.8GB, RTX 3090)", + }; + + case "vision_analysis": + return { + provider: "ollama", + model: LOCAL_MODELS.vision, + reason: "Bildanalyse → lokales qwen3-vl (6.1GB, RTX 3090)", + }; + + // --- Lokal bevorzugt, Cloud bei Bedarf --- + case "article_writing": + case "style_editing": + if (complexity === "low") { + return { + provider: "ollama", + model: LOCAL_MODELS.fast, + reason: "Einfaches Schreiben → Ollama ausreichend", + }; + } + if (hasOpenRouter) { + return { + provider: "openrouter", + // DeepSeek V3 ist extrem günstig und schreibt sehr guten Text + model: CLOUD_MODELS.cheap, + reason: "Anspruchsvolles Schreiben → DeepSeek V3 (günstig, stark)", + }; + } + return { + provider: "ollama", + model: LOCAL_MODELS.fast, + reason: "OpenRouter nicht verfügbar → Ollama Fallback", + }; + + // --- Cloud bevorzugt für Reasoning --- + case "logic_analysis": + if (hasOpenRouter) { + // DeepSeek R1 ist ein dediziertes Reasoning-Modell, deutlich günstiger als Gemini Pro + const model = complexity === "high" + ? CLOUD_MODELS.reasoning // DeepSeek R1 für tiefe Analyse + : CLOUD_MODELS.cheap; // DeepSeek V3 für mittlere Komplexität + return { + provider: "openrouter", + model, + reason: complexity === "high" + ? "Komplexe Argumentationsanalyse → DeepSeek R1 (Reasoning-Modell)" + : "Argumentationsanalyse → DeepSeek V3 (günstig + kompetent)", + }; + } + return { + provider: "ollama", + model: LOCAL_MODELS.reasoning, + reason: "Argumentationsanalyse → deepseek-r1 lokal (kein OpenRouter-Key)", + }; + + case "deep_reasoning": + if (hasOpenRouter) { + return { + provider: "openrouter", + // DeepSeek R1 ist für Reasoning-Tasks günstiger als Gemini Pro + // und liefert vergleichbare oder bessere Ergebnisse + model: complexity === "high" + ? CLOUD_MODELS.reasoning // DeepSeek R1 + : CLOUD_MODELS.qwen_large, // Qwen3 235B für mittlere Komplexität + reason: complexity === "high" + ? "Deep Reasoning (high) → DeepSeek R1 (günstig, stark)" + : "Deep Reasoning (medium) → Qwen3 235B A22B", + }; + } + return { + provider: "ollama", + model: LOCAL_MODELS.reasoning, + reason: "Deep Reasoning → deepseek-r1 lokal (kein OpenRouter-Key)", + }; + } +} + +// --------------------------------------------------------------------------- +// OpenRouter API-Aufruf (generisch) +// --------------------------------------------------------------------------- + +export type OpenRouterMessage = { role: "system" | "user" | "assistant"; content: string }; + +/** + * Ruft ein Modell via OpenRouter auf. + */ +export async function callOpenRouter( + model: string, + messages: OpenRouterMessage[], + options?: { + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; + } +): Promise<{ text: string; promptTokens: number; completionTokens: number; latencyMs: number }> { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error("OPENROUTER_API_KEY ist nicht gesetzt"); + + const t0 = Date.now(); + + const resp = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://pi.local", + "X-Title": "Pi Text-Agent", + }, + body: JSON.stringify({ + model, + messages, + temperature: options?.temperature ?? 0.3, + max_tokens: options?.maxTokens ?? 2000, + }), + signal: options?.signal, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`OpenRouter Fehler ${resp.status}: ${text}`); + } + + const data = await resp.json() as { + choices?: Array<{ message?: { content?: string } }>; + usage?: { prompt_tokens?: number; completion_tokens?: number }; + }; + + const text = data.choices?.[0]?.message?.content?.trim() ?? ""; + if (!text) throw new Error("Leere Antwort von OpenRouter"); + + return { + text, + promptTokens: data.usage?.prompt_tokens ?? 0, + completionTokens: data.usage?.completion_tokens ?? 0, + latencyMs: Date.now() - t0, + }; +} + +// --------------------------------------------------------------------------- +// Kostenabschätzung +// --------------------------------------------------------------------------- + +/** + * Schätzt die ungefähren Kosten eines OpenRouter-Aufrufs (USD). + * Preise sind Näherungswerte — für präzise Zahlen: OpenRouter-Dashboard. + */ +export function estimateOpenRouterCost( + model: string, + promptTokens: number, + completionTokens: number +): number { + // USD pro 1M Tokens [in, out] — Stand 2025/2026 + const pricing: Record = { + // DeepSeek — extrem günstig + "deepseek/deepseek-chat-v3-0324": [0.014, 0.028], + "deepseek/deepseek-chat": [0.014, 0.028], // Alias + "deepseek/deepseek-r1": [0.55, 2.19], + + // Qwen3 (Alibaba) + "qwen/qwen3-235b-a22b": [0.13, 0.60], + "qwen/qwen3-30b-a3b": [0.03, 0.10], + + // Google Gemini + "google/gemini-2.5-flash": [0.15, 0.60], + "google/gemini-2.5-flash-lite": [0.075, 0.30], + "google/gemini-2.5-pro": [1.25, 10.0], + }; + + const [inPrice, outPrice] = pricing[model] ?? [1.0, 3.0]; // konservativer Fallback + return (promptTokens / 1_000_000) * inPrice + + (completionTokens / 1_000_000) * outPrice; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c9a1aaa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,599 @@ +{ + "name": "text-agent", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "text-agent", + "version": "0.1.0", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.15.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9a87457 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "text-agent", + "version": "0.1.0", + "description": "Pi Multi-Agenten-System für Fact-Checking, Recherche und Artikelschreiben", + "type": "module", + "scripts": { + "claim-extract": "tsx agenten/ollama-claim-extractor.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.15.0", + "typescript": "^5.5.0" + } +} diff --git a/tests/corpus/README.md b/tests/corpus/README.md new file mode 100644 index 0000000..6238ade --- /dev/null +++ b/tests/corpus/README.md @@ -0,0 +1,43 @@ +# Testkorpus — Pi Text-Agent Fact-Checker + +Jeder Fall enthält einen Artikel mit mindestens einem bekannten Fehler und 2+ korrekten Fakten. + +## Struktur + +``` +case_XXX/ +├── input.txt ← Artikel mit bekannten Fehlern +├── expected.json ← Erwartete Claim-Status (claim_text → status) +└── notes.md ← Was falsch ist und warum +``` + +## expected.json Format + +```json +{ + "claims": [ + { + "text_contains": "Wort oder Phrase zur Identifikation des Claims", + "expected_status": "contradicted | supported | mixed | insufficient_evidence", + "note": "Kurze Begründung" + } + ] +} +``` + +`text_contains` wird case-insensitiv als Substring gesucht. + +## Fälle + +| Nr | Thema | Fehler | +|----|-------|--------| +| 001 | Deutsche Inflation 2024 | Falsche Rate (3,2% statt 2,2%) | +| 002 | EZB Leitzins | Falscher Zeitpunkt (April statt Juni) | +| 003 | Mondlandung Apollo | Enthält korrekten Fakt | +| 004 | Bevölkerung Deutschland | Falsche Zahl (90 Mio statt ~84 Mio) | +| 005 | Erneuerbare Energien Deutschland 2023 | Falscher Anteil (70% statt ~59%) | +| 006 | Bitcoin Allzeithoch 2021 | Falscher Betrag ($75.000 statt ~$68.000) | +| 007 | COVID Impfstoff Zulassung | Richtiger Fakt | +| 008 | Bundeshaushalt 2024 | Falscher Betrag (500 Mrd statt ~476 Mrd) | +| 009 | Klimaziel Paris | Korrekte Kernaussage | +| 010 | Weltbevölkerung | Falsche Zahl (9 Mrd statt ~8,1 Mrd) | diff --git a/tests/corpus/case_001/expected.json b/tests/corpus/case_001/expected.json new file mode 100644 index 0000000..e16cbad --- /dev/null +++ b/tests/corpus/case_001/expected.json @@ -0,0 +1,19 @@ +{ + "claims": [ + { + "text_contains": "3,2 Prozent", + "expected_status": "contradicted", + "note": "Tatsächliche Inflationsrate 2024 war 2,2% (Destatis), nicht 3,2%" + }, + { + "text_contains": "Zielwert", + "expected_status": "mixed", + "note": "EZB-Ziel ist 2% (korrekt), aber 2024er Jahresschnitt 2,2% ist kaum 'deutlich über Zielwert'; Perplexity liefert 2025-Daten" + }, + { + "text_contains": "Euro als gesetzliches Zahlungsmittel seit 2002", + "expected_status": "supported", + "note": "Deutschland hat den Euro 2002 eingeführt — korrekter Fakt" + } + ] +} diff --git a/tests/corpus/case_001/input.txt b/tests/corpus/case_001/input.txt new file mode 100644 index 0000000..dfff05b --- /dev/null +++ b/tests/corpus/case_001/input.txt @@ -0,0 +1 @@ +Die Inflationsrate in Deutschland betrug im Jahr 2024 durchschnittlich 3,2 Prozent. Damit lag sie deutlich über dem Zielwert der Europäischen Zentralbank von 2 Prozent. Das Statistische Bundesamt veröffentlicht die Inflationsdaten monatlich. Deutschland ist Mitglied der Eurozone und verwendet den Euro als gesetzliches Zahlungsmittel seit 2002. diff --git a/tests/corpus/case_001/notes.md b/tests/corpus/case_001/notes.md new file mode 100644 index 0000000..064f7ce --- /dev/null +++ b/tests/corpus/case_001/notes.md @@ -0,0 +1,13 @@ +# Case 001 — Deutsche Inflation 2024 + +## Fehler +Die Inflationsrate 2024 wird mit 3,2% angegeben. Laut Statistischem Bundesamt (Destatis) betrug sie +tatsächlich **2,2%** (Jahresdurchschnitt 2024). + +## Quelle +- Destatis: https://www.destatis.de/DE/Themen/Wirtschaft/Preise/Verbraucherpreisindex/ + +## Korrekte Fakten im Text +- EZB-Inflationsziel: 2% ✓ +- Euro-Einführung Deutschland: 2002 ✓ +- Statistisches Bundesamt veröffentlicht Daten monatlich ✓ diff --git a/tests/corpus/case_002/expected.json b/tests/corpus/case_002/expected.json new file mode 100644 index 0000000..6db6d19 --- /dev/null +++ b/tests/corpus/case_002/expected.json @@ -0,0 +1,19 @@ +{ + "claims": [ + { + "text_contains": "April 2024", + "expected_status": "contradicted", + "note": "Die erste EZB-Zinssenkung 2024 war im Juni, nicht April" + }, + { + "text_contains": "Frankfurt am Main", + "expected_status": "supported", + "note": "EZB-Sitz ist Frankfurt am Main — korrekter Fakt" + }, + { + "text_contains": "20 Mitgliedsstaaten", + "expected_status": "contradicted", + "note": "Bulgarien trat am 1.1.2026 bei — Eurozone hat seither 21 Mitglieder" + } + ] +} diff --git a/tests/corpus/case_002/input.txt b/tests/corpus/case_002/input.txt new file mode 100644 index 0000000..f36bb0b --- /dev/null +++ b/tests/corpus/case_002/input.txt @@ -0,0 +1 @@ +Die Europäische Zentralbank hat im April 2024 erstmals seit Jahren den Leitzins gesenkt. Der Schritt um 0,25 Prozentpunkte war das Ergebnis monatelanger Beratungen im EZB-Rat. Die EZB hat ihren Sitz in Frankfurt am Main und ist für die Geldpolitik im Euroraum zuständig. Insgesamt umfasst die Eurozone 20 Mitgliedsstaaten. diff --git a/tests/corpus/case_002/notes.md b/tests/corpus/case_002/notes.md new file mode 100644 index 0000000..3149361 --- /dev/null +++ b/tests/corpus/case_002/notes.md @@ -0,0 +1,13 @@ +# Case 002 — EZB Leitzins 2024 + +## Fehler +Der Text behauptet, die erste Zinssenkung war im **April** 2024. Tatsächlich senkte die EZB den +Leitzins erstmals im **Juni 2024** (Sitzung vom 6. Juni 2024). + +## Quelle +- EZB-Pressemitteilung vom 6. Juni 2024 + +## Korrekte Fakten im Text +- EZB-Sitz Frankfurt am Main ✓ +- Eurozone: 20 Mitgliedsstaaten (seit Kroatien 2023) ✓ +- Senkung um 0,25 Prozentpunkte ✓ diff --git a/tests/corpus/case_003/expected.json b/tests/corpus/case_003/expected.json new file mode 100644 index 0000000..1f1c11f --- /dev/null +++ b/tests/corpus/case_003/expected.json @@ -0,0 +1,19 @@ +{ + "claims": [ + { + "text_contains": "20. Juli 1969", + "expected_status": "supported", + "note": "Armstrong betrat den Mond am 20. Juli 1969 (UTC) — korrekter Fakt" + }, + { + "text_contains": "Apollo 11", + "expected_status": "supported", + "note": "Korrekte Missionsbezeichnung" + }, + { + "text_contains": "Collins im Mondorbit", + "expected_status": "supported", + "note": "Collins blieb tatsächlich im Mondorbit — korrekter Fakt" + } + ] +} diff --git a/tests/corpus/case_003/input.txt b/tests/corpus/case_003/input.txt new file mode 100644 index 0000000..eba34ac --- /dev/null +++ b/tests/corpus/case_003/input.txt @@ -0,0 +1 @@ +Am 20. Juli 1969 betrat Neil Armstrong als erster Mensch den Mond. Die Mission trug den Namen Apollo 11. Zusammen mit ihm flogen Buzz Aldrin und Michael Collins zum Mond, wobei Collins im Mondorbit verblieb und nicht auf der Oberfläche landete. Die NASA ist die US-amerikanische Raumfahrtbehörde mit Hauptsitz in Washington D.C. diff --git a/tests/corpus/case_003/notes.md b/tests/corpus/case_003/notes.md new file mode 100644 index 0000000..bb6257c --- /dev/null +++ b/tests/corpus/case_003/notes.md @@ -0,0 +1,16 @@ +# Case 003 — Mondlandung Apollo 11 + +## Hinweis +Dieser Fall enthält überwiegend korrekte Fakten. Er dient als Negativtest: Der Fact-Checker +sollte keine falschen Widerlegungen produzieren (False Positives). + +## Alle Fakten +- Armstrong erster Mensch auf dem Mond: ✓ +- Datum 20. Juli 1969 (UTC): ✓ +- Missionsname Apollo 11: ✓ +- Collins im Mondorbit: ✓ +- NASA-Hauptsitz Washington D.C.: ✓ (Johnson Space Center ist in Houston, aber HQ ist D.C.) + +## Erwartetes Verhalten +Alle prüfbaren Claims sollten als `supported` bewertet werden. +Der Test schlägt fehl wenn ein Claim fälschlicherweise als `contradicted` markiert wird. diff --git a/tests/corpus/case_004/expected.json b/tests/corpus/case_004/expected.json new file mode 100644 index 0000000..12e522f --- /dev/null +++ b/tests/corpus/case_004/expected.json @@ -0,0 +1,24 @@ +{ + "claims": [ + { + "text_contains": "90 Millionen", + "expected_status": "contradicted", + "note": "Deutschland hatte Ende 2023 ca. 84,7 Millionen Einwohner, nicht 90 Millionen" + }, + { + "text_contains": "16 Bundesländer", + "expected_status": "supported", + "note": "Deutschland hat 16 Bundesländer — korrekter Fakt" + }, + { + "text_contains": "drei Stadtstaaten", + "expected_status": "supported", + "note": "Berlin, Hamburg, Bremen sind die drei Stadtstaaten — korrekter Fakt" + }, + { + "text_contains": "größte Stadt", + "expected_status": "supported", + "note": "Berlin ist die größte Stadt Deutschlands — korrekter Fakt" + } + ] +} diff --git a/tests/corpus/case_004/input.txt b/tests/corpus/case_004/input.txt new file mode 100644 index 0000000..8aa074a --- /dev/null +++ b/tests/corpus/case_004/input.txt @@ -0,0 +1 @@ +Deutschland ist das bevölkerungsreichste Land der Europäischen Union. Nach Angaben des Statistischen Bundesamtes lebten Ende 2023 rund 90 Millionen Menschen in Deutschland. Das Land besteht aus 16 Bundesländern, davon sind drei Stadtstaaten: Berlin, Hamburg und Bremen. Die Hauptstadt Berlin ist zugleich die größte Stadt Deutschlands. diff --git a/tests/corpus/case_004/notes.md b/tests/corpus/case_004/notes.md new file mode 100644 index 0000000..7219f91 --- /dev/null +++ b/tests/corpus/case_004/notes.md @@ -0,0 +1,14 @@ +# Case 004 — Bevölkerung Deutschland + +## Fehler +Der Text behauptet 90 Millionen Einwohner. Laut Destatis lebten Ende 2023 ca. **84,7 Millionen** +Menschen in Deutschland. + +## Quelle +- Destatis Bevölkerungsstand: https://www.destatis.de/DE/Themen/Gesellschaft-Umwelt/Bevoelkerung/ + +## Korrekte Fakten +- bevölkerungsreichstes EU-Land ✓ +- 16 Bundesländer ✓ +- 3 Stadtstaaten (Berlin, Hamburg, Bremen) ✓ +- Berlin größte Stadt ✓ diff --git a/tests/corpus/case_005/expected.json b/tests/corpus/case_005/expected.json new file mode 100644 index 0000000..9ef3d24 --- /dev/null +++ b/tests/corpus/case_005/expected.json @@ -0,0 +1,19 @@ +{ + "claims": [ + { + "text_contains": "70 Prozent", + "expected_status": "contradicted", + "note": "Anteil erneuerbarer Energien 2023 lag bei ca. 59-62%, nicht 70%" + }, + { + "text_contains": "letzten drei Kernkraftwerke", + "expected_status": "supported", + "note": "Deutschland hat am 15. April 2023 die letzten 3 AKW abgeschaltet — korrekter Fakt" + }, + { + "text_contains": "April 2023", + "expected_status": "supported", + "note": "Abschaltdatum April 2023 ist korrekt" + } + ] +} diff --git a/tests/corpus/case_005/input.txt b/tests/corpus/case_005/input.txt new file mode 100644 index 0000000..3e72c97 --- /dev/null +++ b/tests/corpus/case_005/input.txt @@ -0,0 +1 @@ +Im Jahr 2023 deckten erneuerbare Energien rund 70 Prozent des deutschen Stromverbrauchs ab. Windkraft war dabei die wichtigste Quelle, gefolgt von Photovoltaik und Biomasse. Deutschland hat im April 2023 seine letzten drei Kernkraftwerke vom Netz genommen. Der Ausbau erneuerbarer Energien ist zentrales Ziel der deutschen Energiepolitik. diff --git a/tests/corpus/case_005/notes.md b/tests/corpus/case_005/notes.md new file mode 100644 index 0000000..f587b92 --- /dev/null +++ b/tests/corpus/case_005/notes.md @@ -0,0 +1,15 @@ +# Case 005 — Erneuerbare Energien Deutschland 2023 + +## Fehler +Der Text behauptet 70% Anteil erneuerbarer Energien am Stromverbrauch 2023. +Laut Bundesnetzagentur / Fraunhofer ISE lag der Anteil 2023 bei ca. **59,0%** (Stromerzeugung) +bzw. ~52% am Verbrauch. + +## Quelle +- Fraunhofer ISE Energy-Charts +- Bundesnetzagentur Jahresbericht 2023 + +## Korrekte Fakten +- AKW-Abschaltung April 2023 ✓ +- Windkraft wichtigste Quelle ✓ +- Reihenfolge Wind > PV > Biomasse (ungefähr) ✓ diff --git a/tests/corpus/case_006/expected.json b/tests/corpus/case_006/expected.json new file mode 100644 index 0000000..40d7539 --- /dev/null +++ b/tests/corpus/case_006/expected.json @@ -0,0 +1,24 @@ +{ + "claims": [ + { + "text_contains": "75.000 US-Dollar", + "expected_status": "contradicted", + "note": "Bitcoin ATH Nov. 2021 war ca. $68.000-69.000, nicht $75.000" + }, + { + "text_contains": "2009", + "expected_status": "supported", + "note": "Bitcoin wurde 2009 eingeführt — korrekter Fakt" + }, + { + "text_contains": "21 Millionen", + "expected_status": "supported", + "note": "Bitcoin-Supply-Cap ist 21 Mio — korrekter Fakt" + }, + { + "text_contains": "Proof-of-Work", + "expected_status": "supported", + "note": "Bitcoin verwendet PoW — korrekter Fakt" + } + ] +} diff --git a/tests/corpus/case_006/input.txt b/tests/corpus/case_006/input.txt new file mode 100644 index 0000000..9a8b97d --- /dev/null +++ b/tests/corpus/case_006/input.txt @@ -0,0 +1 @@ +Bitcoin erreichte im November 2021 ein Allzeithoch von rund 75.000 US-Dollar. Die Kryptowährung wurde 2009 von einer Person oder Gruppe unter dem Pseudonym Satoshi Nakamoto eingeführt. Die Gesamtmenge an Bitcoin ist auf 21 Millionen Einheiten begrenzt. Bitcoin verwendet zur Absicherung der Transaktionen die Proof-of-Work-Methode. diff --git a/tests/corpus/case_006/notes.md b/tests/corpus/case_006/notes.md new file mode 100644 index 0000000..aadf623 --- /dev/null +++ b/tests/corpus/case_006/notes.md @@ -0,0 +1,14 @@ +# Case 006 — Bitcoin Allzeithoch 2021 + +## Fehler +Das Bitcoin-ATH im November 2021 lag bei ca. **$68.789** (10. November 2021), nicht bei $75.000. +Das tatsächliche Allzeithoch von ~$73.000+ wurde erst im März 2024 erreicht. + +## Quelle +- CoinMarketCap historische Daten + +## Korrekte Fakten +- Einführungsjahr 2009 ✓ +- Satoshi Nakamoto Pseudonym ✓ +- Supply-Cap 21 Millionen ✓ +- Proof-of-Work ✓ diff --git a/tests/corpus/case_007/expected.json b/tests/corpus/case_007/expected.json new file mode 100644 index 0000000..d7b66a4 --- /dev/null +++ b/tests/corpus/case_007/expected.json @@ -0,0 +1,19 @@ +{ + "claims": [ + { + "text_contains": "Dezember 2020", + "expected_status": "supported", + "note": "FDA EUA für BioNTech/Pfizer war 11. Dezember 2020 — korrekter Fakt" + }, + { + "text_contains": "95 Prozent", + "expected_status": "supported", + "note": "Studien zeigten ~95% Wirksamkeit (Phase-3-Studie) — korrekter Fakt" + }, + { + "text_contains": "Mainz", + "expected_status": "supported", + "note": "BioNTech-Sitz ist Mainz — korrekter Fakt" + } + ] +} diff --git a/tests/corpus/case_007/input.txt b/tests/corpus/case_007/input.txt new file mode 100644 index 0000000..6c3aa62 --- /dev/null +++ b/tests/corpus/case_007/input.txt @@ -0,0 +1 @@ +Der mRNA-Impfstoff gegen COVID-19 von BioNTech und Pfizer erhielt im Dezember 2020 in den USA die Notfallzulassung der FDA. In der EU folgte die Zulassung durch die Europäische Arzneimittel-Agentur EMA kurz darauf. Die klinischen Studien zeigten eine Wirksamkeit von rund 95 Prozent gegen schwere Verläufe. BioNTech hat seinen Sitz in Mainz, Deutschland. diff --git a/tests/corpus/case_007/notes.md b/tests/corpus/case_007/notes.md new file mode 100644 index 0000000..b5badf1 --- /dev/null +++ b/tests/corpus/case_007/notes.md @@ -0,0 +1,13 @@ +# Case 007 — COVID Impfstoff Zulassung (Negativtest) + +## Hinweis +Dieser Fall enthält ausschließlich korrekte Fakten. Dient als Negativtest für False Positives. + +## Alle Fakten korrekt +- FDA EUA: 11. Dezember 2020 ✓ +- EMA-Zulassung folgte kurz darauf (21. Dezember 2020) ✓ +- Wirksamkeit ~95% (Phase-3-Studie Polack et al., NEJM 2020) ✓ +- BioNTech-Sitz Mainz ✓ + +## Erwartetes Verhalten +Alle Claims sollten als `supported` bewertet werden. diff --git a/tests/corpus/case_008/expected.json b/tests/corpus/case_008/expected.json new file mode 100644 index 0000000..409022b --- /dev/null +++ b/tests/corpus/case_008/expected.json @@ -0,0 +1,19 @@ +{ + "claims": [ + { + "text_contains": "500 Milliarden", + "expected_status": "contradicted", + "note": "Bundeshaushalt 2024 hatte ein Volumen von ca. 476-477 Mrd. Euro, nicht 500 Mrd." + }, + { + "text_contains": "Bundesverfassungsgerichts", + "expected_status": "supported", + "note": "BVerfG-Urteil zur Schuldenbremse führte zu Haushaltsumstrukturierung — korrekter Fakt" + }, + { + "text_contains": "Christian Lindner", + "expected_status": "mixed", + "note": "Lindner war Finanzminister 2024 (korrekt), aber Perplexity verwirrt 2024/2025 Haushaltsjahr" + } + ] +} diff --git a/tests/corpus/case_008/input.txt b/tests/corpus/case_008/input.txt new file mode 100644 index 0000000..c7dab93 --- /dev/null +++ b/tests/corpus/case_008/input.txt @@ -0,0 +1 @@ +Der Bundeshaushalt 2024 umfasst ein Gesamtvolumen von rund 500 Milliarden Euro. Der Haushalt wurde nach dem Urteil des Bundesverfassungsgerichts zur Schuldenbremse erheblich umstrukturiert. Bundesfinanzminister Christian Lindner legte den Haushalt vor, der anschließend im Bundestag beschlossen wurde. Deutschland ist nach dem EU-Recht verpflichtet, die Vorgaben des Stabilitäts- und Wachstumspaktes einzuhalten. diff --git a/tests/corpus/case_008/notes.md b/tests/corpus/case_008/notes.md new file mode 100644 index 0000000..b6ee7fa --- /dev/null +++ b/tests/corpus/case_008/notes.md @@ -0,0 +1,14 @@ +# Case 008 — Bundeshaushalt 2024 + +## Fehler +Der Bundeshaushalt 2024 hatte ein Volumen von ca. **476,8 Milliarden Euro** (beschlossen), +nicht 500 Milliarden. + +## Quelle +- BMF: Bundeshaushalt 2024 +- Bundestag-Beschluss vom 2. Februar 2024 + +## Korrekte Fakten +- BVerfG-Urteil zur Schuldenbremse hat Haushalt beeinflusst ✓ +- Christian Lindner war Bundesfinanzminister (bis November 2024) ✓ +- EU-Stabilitätspakt-Pflicht ✓ diff --git a/tests/corpus/case_009/expected.json b/tests/corpus/case_009/expected.json new file mode 100644 index 0000000..27bd5e4 --- /dev/null +++ b/tests/corpus/case_009/expected.json @@ -0,0 +1,19 @@ +{ + "claims": [ + { + "text_contains": "1,5 Grad", + "expected_status": "supported", + "note": "1,5°C-Ziel ist im Pariser Abkommen verankert — korrekter Fakt" + }, + { + "text_contains": "2015", + "expected_status": "supported", + "note": "Pariser Abkommen wurde 2015 verabschiedet — korrekter Fakt" + }, + { + "text_contains": "zweimal beigetreten und einmal ausgetreten", + "expected_status": "contradicted", + "note": "USA traten 2026 (Trump 2. Amtszeit) ein zweites Mal aus — Behauptung jetzt veraltet/falsch" + } + ] +} diff --git a/tests/corpus/case_009/input.txt b/tests/corpus/case_009/input.txt new file mode 100644 index 0000000..7ea2a2b --- /dev/null +++ b/tests/corpus/case_009/input.txt @@ -0,0 +1 @@ +Das Pariser Klimaabkommen von 2015 hat das Ziel, die Erderwärmung auf möglichst 1,5 Grad Celsius gegenüber dem vorindustriellen Niveau zu begrenzen. Das Abkommen wurde von fast allen Ländern der Welt unterzeichnet. Es ist das erste universell verbindliche globale Klimaabkommen. Die USA sind dem Abkommen zweimal beigetreten und einmal ausgetreten. diff --git a/tests/corpus/case_009/notes.md b/tests/corpus/case_009/notes.md new file mode 100644 index 0000000..7d24ce6 --- /dev/null +++ b/tests/corpus/case_009/notes.md @@ -0,0 +1,13 @@ +# Case 009 — Pariser Klimaabkommen (Negativtest) + +## Hinweis +Dieser Fall enthält überwiegend korrekte Fakten. Negativtest für False Positives. + +## Alle Fakten +- 1,5°C-Ziel: ✓ (Art. 2 des Abkommens) +- Verabschiedung 2015: ✓ (COP21, 12. Dezember 2015) +- Fast alle Länder unterzeichnet: ✓ (196 Vertragsparteien) +- USA zweimal beigetreten/einmal ausgetreten: ✓ (Obama → Trump Austritt → Biden Wiederbeitritt) + +## Erwartetes Verhalten +Alle Claims sollten als `supported` bewertet werden. diff --git a/tests/corpus/case_010/expected.json b/tests/corpus/case_010/expected.json new file mode 100644 index 0000000..6590ffb --- /dev/null +++ b/tests/corpus/case_010/expected.json @@ -0,0 +1,19 @@ +{ + "claims": [ + { + "text_contains": "8 Milliarden", + "expected_status": "supported", + "note": "Weltbevölkerung überschritt 8 Mrd. am 15. November 2022 — korrekter Fakt" + }, + { + "text_contains": "9 Milliarden", + "expected_status": "mixed", + "note": "UN-Prognosen für 2050 schwanken zwischen 9,5 und 10 Mrd. — '9 Mrd.' ist eine Untertreibung" + }, + { + "text_contains": "Indien", + "expected_status": "supported", + "note": "Indien überholte China 2023 als bevölkerungsreichstes Land — korrekter Fakt" + } + ] +} diff --git a/tests/corpus/case_010/input.txt b/tests/corpus/case_010/input.txt new file mode 100644 index 0000000..b3380a2 --- /dev/null +++ b/tests/corpus/case_010/input.txt @@ -0,0 +1 @@ +Die Weltbevölkerung überschritt im Jahr 2022 die Marke von 8 Milliarden Menschen. Experten gehen davon aus, dass bis 2050 rund 9 Milliarden Menschen auf der Erde leben werden. Das bevölkerungsreichste Land der Welt ist Indien, das China im Jahr 2023 überholte. Die Vereinten Nationen schätzen, dass die Weltbevölkerung um das Jahr 2080 ihren Höhepunkt erreichen wird. diff --git a/tests/corpus/case_010/notes.md b/tests/corpus/case_010/notes.md new file mode 100644 index 0000000..a55c2d7 --- /dev/null +++ b/tests/corpus/case_010/notes.md @@ -0,0 +1,18 @@ +# Case 010 — Weltbevölkerung + +## Potenzielle Fehler +Der Text nennt "9 Milliarden bis 2050" — tatsächlich prognostizieren UN-Projektionen für 2050 +eher **9,7–10,4 Milliarden**. Die Zahl 9 Mrd. ist daher eher zu niedrig. + +Das Wachstumsplateau wird von UN auf ca. **2086** (Mittelprojektion) geschätzt, nicht "2080". + +## Quelle +- UN DESA World Population Prospects 2022 + +## Korrekte Fakten +- 8-Milliarden-Marke im November 2022 ✓ +- Indien hat China 2023 überholt ✓ + +## Hinweis +Dieser Fall testet ob der Checker auch moderate Ungenauigkeiten (nicht nur grobe Falschaussagen) +erkennt. Status `mixed` für die 9-Mrd.-Prognose ist akzeptabel. diff --git a/tests/run_corpus.sh b/tests/run_corpus.sh new file mode 100755 index 0000000..1ef1076 --- /dev/null +++ b/tests/run_corpus.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# tests/run_corpus.sh +# Führt alle Testkorpus-Fälle durch verify-article und berechnet Precision/Recall. +# +# Verwendung: +# cd ~/Pi_Agent_Projekts/text_agent +# bash tests/run_corpus.sh # Alle Fälle +# bash tests/run_corpus.sh case_001 case_002 # Nur bestimmte Fälle +# bash tests/run_corpus.sh --mode deep # Perplexity-Modus +# bash tests/run_corpus.sh --no-cache # Cache umgehen +# +# Ausgabe: +# tests/results// ← JSON-Reports pro Fall +# tests/results//summary.txt ← Precision/Recall-Zusammenfassung + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CORPUS_DIR="${SCRIPT_DIR}/corpus" +AGENT="${SCRIPT_DIR}/../agenten/llama-verify-article.ts" +TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)" +RESULTS_DIR="${SCRIPT_DIR}/results/${TIMESTAMP}" + +# --------------------------------------------------------------------------- +# Argument-Parsing +# --------------------------------------------------------------------------- + +MODE="fast" +EXTRA_FLAGS="" +SELECTED_CASES=() + +for arg in "$@"; do + case "$arg" in + --mode) shift; MODE="$1" ;; + --mode=*) MODE="${arg#--mode=}" ;; + --no-cache) EXTRA_FLAGS="${EXTRA_FLAGS} --no-cache" ;; + case_*) SELECTED_CASES+=("$arg") ;; + *) ;; + esac +done + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +mkdir -p "${RESULTS_DIR}" + +# Hilfsfunktionen +green() { echo -e "\033[0;32m$*\033[0m"; } +red() { echo -e "\033[0;31m$*\033[0m"; } +yellow() { echo -e "\033[0;33m$*\033[0m"; } +bold() { echo -e "\033[1m$*\033[0m"; } + +# --------------------------------------------------------------------------- +# Fälle ermitteln +# --------------------------------------------------------------------------- + +if [ ${#SELECTED_CASES[@]} -eq 0 ]; then + mapfile -t CASES < <(ls -d "${CORPUS_DIR}"/case_* 2>/dev/null | xargs -I{} basename {}) +else + CASES=("${SELECTED_CASES[@]}") +fi + +if [ ${#CASES[@]} -eq 0 ]; then + echo "Keine Fälle in ${CORPUS_DIR} gefunden." + exit 1 +fi + +bold "Pi Text-Agent — Testkorpus-Auswertung" +echo "Modus: ${MODE} | Fälle: ${#CASES[@]} | Ergebnisse: ${RESULTS_DIR}" +echo "" + +# --------------------------------------------------------------------------- +# Metriken (Globale Zähler) +# --------------------------------------------------------------------------- + +TOTAL_CLAIMS=0 +TRUE_POS=0 # Erwartet X → tatsächlich X +FALSE_POS=0 # Erwartet NOT contradicted → tatsächlich contradicted +FALSE_NEG=0 # Erwartet contradicted → tatsächlich NOT contradicted +TRUE_NEG=0 # Erwartet NOT contradicted → tatsächlich NOT contradicted + +CASE_PASS=0 +CASE_FAIL=0 +CASE_ERROR=0 + +TOTAL_COST=0 +TOTAL_TIME=0 + +# --------------------------------------------------------------------------- +# Pro-Fall-Verarbeitung +# --------------------------------------------------------------------------- + +for case_name in "${CASES[@]}"; do + case_dir="${CORPUS_DIR}/${case_name}" + input_file="${case_dir}/input.txt" + expected_file="${case_dir}/expected.json" + + if [ ! -f "${input_file}" ]; then + yellow " ${case_name}: input.txt nicht gefunden — übersprungen" + continue + fi + if [ ! -f "${expected_file}" ]; then + yellow " ${case_name}: expected.json nicht gefunden — übersprungen" + continue + fi + + echo -n " ${case_name}: " + result_file="${RESULTS_DIR}/${case_name}.json" + t_start=$(date +%s%3N) + + # verify-article aufrufen + if npx tsx "${AGENT}" \ + --mode "${MODE}" \ + --json \ + ${EXTRA_FLAGS} \ + "$(cat "${input_file}")" \ + > "${result_file}" 2>/dev/null; then + t_end=$(date +%s%3N) + elapsed_ms=$((t_end - t_start)) + else + t_end=$(date +%s%3N) + elapsed_ms=$((t_end - t_start)) + red "FEHLER (${elapsed_ms}ms)" + CASE_ERROR=$((CASE_ERROR + 1)) + echo " Fehlerhafter Exit-Code von verify-article" >> "${RESULTS_DIR}/errors.log" + continue + fi + + # Kosten aus Report + cost=$(python3 -c " +import json, sys +try: + r = json.load(open('${result_file}')) + print(r.get('totalCostUSD', 0)) +except: print(0) +" 2>/dev/null || echo "0") + TOTAL_COST=$(python3 -c "print(${TOTAL_COST} + ${cost})" 2>/dev/null || echo "${TOTAL_COST}") + TOTAL_TIME=$((TOTAL_TIME + elapsed_ms)) + + # Erwartungen prüfen + case_pass=true + claim_results="" + + while IFS= read -r expected_claim; do + text_contains=$(echo "${expected_claim}" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('text_contains',''))" 2>/dev/null) + expected_status=$(echo "${expected_claim}" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('expected_status',''))" 2>/dev/null) + note=$(echo "${expected_claim}" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('note',''))" 2>/dev/null) + + if [ -z "${text_contains}" ] || [ -z "${expected_status}" ]; then + continue + fi + + TOTAL_CLAIMS=$((TOTAL_CLAIMS + 1)) + + # Tatsächlichen Status aus Report ermitteln + actual_status=$(python3 -c " +import json, sys +try: + report = json.load(open('${result_file}')) + needle = '${text_contains}'.lower() + for r in report.get('results', []): + if needle in r.get('claim_text', '').lower(): + print(r.get('status', 'not_found')) + sys.exit(0) + print('not_found') +except Exception as e: + print('error') +" 2>/dev/null) + + # Metriken aktualisieren + if [ "${expected_status}" = "${actual_status}" ]; then + # Exakter Match + if [ "${expected_status}" = "contradicted" ]; then + TRUE_POS=$((TRUE_POS + 1)) + else + TRUE_NEG=$((TRUE_NEG + 1)) + fi + claim_results="${claim_results}\n ✓ [${actual_status}] ${text_contains:0:50}" + else + # Mismatch + case_pass=false + if [ "${expected_status}" = "contradicted" ] && [ "${actual_status}" != "contradicted" ]; then + FALSE_NEG=$((FALSE_NEG + 1)) + claim_results="${claim_results}\n ✗ Erwartet contradicted, bekam ${actual_status}: ${text_contains:0:50}" + elif [ "${expected_status}" != "contradicted" ] && [ "${actual_status}" = "contradicted" ]; then + FALSE_POS=$((FALSE_POS + 1)) + claim_results="${claim_results}\n ✗ Falsch widersprüchlich: ${text_contains:0:50}" + else + # z.B. supported vs mixed + claim_results="${claim_results}\n ~ Erwartet ${expected_status}, bekam ${actual_status}: ${text_contains:0:50}" + fi + fi + done < <(python3 -c " +import json +data = json.load(open('${expected_file}')) +for c in data.get('claims', []): + print(json.dumps(c)) +" 2>/dev/null) + + if [ "${case_pass}" = true ]; then + green "OK (${elapsed_ms}ms, \$${cost})" + CASE_PASS=$((CASE_PASS + 1)) + else + red "FEHLGESCHLAGEN (${elapsed_ms}ms, \$${cost})" + CASE_FAIL=$((CASE_FAIL + 1)) + fi + + if [ -n "${claim_results}" ]; then + echo -e "${claim_results}" + fi +done + +# --------------------------------------------------------------------------- +# Zusammenfassung +# --------------------------------------------------------------------------- + +echo "" +bold "==============================" +bold "Ergebnisse" +bold "==============================" +echo "" +echo "Fälle: ${CASE_PASS} OK | ${CASE_FAIL} fehlgeschlagen | ${CASE_ERROR} Fehler" +echo "Claims: ${TOTAL_CLAIMS} geprüft" +echo "" + +# Precision (wie viele der als contradicted markierten sind wirklich falsch) +if [ $((TRUE_POS + FALSE_POS)) -gt 0 ]; then + precision=$(python3 -c "print(f'{${TRUE_POS} / (${TRUE_POS} + ${FALSE_POS}) * 100:.1f}%')" 2>/dev/null || echo "n/a") +else + precision="n/a (keine contradicted-Urteile)" +fi + +# Recall (wie viele der wirklich falschen Claims wurden erkannt) +if [ $((TRUE_POS + FALSE_NEG)) -gt 0 ]; then + recall=$(python3 -c "print(f'{${TRUE_POS} / (${TRUE_POS} + ${FALSE_NEG}) * 100:.1f}%')" 2>/dev/null || echo "n/a") +else + recall="n/a (keine erwarteten contradicted-Claims)" +fi + +echo "Precision: ${precision} (Anteil korrekt widerlegter unter allen Widerlegungen)" +echo "Recall: ${recall} (Anteil erkannter Fehler unter allen bekannten Fehlern)" +echo "" +echo "True Positives: ${TRUE_POS} (korrekter contradicted-Fund)" +echo "False Positives: ${FALSE_POS} (fälschlich widerlegter korrekter Fakt)" +echo "False Negatives: ${FALSE_NEG} (nicht erkannter Fehler)" +echo "True Negatives: ${TRUE_NEG} (korrekt als nicht-widerlegbar bewertet)" +echo "" +echo "Kosten: \$${TOTAL_COST}" +echo "Zeit: $((TOTAL_TIME / 1000))s total" +echo "" +echo "Reports: ${RESULTS_DIR}/" + +# Zusammenfassung speichern +{ + echo "Testlauf: ${TIMESTAMP}" + echo "Modus: ${MODE}" + echo "Fälle: ${CASE_PASS} OK | ${CASE_FAIL} fehlgeschlagen | ${CASE_ERROR} Fehler" + echo "Claims: ${TOTAL_CLAIMS} geprüft" + echo "Precision: ${precision}" + echo "Recall: ${recall}" + echo "TP=${TRUE_POS} FP=${FALSE_POS} FN=${FALSE_NEG} TN=${TRUE_NEG}" + echo "Kosten: \$${TOTAL_COST}" + echo "Zeit: $((TOTAL_TIME / 1000))s" +} > "${RESULTS_DIR}/summary.txt" + +# Exit-Code: 0 wenn alle Fälle bestanden +if [ "${CASE_FAIL}" -gt 0 ] || [ "${CASE_ERROR}" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..348bc7f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["agenten/**/*.ts", "lib/**/*.ts", "schemas/**/*.ts", "types/**/*.d.ts"] +} diff --git a/types/pi-coding-agent.d.ts b/types/pi-coding-agent.d.ts new file mode 100644 index 0000000..c789a32 --- /dev/null +++ b/types/pi-coding-agent.d.ts @@ -0,0 +1,30 @@ +/** + * Lokaler Type-Stub für @mariozechner/pi-coding-agent + * Nur für TypeScript-Prüfung während der Entwicklung. + * Im Pi-Runtime wird das echte Paket aufgelöst. + */ +declare module "@mariozechner/pi-coding-agent" { + type ContentBlock = { type: "text"; text: string } | { type: string; [key: string]: unknown }; + + type ToolResult = { + content: ContentBlock[]; + details?: Record; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type ToolDefinition = { + name: string; + label?: string; + description: string; + promptGuidelines?: string[]; + parameters: unknown; + // Pi löst Parameter-Typen über TypeBox zur Laufzeit auf. + // Für lokale Entwicklung werden params als any akzeptiert. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + execute: (toolCallId: string, params: any, signal: AbortSignal) => Promise; + }; + + interface ExtensionAPI { + registerTool(tool: ToolDefinition): void; + } +}