From c2c16a3407f9cfbf3ef6487885ab8eb68b1d4ce8 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Sat, 9 May 2026 08:21:30 +0200 Subject: [PATCH] =?UTF-8?q?Spezifikation=20f=C3=BCr=20V3.3=20hinzugef?= =?UTF-8?q?=C3=BCgt=20(Kostentracker)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/V3_3_-_Spezifikation.md | 1592 ++++++++++++++++++++++++++++ 1 file changed, 1592 insertions(+) create mode 100644 docs/specs/V3_3_-_Spezifikation.md diff --git a/docs/specs/V3_3_-_Spezifikation.md b/docs/specs/V3_3_-_Spezifikation.md new file mode 100644 index 0000000..3aede7b --- /dev/null +++ b/docs/specs/V3_3_-_Spezifikation.md @@ -0,0 +1,1592 @@ +# V3.3 – Token- und Kosten-Tracking + +**Status:** Implementierungsvorbereitend – Code-Reads vor Implementierungsstart erforderlich (siehe Schlussabschnitt) +**Erstellt:** 2026-05-08 +**Aktualisiert:** 2026-05-08 (V6 nach fünftem Review) +**Autor:** Marcus (mit Claude als Mentor) + +--- + +## Freigabe-Gate + +> **Die Umsetzung darf nicht beginnen, solange einer der freigabe-blockierenden +> Code-Reads im Schlussabschnitt mit Status „offen" markiert ist.** Jeder +> dieser Reads muss vor Implementierungsstart auf „bestätigt" oder +> „abweichend (Spec angepasst auf: …)" gesetzt werden, einschließlich +> Anpassungen der betroffenen SQL-, Migrations-, Hook- und CLI-Abschnitte. +> Die Fertigstellungs-Checkliste enthält dafür einen eigenen Punkt: +> „Spec nach Code-Reads aktualisiert und freigegeben". + +--- + +## Ziel + +V3.3 bringt **#74 – Token- und Kosten-Tracking**: Die Anwendung erfasst pro +Verarbeitungsversuch die verbrauchten Input- und Output-Tokens beider +KI-Provider, persistiert sie zusammen mit einem Preis-Snapshot zum +Verarbeitungszeitpunkt in der Datenbank und stellt aggregierte Kosten +und Token-Statistiken in der GUI bereit – mit Sortierung und Pagination +zur Navigation auch in großen Datenbeständen. Zusätzlich enthält V3.3 +das GUI-Bugfix **#98 – Modell-Combobox-Filterung**, da der korrekte +Modellname als Join-Key zur Preistabelle für #74 unverzichtbar ist. +Drittes Element ist eine schlanke **CLI-Schnittstelle für Modell-Preise** +(#99), damit Headless-Nutzer Preise pflegen können. + +V3.3 ist ein **mittelgroßes Auswertungs-Release** (kein „kleines" +Token-Tracking-Release): Pagination, Sortierung, BigInteger-Aggregation, +CLI und Unknown-Provider-Handling kommen mit dazu. Es entstehen aber +keine Architekturausnahmen, keine neuen Module und keine +Bootstrap-Refactorings. + +### Arbeitspakete in V3.3 + +V3.3 wird in drei Arbeitspakete strukturiert. Jedes Paket hat eine +eigene Test-Sektion in der Produkttest-Matrix und eine eigene +Abnahme-Liste in der Fertigstellungs-Checkliste. + +| AP | Inhalt | Anteil | +|---|---|---| +| AP-A: Token- und Kosten-Tracking (#74 Kern) | Schema, Adapter-Token-Extraktion, Snapshot-Persistierung, History-Erweiterung, Summary-Banner | ~60% | +| AP-B: Kosten-Analyse mit Pagination (#74 GUI-Auswertung) | Tab Kosten-Analyse, Pagination, Sortierung, Mix-Status, Read-Transaction-Konsistenz | ~25% | +| AP-C: Modell-Filter (#98) und CLI (#99) | Filter im GUI-Catalog, CLI-Befehle, Headless-Hinweise | ~15% | + +Innerhalb von V3.3 wird AP-A vor AP-B und AP-C umgesetzt, weil das +Schema und die Datenbasis Voraussetzung sind. + +--- + +## Einordnung + +V3.2 ist der abgeschlossene Ausgangspunkt. Hexagonale Architektur, +Modulstruktur, headless-Betrieb, `.properties`-Konfigurationswahrheit, +Flyway-DB-Evolution und Scheduler-Mechanik bleiben als Grundprinzipien +vollständig erhalten. + +### Datenbankschema + +V3.3 enthält **eine Flyway-Migration `V2__token_tracking.sql`**: + +- Neue Token-Spalten und Preis-Snapshot-Spalten in `processing_attempt` +- Neue Tabelle `model_price` für persistierte Modell-Preise mit Composite Primary Key +- Vorausgefüllte Default-Preise für bekannte Modelle beider Provider + +**Verifizierter Stand:** Im aktuellen V3.2-Codestand existiert nur +`V1__initial_schema.sql` als einzige Flyway-Migration im gesamten Projekt. +`V2` ist daher die korrekte nächste Versionsnummer. + +### Keine neuen Maven-Module + +Token-Tracking, Filter und CLI werden in bestehende Module integriert: + +- `domain` / `application` → DTOs, Ports, Use Cases, `CostCalculator`, Read-Model in eigenem Subpaket +- `adapter-out` → SQLite-Implementierungen, Token-Extraktion in beiden KI-Adaptern +- `adapter-in-gui` → zwei neue Tabs, History-Erweiterung, Summary-Banner-Erweiterung, Modell-Filter +- `adapter-in-cli` (bestehend) → drei neue CLI-Befehle für Modell-Preise + +### Währung: USD + +Modell-Preise werden ausschließlich in **USD** gespeichert und angezeigt. +Begründung: Die Provider rechnen in USD ab. Eine Umrechnung in EUR ist +trügerisch, da der Wechselkurs zum Zeitpunkt des Token-Verbrauchs +irrelevant ist. + +### Speicherformat für Preise: Nano-USD pro Token (INTEGER) + +Modell-Preise werden in der DB als **Integer in Nano-USD pro Token** +gespeichert (1 Nano-USD = 10⁻⁹ USD). + +| Anbieter-Notation | Umrechnung in Nano-USD/Token | +|---|---| +| `$0.15 / 1M Tokens` (gpt-4o-mini Input) | `150` | +| `$5.00 / 1M Tokens` (claude-opus-4-7 Input) | `5000` | +| `$25.00 / 1M Tokens` (claude-opus-4-7 Output) | `25000` | + +### Eingabe-Umrechnungsregel `$/1M Tokens` ↔ Nano-USD/Token + +- **GUI- und CLI-Eingabe:** Dezimalwert in `$ pro 1M Tokens`, maximal 6 Nachkommastellen +- **Konvertierung in Nano-USD/Token:** `nanoPerToken = round(amountUsdPer1M × 1000)` mit `RoundingMode.HALF_UP` +- **Validierung im Use Case:** mehr als 6 Nachkommastellen → Validierungsfehler; nicht parsebar / negativ / über Maximum → Validierungsfehler + +### Wertgrenzen und Overflow-Strategie + +V3.3 wählt eine **gestufte Aggregations-Strategie**: SQL liefert Pro-Group- +Aggregate als `long`, Java summiert über mehrere Groups mit +`BigInteger`/`BigDecimal`. Diese Strategie ist **robust für realistische +Datenmengen** unter folgender expliziter Annahme: + +> **Annahme:** Pro `(fingerprint, ai_provider, model_name)`-Gruppe treten +> in der Praxis maximal etwa 100 Attempts auf. Bei dieser Größenordnung +> ist die SQL-`SUM`-Aggregation als `long` mit großer Reserve sicher. + +**Wertgrenzen pro Feld (DB-CHECK + Adapter-Validierung):** + +| Größe | Maximum | Verhalten bei Überschreitung | +|---|---|---| +| Token pro Feld (input/output/cache) | `10_000_000` (10 Mio) | Adapter setzt Feld auf NULL, WARN-Log | +| Nano-USD-Preis pro Token (input/output) | `100_000_000` (= $0,10/Token = $100.000/1M Tokens) | Use-Case-Validierung verweigert Speichern | + +**Aggregations-Ebenen:** + +| Ebene | Datentyp | Sicherheits-Reserve | +|---|---|---| +| Pro-Attempt-Kosten (`tokens × price`) | `long` (in SQL) | Max 10M × 100M = 10¹⁵ → Faktor ~9000 in long | +| Pro-Group-Aggregation (Σ über Attempts einer Group) | `long` (in SQL) | Bei 100 Attempts max 10¹⁷ → Faktor ~90 in long | +| Über-Group-Aggregation (Kopfzeile, Summary) | `BigInteger`/`BigDecimal` (in Java) | Strukturell unbegrenzt | + +**Was passiert, wenn die Annahme verletzt wird?** SQLite-`SUM` über +`long` kann bei extrem vielen Attempts pro Group still überlaufen. Der +Test `SqliteGroupAggregationOverflowGuardTest` (siehe Testliste) prüft, +dass Maximalwerte in einer realistisch dimensionierten Group keine +falschen Ergebnisse erzeugen. Bei nicht-realistischen Datenmengen (>1000 +Attempts pro Group) gilt das Verhalten als undefiniert; ein +entsprechender Fix wäre V3.x-Scope (Pro-Group-Aggregation in Java mit +BigInteger). + +### Cache-Tokens (Anthropic) + +V3.3 **persistiert** Cache-Tokens (`cache_creation_input_tokens`, +`cache_read_input_tokens`), berechnet aber **keine Cache-Kosten**. + +**Konsequenz für die Anzeige:** + +- Token-Spalten zeigen Standard-Tokens normal an (Cache nicht aufaddiert) +- Kostenspalte erhält Suffix `(ohne Cache-Anteil)` wenn Cache-Tokens vorkamen +- Banner über aggregierten Tabellen +- Tooltip listet alle gleichzeitigen Ursachen einzeln auf + +**Cache-only Attempts:** + +- **In Aggregations-Tabellen** (Kosten-Analyse, Run-Summary): in der Hauptaggregation **ausgeschlossen** (Einschlusskriterium: `input_tokens` ODER `output_tokens` nicht NULL) +- **Aber sichtbar gemacht über zusätzliche Zähl-Information:** + - Im Summary-Banner: zusätzliche Zeile `ℹ Cache-only Versuche: {n} (in Kosten nicht enthalten)` + - Im Kosten-Analyse-Tab: zusätzlicher Banner `ℹ {n} Cache-only Versuche im Zeitraum (in Kosten nicht enthalten)` +- **Im History-Tab:** sichtbar mit eigenem Status-Text „nur Cache-Tokens, keine Standardkosten" + +### Kein automatischer Preis-Update-Mechanismus + +Issue **#95** ist eigenständig angelegt, aber **nicht in V3.3**. + +### Headless-Betrieb mit CLI-Preisverwaltung + +Modell-Preise sind über GUI **und** CLI editierbar (siehe Abschnitt +„#99 – CLI für Modell-Preise" mit vollem CLI-Vertrag). + +--- + +## Scope + +### In V3.3 enthalten + +| # | Thema | AP | +|---|---|---| +| #74 | Token- und Kosten-Tracking inkl. Pagination und Sortierung | AP-A + AP-B | +| #98 | Modell-Combobox: Filterung ungeeigneter Modelle | AP-C | +| #99 | CLI-Befehle für Modell-Preise | AP-C | + +### Explizit nicht in V3.3 + +- Cache-Token-Auswertung in der Kostenberechnung → V3.x +- Automatischer Preis-Update-Mechanismus (#95) → V3.x +- Zeit-Kosten-Graph (#96) → V3.x +- Token-Schätzung für historische Einträge → bewusst nicht +- EUR-Umrechnung → bewusst nicht +- Letzte Konfiguration nicht geladen (#97) → wird **vor** V3.3 separat behoben +- Filter nach Banner-Status → V3.x +- Pro-Group-Aggregation in Java mit BigInteger (gegen extreme Datenmengen >1000 Attempts pro Group) → V3.x +- Cursor-basierte Pagination (Keyset-Pagination) → V3.x falls OFFSET-Performance kritisch wird + +--- + +## Unverrückbare Leitplanken + +- Java 21, Maven Multi-Module, hexagonale Architektur +- Shade-JAR als primäres Distributionsartefakt +- GUI ist Standardstart, `--headless` und CLI-Befehle bleiben vollständig erhalten +- `.properties` bleibt die einzige Konfigurationswahrheit +- Flyway ist die einzige Schema-Evolutionsquelle +- Kein JavaFX in `domain` oder `application` +- Modell-Preise sind **DB-only**, editierbar über GUI und CLI +- JavaDoc auf allen neuen öffentlichen Ports, Use-Cases, DTOs und öffentlichen Adapter-Methoden +- Notwendige Code-Kommentare auf Deutsch; Logging auf Deutsch +- Geldbeträge intern als `BigDecimal` mit voller Präzision; Persistierung als `INTEGER` in Nano-USD; Anzeigeformatierung **ausschließlich** im GUI-Layer auf vier Nachkommastellen +- Über-Group-Aggregationen erfolgen in Java mit `BigInteger`/`BigDecimal` +- Validierung von Domänen-Invarianten **immer** in der Application-Schicht (nicht ausschließlich GUI/CLI) +- Aggregierende Identität ist **(provider, model_name)**, nicht `model_name` allein +- Application-Read-Model-DTOs enthalten **keine** JavaFX-Typen, GUI-Strings oder Locale-Formatierungen +- **Lese-Konsistenz**: Eine vollständige Aktualisierung des Kosten-Analyse-Tabs erfolgt innerhalb **einer Read-Transaction** (siehe Abschnitt „Lese-Konsistenz") + +--- + +## Kosten-Semantik (Snapshot-Modell) + +> Zum Zeitpunkt jedes erfolgreichen KI-Aufrufs wird der dann gültige Modell-Preis +> aus `model_price` als **Snapshot** zusammen mit den Token-Counts in +> `processing_attempt` persistiert. Spätere Preisänderungen wirken **nicht** +> auf historische Kostenanzeigen. + +### Aggregationsregel + +Werden Versuche derselben **(Fingerprint × Provider × Modell)**-Gruppe oder +desselben `run_id` aggregiert, werden Kosten **pro Attempt mit dessen +eigenem Snapshot-Preis** berechnet und anschließend summiert: + +``` +Gesamtkosten = Σᵢ ( inputTokensᵢ × snapshotPriceInputᵢ + outputTokensᵢ × snapshotPriceOutputᵢ ) / 10⁹ +``` + +Architektur: +- **SQL berechnet Pro-Attempt-Kosten und aggregiert pro Group** zu Long-Werten +- **Java aggregiert über alle Groups** in `BigInteger`, konvertiert zu `BigDecimal`-USD +- Der `CostCalculator` interpretiert die aggregierten Rohkosten und bestimmt Statusflags + +### Provider als Aggregationsschlüssel + +`(provider, model_name)` ist Composite Primary Key in `model_price`, +also auch Aggregationsschlüssel: + +- **Cost-Analysis-Aggregation:** `(fingerprint, ai_provider, model_name)` +- **Run-Summary-Aggregation:** `(ai_provider, model_name)` +- DTOs tragen `provider` als Feld; GUI zeigt Provider als Tooltip am Modellnamen + +### V3.3-Provider-Whitelist + +V3.3 unterstützt fachlich **nur** `openai-compatible` und `anthropic`. +Die DB ist bewusst offen (keine CHECK-Constraint auf `provider`). +Falls in `model_price` ein unbekannter Provider-Wert vorhanden ist, +zeigt die GUI ihn **read-only** mit Hinweis „Unbekannter Provider" an. +Die CLI verhält sich analog (siehe „#99 – CLI für Modell-Preise"). + +--- + +## Lese-Konsistenz + +Eine vollständige Aktualisierung des Kosten-Analyse-Tabs umfasst mehrere +Queries (Page, Total-Count, Totals, Cache-only-Count). Damit Kopfzeile +und Tabelle zueinander passen, läuft die Aktualisierung in **einer +Read-Transaction**: + +``` +connection.setAutoCommit(false); +connection.setReadOnly(true); +try { + page = readPage(...); + totals = readTotals(...); + totalCount = readTotalCount(...); + cacheOnly = readCacheOnlyCount(...); + connection.commit(); // Read-Only: rein zur Snapshot-Beendigung +} catch (...) { + connection.rollback(); + throw ...; +} +``` + +**Regeln:** + +- WAL-Modus stellt sicher, dass Reader nicht durch Writer blockiert werden +- Innerhalb der Transaction sehen alle Queries denselben SQLite-Snapshot +- Die Transaction ist kurzlebig (typisch < 1 Sekunde); bei `database is locked` + greift der `busy_timeout` von 5 Sekunden +- Failed-Transaction → deutsche GUI-Fehlermeldung, vorigen Tab-Zustand behalten +- Fortgesetzte Verarbeitung durch den Scheduler bleibt unbeeinträchtigt (WAL) + +--- + +## Anzeige-Semantik (zentral, gilt überall in der GUI) + +### Status-Tabelle pro Versuch / Aggregations-Zeile + +| Zustand (Flags) | Tokenanzeige | Kostenanzeige | Tooltip-Ursachen | +|---|---|---|---| +| `exact` (alle Daten vorhanden) | `1.234` | `$0.0023` | – | +| `partialTokens` ohne `missingPriceSnapshot`, Preis vorhanden | `1.234 / –` oder `– / 80` | `~$0.0012` | „Untergrenze geschätzt – ein Token-Wert fehlte." | +| `missingPriceSnapshot` ohne `partialTokens`, kein berechenbarer Anteil | `1.234` | `Preis fehlt` | „Zum Zeitpunkt des Aufrufs war kein Modell-Preis konfiguriert." | +| `missingPriceSnapshot` mit teilweise berechenbaren Kosten | `412.230` | `~$0.0780 (unvollständig)` | „X Versuche ohne Preis-Snapshot." | +| `partialTokens` UND `missingPriceSnapshot`, kein berechenbarer Anteil | `1.234 / –` | `unvollständig` | beide Ursachen | +| `partialTokens` UND `missingPriceSnapshot` mit berechenbarem Anteil | wie oben | `~$0.0012 (unvollständig)` | beide Ursachen | +| `noTokens` | `keine Token-Daten` | `keine Token-Daten` | „Tokens wurden nicht erfasst." | +| Cache-only (nur in History) | `nur Cache: 1.234` | `nur Cache-Tokens, keine Standardkosten` | „Cache-Tokens werden in V3.3 nicht in die Kostenberechnung einbezogen." | +| Versuch fehlgeschlagen vor API-Aufruf (nur in History) | `–` | `–` | – | +| `cacheTokensIgnored` zusätzlich | wie oben + Suffix `(ohne Cache-Anteil)` | Tooltip-Ursache `cacheTokensIgnored` ergänzt | +| Sehr kleiner Betrag (< $0.0001) | – | `< $0.0001` | – | + +### Banner über aggregierten Tabellen + +| Bedingung | Banner-Text | +|---|---| +| Mind. eine Zeile mit `partialTokens` | „⚠ {n} Versuche mit unvollständigen Token-Daten – Kosten als Untergrenze geschätzt" | +| Mind. eine Zeile mit `missingPriceSnapshot` | „⚠ {n} Versuche ohne Preis-Snapshot – Kosten unvollständig" | +| Mind. eine Zeile mit `cacheTokensIgnored` | „⚠ {n} Versuche enthalten Cache-Tokens, die nicht in die Kosten eingerechnet sind" | +| Cache-only Versuche im Zeitraum vorhanden | „ℹ {n} Cache-only Versuche im Zeitraum (in Kosten nicht enthalten)" | +| Page automatisch korrigiert (siehe „Page-Navigation") | „ℹ Page automatisch angepasst" | +| Mehrere Bedingungen gleichzeitig | Eine Bannerzeile pro Bedingung | + +**Banner sind reine Hinweisanzeigen ohne Klick-Interaktion in V3.3.** + +### Gesamt-Kosten-Kopfzeile + +Die Gesamt-Kosten-Kopfzeile bezieht sich auf den **vollständigen +Zeitraum** (nicht nur die aktuelle Page). Sie wird durch eine zweite +SQL-Aggregation pro `(ai_provider, model_name)` berechnet, deren +Ergebnisse in Java mit `BigInteger` summiert werden. Die Mix-Status-Logik +gilt analog zur Zell-Anzeige. + +### Formatierungsregeln + +- Tokenzahlen: deutsche Locale (`412.230`) +- Geldbeträge: US-Notation (`$0.0792`) +- 4 Nachkommastellen +- Beträge unterhalb $0.0001 als `< $0.0001` +- Niemals `$0.0000` für nicht-null-Beträge +- Tilde `~` markiert Untergrenzen +- Suffix `(ohne Cache-Anteil)` bei Cache-Token-Beteiligung +- Suffix `(unvollständig)` bei Mix-Status mit fehlendem Preis + +--- + +## #74 – Token- und Kosten-Tracking + +### Datenbankschema – `V2__token_tracking.sql` + +```sql +-- V2: Token-Erfassung mit Preis-Snapshot in processing_attempt; +-- neue model_price-Tabelle mit Composite Primary Key. +-- Verifizierter Stand: V1__initial_schema.sql ist die einzige bisherige +-- Migration im Projekt. + +ALTER TABLE processing_attempt + ADD COLUMN input_tokens INTEGER + CHECK (input_tokens IS NULL OR (input_tokens >= 0 AND input_tokens <= 10000000)); + +ALTER TABLE processing_attempt + ADD COLUMN output_tokens INTEGER + CHECK (output_tokens IS NULL OR (output_tokens >= 0 AND output_tokens <= 10000000)); + +ALTER TABLE processing_attempt + ADD COLUMN cache_creation_input_tokens INTEGER + CHECK (cache_creation_input_tokens IS NULL OR (cache_creation_input_tokens >= 0 AND cache_creation_input_tokens <= 10000000)); + +ALTER TABLE processing_attempt + ADD COLUMN cache_read_input_tokens INTEGER + CHECK (cache_read_input_tokens IS NULL OR (cache_read_input_tokens >= 0 AND cache_read_input_tokens <= 10000000)); + +ALTER TABLE processing_attempt + ADD COLUMN price_input_per_token_nano_usd INTEGER + CHECK (price_input_per_token_nano_usd IS NULL OR (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000)); + +ALTER TABLE processing_attempt + ADD COLUMN price_output_per_token_nano_usd INTEGER + CHECK (price_output_per_token_nano_usd IS NULL OR (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000)); + +CREATE TABLE model_price ( + provider TEXT NOT NULL, + model_name TEXT NOT NULL, + price_input_per_token_nano_usd INTEGER NOT NULL CHECK (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000), + price_output_per_token_nano_usd INTEGER NOT NULL CHECK (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000), + currency TEXT NOT NULL DEFAULT 'USD' CHECK (currency = 'USD'), + updated_at TEXT NOT NULL, + PRIMARY KEY (provider, model_name) +); + +CREATE INDEX idx_processing_attempt_started_at_provider_fp_model + ON processing_attempt (started_at, ai_provider, fingerprint, model_name); + +CREATE INDEX idx_processing_attempt_run_id_provider_model + ON processing_attempt (run_id, ai_provider, model_name); + +-- Default-Preise (Stand 2026-05-08, in Nano-USD pro Token) +-- Quellen (abgerufen 2026-05-08): +-- OpenAI: https://openai.com/api/pricing/ +-- Anthropic: https://www.anthropic.com/pricing +-- ON CONFLICT DO NOTHING: schützt vor manuell vorhandenen Default-Zeilen. +INSERT INTO model_price + (provider, model_name, price_input_per_token_nano_usd, price_output_per_token_nano_usd, currency, updated_at) +VALUES + ('openai-compatible', 'gpt-4o-mini', 150, 600, 'USD', '2026-05-08T00:00:00Z'), + ('openai-compatible', 'gpt-4o', 2500, 10000, 'USD', '2026-05-08T00:00:00Z'), + ('openai-compatible', 'gpt-4.1', 2000, 8000, 'USD', '2026-05-08T00:00:00Z'), + ('openai-compatible', 'gpt-4.1-mini', 400, 1600, 'USD', '2026-05-08T00:00:00Z'), + ('openai-compatible', 'gpt-4.1-nano', 100, 400, 'USD', '2026-05-08T00:00:00Z'), + ('openai-compatible', 'gpt-5', 1250, 10000, 'USD', '2026-05-08T00:00:00Z'), + ('openai-compatible', 'gpt-5-mini', 250, 2000, 'USD', '2026-05-08T00:00:00Z'), + ('anthropic', 'claude-haiku-4-5-20251001', 1000, 5000, 'USD', '2026-05-08T00:00:00Z'), + ('anthropic', 'claude-sonnet-4-6', 3000, 15000, 'USD', '2026-05-08T00:00:00Z'), + ('anthropic', 'claude-opus-4-7', 5000, 25000, 'USD', '2026-05-08T00:00:00Z') +ON CONFLICT (provider, model_name) DO NOTHING; +``` + +**`updated_at`-Lesepfad:** Adapter parst `Instant.parse(...)`. Bei +`DateTimeParseException`: WARN-Log, Adapter erzeugt ein +`ModelPriceView`-Objekt mit `updatedAt = null` und `invalidUpdatedAt = true`. + +### Architektur-Übersicht + +``` +[KI-Provider-Response] + ↓ (Tokens + ggf. Cache-Tokens) +[AnthropicClaudeHttpAdapter / OpenAiHttpAdapter] + ↓ extrahiert Tokens, validiert gegen Wertgrenzen +[AiInvocationSuccess (mit AiUsageMetadata)] + ↓ +[BatchRunProcessingUseCase] + ↓ (1) lädt Preis-Snapshot via ModelPriceRepository + ↓ (2) baut processing_attempt-Record mit Tokens + Snapshot-Preis + ↓ (3) persistiert atomar +[SQLite: processing_attempt] + + +[GUI Kosten-Analyse-Tab / History-Tab / Summary-Banner] + ↓ liest aggregierte Rohkosten + Flags + Pagination +[TokenStatisticsReadModelPort (application.reporting.costanalysis)] + ↓ liefert pro Aktualisierung: + ↓ - Page-Daten (Long-Aggregate je Group) + ↓ - Total-Count + ↓ - Gesamt-Aggregat (Pro-(Provider,Modell)-Long-Aggregate + ↓ → in Java mit BigInteger zur Kopfzeile summiert) + ↓ - Cache-only-Anzahl + ↓ alle in EINER Read-Transaction +[CostCalculator (Application)] + ↓ interpretiert Rohkosten: + ↓ Long-/BigInteger-Werte → BigDecimal-USD volle Präzision + ↓ setzt CostResult-Flags +[GUI rendert mit Anzeige-Semantik, Pagination, Sortierung] + ↓ formatiert auf 4 Nachkommastellen (CostFormatter) +``` + +### Adapter-Erweiterungen + +#### Anthropic-Adapter + +Neue Methode `extractTokenUsageFromResponse(JSONObject root)` extrahiert +`usage.input_tokens`, `usage.output_tokens`, +`usage.cache_creation_input_tokens`, `usage.cache_read_input_tokens`. + +Validierung: nicht numerisch / negativ / > 10 Mio → Feld NULL, WARN-Log. + +#### OpenAI-kompatibler Adapter + +Mapping `prompt_tokens → input_tokens`, `completion_tokens → output_tokens`. +Cache-Felder bleiben **immer NULL**. + +### Domain- und Application-Erweiterungen + +#### DTO `AiUsageMetadata` + +```java +package de.gecheckt.pdf.umbenenner.application.dto; + +public record AiUsageMetadata( + Long inputTokens, + Long outputTokens, + Long cacheCreationInputTokens, + Long cacheReadInputTokens +) { + public static AiUsageMetadata empty() { + return new AiUsageMetadata(null, null, null, null); + } + + public boolean hasAnyTokenData() { + return inputTokens != null || outputTokens != null; + } + + public boolean hasCacheTokens() { + return (cacheCreationInputTokens != null && cacheCreationInputTokens > 0) + || (cacheReadInputTokens != null && cacheReadInputTokens > 0); + } +} +``` + +#### Erweiterung `AiInvocationSuccess` + +```java +public record AiInvocationSuccess( + AiRequestRepresentation request, + AiRawResponse rawResponse, + AiUsageMetadata usageMetadata +) implements AiInvocationResult { ... } +``` + +#### DTOs `ModelPriceEntry` (Schreibpfad) und `ModelPriceView` (Lesepfad) + +```java +/** + * Schreib- und Validierungs-DTO. updatedAt non-null. + * Wird in ManageModelPricesUseCase für Inserts/Updates verwendet. + */ +public record ModelPriceEntry( + String provider, + String modelName, + long priceInputPerTokenNanoUsd, + long priceOutputPerTokenNanoUsd, + String currency, + Instant updatedAt +) { + public ModelPriceEntry { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(modelName, "modelName"); + Objects.requireNonNull(updatedAt, "updatedAt"); + if (provider.isBlank()) throw new IllegalArgumentException("provider darf nicht leer sein"); + if (modelName.isBlank()) throw new IllegalArgumentException("modelName darf nicht leer sein"); + if (priceInputPerTokenNanoUsd < 0) throw new IllegalArgumentException("Input-Preis darf nicht negativ sein"); + if (priceOutputPerTokenNanoUsd < 0) throw new IllegalArgumentException("Output-Preis darf nicht negativ sein"); + if (priceInputPerTokenNanoUsd > 100_000_000L) throw new IllegalArgumentException("Input-Preis überschreitet Maximum"); + if (priceOutputPerTokenNanoUsd > 100_000_000L) throw new IllegalArgumentException("Output-Preis überschreitet Maximum"); + if (!"USD".equals(currency)) throw new IllegalArgumentException("Nur Währung USD unterstützt"); + } +} + +/** + * Lese-/Anzeige-DTO. updatedAt nullable bei beschädigten DB-Werten. + * NICHT direkt im Schreibpfad verwendbar. + */ +public record ModelPriceView( + String provider, + String modelName, + long priceInputPerTokenNanoUsd, + long priceOutputPerTokenNanoUsd, + String currency, + Instant updatedAt, // null möglich + String invalidUpdatedAtRaw, // gesetzt wenn DB-Wert nicht parsebar + boolean invalidUpdatedAt +) {} +``` + +#### Outbound-Port `ModelPriceRepository` + +```java +package de.gecheckt.pdf.umbenenner.application.port.out; + +/** + * Repository für Modell-Preise. + * + * Schreibpfad-Konvention: GUI und CLI nutzen ausschließlich + * saveAllChanges(...) für transaktionale Batch-Speicherung. + * + * Lesemethoden liefern ModelPriceView (mit nullable updatedAt). + * Schreibmethoden akzeptieren ausschließlich ModelPriceEntry. + */ +public interface ModelPriceRepository { + List findAll(); + Optional findByProviderAndModelName(String provider, String modelName); + + /** @internal Nicht von GUI/CLI direkt verwenden – nur für Tests/Werkzeuge. */ + void upsert(ModelPriceEntry entry); + + /** @internal Nicht von GUI/CLI direkt verwenden – nur für Tests/Werkzeuge. */ + void delete(String provider, String modelName); + + /** + * Speichert eine Sammlung von Preisänderungen atomar. + * Validierung des ChangeSets erfolgt vor der Transaktion im Use Case. + */ + void saveAllChanges(ModelPriceChangeSet changeSet); +} + +public record ModelPriceChangeSet( + List upserts, + List deletions +) {} + +public record ModelPriceKey(String provider, String modelName) {} +``` + +**ChangeSet-Validierungsregeln (im `ManageModelPricesUseCase`):** + +| Regel | Verhalten | +|---|---| +| Ein Key (provider, modelName) darf in genau einer Operation vorkommen | Konflikt → Validierungsfehler | +| Doppelte Keys innerhalb `upserts` oder `deletions` | Validierungsfehler | +| Leeres ChangeSet | No-op; INFO-Log; keine Transaktion | +| „Ersetzen" (Wert eines Eintrags aktualisieren) | Erfolgt als Upsert, nicht Delete+Insert | + +#### Outbound-Port `TokenStatisticsReadModelPort` + +```java +package de.gecheckt.pdf.umbenenner.application.reporting.costanalysis; + +/** + * Read-Model-Port für aggregierte Token-Statistiken und Kosten. + * GUI-/Reporting-Read-Model. DTOs enthalten keine JavaFX-Typen, + * Locale-Formatierung oder GUI-Strings. + * + * Aggregationsschlüssel: + * - Cost-Analysis: (fingerprint, ai_provider, model_name) + * - Run-Summary: (ai_provider, model_name) + * + * Lese-Konsistenz: Eine Aktualisierung läuft in einer Read-Transaction + * (siehe Spec-Abschnitt "Lese-Konsistenz"). + */ +public interface TokenStatisticsReadModelPort { + + /** + * Liefert Page, Total-Count, Totals und Cache-only-Count gemeinsam + * in einer Read-Transaction. Damit ist die Gesamt-Kopfzeile + * konsistent mit der angezeigten Page. + * + * Pagination: pageNumber 0-basiert; pageSize aus {50,100,250,500}. + */ + CostAnalysisFullResult queryCostAnalysisFull( + Instant from, + Instant to, + int pageNumber, + int pageSize, + CostAnalysisSortField sortField, + SortDirection sortDirection); + + /** + * Liefert nur Totals + Cache-only-Count, ohne Page. + * Wird verwendet, wenn nur der Zeitraum geprüft werden soll. + */ + CostAnalysisHeaderResult queryCostAnalysisHeaderOnly(Instant from, Instant to); + + /** + * Liefert Token- und Kosten-Aggregation eines konkreten Laufs. + */ + RunSummaryResult queryRunSummary(String runId); +} +``` + +**Sortier-Enum:** + +```java +public enum CostAnalysisSortField { + LAST_STARTED_AT, + PROVIDER_AND_MODEL_NAME, + SOURCE_FILE_NAME, + NEWEST_TARGET_FILE_NAME, + SUM_INPUT_TOKENS, + SUM_OUTPUT_TOKENS, + SUM_TOTAL_COST_NANO_USD +} + +public enum SortDirection { ASC, DESC } +``` + +#### DTOs + +```java +public record CostAnalysisFullResult( + List rows, + int pageNumber, + int pageSize, + long totalRowCount, // Gesamtanzahl Groups im Zeitraum + CostAnalysisTotals totals // über vollständigen Zeitraum +) {} + +public record CostAnalysisHeaderResult( + long totalRowCount, + CostAnalysisTotals totals +) {} + +public record CostAnalysisRow( + String fingerprint, + String provider, + String modelName, + Instant lastStartedAt, + String sourceFileName, + String newestTargetFileName, + Long sumInputTokens, + Long sumOutputTokens, + Long sumCacheCreationInputTokens, + Long sumCacheReadInputTokens, + Long sumInputCostNanoUsd, + Long sumOutputCostNanoUsd, + boolean hasPartialTokenData, + boolean hasMissingPriceSnapshot, + boolean hasCacheTokensIgnored +) {} + +/** + * Über vollständigen Zeitraum aggregierte Werte. + * + * NULL-Semantik: BigInteger-Felder sind null, wenn KEIN Wert vorhanden war. + * Die hasAny...-Flags ermöglichen klare Unterscheidung zwischen + * "keine Werte", "Summe = 0", "alle Werte ohne Preis", "Mikrobetrag": + * - hasAnyInputTokens = false → totalInputTokens null UND keine Token-Daten + * - hasAnyInputCost = false → totalInputCostNanoUsd null UND kein berechenbarer Anteil + */ +public record CostAnalysisTotals( + BigInteger totalInputTokens, // null wenn hasAnyInputTokens=false + BigInteger totalOutputTokens, // null wenn hasAnyOutputTokens=false + BigInteger totalInputCostNanoUsd, // null wenn hasAnyInputCost=false + BigInteger totalOutputCostNanoUsd, // null wenn hasAnyOutputCost=false + boolean hasAnyInputTokens, + boolean hasAnyOutputTokens, + boolean hasAnyInputCost, + boolean hasAnyOutputCost, + long cacheOnlyAttemptCount, + boolean hasPartialTokenData, + boolean hasMissingPriceSnapshot, + boolean hasCacheTokensIgnored +) {} + +public record RunSummaryResult( + List rows, + long cacheOnlyAttemptCount +) {} + +public record RunModelTokenSummary( + String provider, + String modelName, + Long sumInputTokens, + Long sumOutputTokens, + Long sumInputCostNanoUsd, + Long sumOutputCostNanoUsd, + boolean hasPartialTokenData, + boolean hasMissingPriceSnapshot, + boolean hasCacheTokensIgnored +) {} +``` + +#### Application-Komponente `CostCalculator` + +```java +package de.gecheckt.pdf.umbenenner.application.cost; + +/** + * Interpretiert aggregierte Rohkosten in BigDecimal-USD und bestimmt + * Status-Flags. Führt KEINE Multiplikation Token×Preis durch. + */ +public final class CostCalculator { + + /** Pro Tabellen-Zeile: Long-Aggregate → CostResult. */ + public CostResult formatRow( + Long sumInputCostNanoUsd, + Long sumOutputCostNanoUsd, + boolean hasPartialTokenData, + boolean hasMissingPriceSnapshot, + boolean hasCacheTokensIgnored, + boolean hasAnyTokenData) { ... } + + /** Für Kopfzeile / Summary: BigInteger-Aggregate + hasAny-Flags → CostResult. */ + public CostResult formatTotal( + BigInteger totalInputCostNanoUsd, + BigInteger totalOutputCostNanoUsd, + boolean hasAnyInputCost, + boolean hasAnyOutputCost, + boolean hasAnyTokenData, + boolean hasPartialTokenData, + boolean hasMissingPriceSnapshot, + boolean hasCacheTokensIgnored) { ... } + + /** Für History-Tab: Einzelner Attempt. */ + public CostResult calculateAttempt( + Long inputTokens, + Long outputTokens, + Long priceInputPerTokenNanoUsd, + Long priceOutputPerTokenNanoUsd, + boolean hasCacheTokens) { ... } +} +``` + +DTO `CostResult`: + +```java +public record CostResult( + BigDecimal amountUsd, + boolean exact, + boolean partialTokens, + boolean missingPriceSnapshot, + boolean noTokens, + boolean cacheTokensIgnored, + boolean hasAnyCalculatedCost +) { ... } +``` + +**Mix-Status-Logik (Anzeige-Mapping):** wie in V5. + +**Keine interne Rundung.** Rundung auf 4 Stellen im GUI-`CostFormatter`. + +### Use Cases + +| Use Case | Zweck | +|---|---| +| `ManageModelPricesUseCase` | CRUD-Fassade, Validierung, transaktionaler Batch, ChangeSet-Konfliktregeln | +| `QueryCostAnalysisFullUseCase` | Ruft `queryCostAnalysisFull(...)`; mappt auf GUI-DTO; **validiert Page-Parameter (siehe unten)** | +| `QueryCostAnalysisHeaderOnlyUseCase` | Ruft `queryCostAnalysisHeaderOnly(...)`; ohne Page-Render | +| `QueryRunSummaryUseCase` | Ruft `queryRunSummary(...)`; aggregiert in BigInteger | + +**Page-Parameter-Validierung in `QueryCostAnalysisFullUseCase`:** + +| Regel | Verhalten | +|---|---| +| `pageNumber >= 0` | Sonst Validierungsfehler | +| `pageSize ∈ {50, 100, 250, 500}` | Sonst Validierungsfehler | +| `sortField != null`, `sortDirection != null` | Sonst Validierungsfehler | +| Offset-Berechnung mit `long` | `Math.multiplyExact(pageNumber, pageSize)` – wirft `ArithmeticException` bei Overflow | +| `pageNumber > letzte gültige Page` (nach Datenänderung) | Use Case lädt **letzte gültige Page**, setzt Hinweis-Flag `pageAdjusted=true` | +| Leerer Zeitraum (totalRowCount=0) | `pageNumber=0`, leere Page | + +### Persistenz-Adapter + +#### `SqliteModelPriceRepositoryAdapter` + +UPSERT via `INSERT ... ON CONFLICT(provider, model_name) DO UPDATE SET ...`. +`saveAllChanges(...)` läuft in einer JDBC-Transaktion mit +`autoCommit=false`. + +`updated_at`-Lesepfad: bei `DateTimeParseException` → `ModelPriceView` mit +`updatedAt=null`, `invalidUpdatedAt=true`, `invalidUpdatedAtRaw=originalString`. + +#### `SqliteTokenStatisticsReadModelAdapter` + +**Gemeinsame Query-Bausteine:** + +Um Drift zwischen den Queries zu vermeiden, werden gemeinsame +SQL-Fragmente als private Methoden im Adapter zentralisiert: + +```java +final class SqliteTokenStatisticsReadModelAdapter implements TokenStatisticsReadModelPort { + + /** Gemeinsame WHERE-Klausel für Zeitraum-Filter und Token-Einschluss. */ + private static final String WHERE_TIME_RANGE_AND_HAS_STD_TOKENS = """ + WHERE pa.started_at >= :from + AND pa.started_at < :to + AND (pa.input_tokens IS NOT NULL OR pa.output_tokens IS NOT NULL) + """; + + /** Gemeinsame CTE attempts_with_tokens. */ + private static final String CTE_ATTEMPTS_WITH_TOKENS = """ + WITH attempts_with_tokens AS ( + SELECT + pa.fingerprint, pa.ai_provider, pa.model_name, + pa.started_at, pa.id AS attempt_id, + pa.input_tokens, pa.output_tokens, + pa.cache_creation_input_tokens, pa.cache_read_input_tokens, + pa.price_input_per_token_nano_usd, pa.price_output_per_token_nano_usd, + pa.final_target_file_name, + CASE WHEN pa.input_tokens IS NOT NULL AND pa.price_input_per_token_nano_usd IS NOT NULL + THEN pa.input_tokens * pa.price_input_per_token_nano_usd + ELSE NULL + END AS attempt_cost_input_nano_usd, + CASE WHEN pa.output_tokens IS NOT NULL AND pa.price_output_per_token_nano_usd IS NOT NULL + THEN pa.output_tokens * pa.price_output_per_token_nano_usd + ELSE NULL + END AS attempt_cost_output_nano_usd + FROM processing_attempt pa + """ + WHERE_TIME_RANGE_AND_HAS_STD_TOKENS + ")"; +} +``` + +**Vier Queries innerhalb der Read-Transaction:** + +| Query | Zweck | +|---|---| +| (1) Page-Query | CTEs `attempts_with_tokens` + `latest_per_group` + `groups`; `ORDER BY ; LIMIT :pageSize OFFSET ...` | +| (2) Total-Count-Query | `SELECT COUNT(*) FROM (groups)` | +| (3) Totals-Query | Aggregation pro `(ai_provider, model_name)` für Java-BigInteger-Aufsummierung | +| (4) Cache-only-Count | Anzahl Attempts ohne Standard-Tokens, aber mit Cache-Tokens | + +**Sortier-Whitelist (Adapter):** + +| `CostAnalysisSortField` | SQL | +|---|---| +| `LAST_STARTED_AT` | `last_started_at` | +| `PROVIDER_AND_MODEL_NAME` | `provider, model_name` | +| `SOURCE_FILE_NAME` | `source_file_name` (NULLS LAST per `CASE WHEN ... IS NULL THEN 1 ELSE 0 END`) | +| `NEWEST_TARGET_FILE_NAME` | `newest_target_file_name` (NULLS LAST) | +| `SUM_INPUT_TOKENS` | `sum_input` (NULLS LAST) | +| `SUM_OUTPUT_TOKENS` | `sum_output` (NULLS LAST) | +| `SUM_TOTAL_COST_NANO_USD` | **zweistufig**: `(CASE WHEN sum_input_cost_nano_usd IS NULL AND sum_output_cost_nano_usd IS NULL THEN 1 ELSE 0 END) ASC, (COALESCE(sum_input_cost_nano_usd, 0) + COALESCE(sum_output_cost_nano_usd, 0)) ` | + +Die zweistufige Sortierung für Gesamt-Kosten stellt sicher, dass nicht +berechenbare Kosten unabhängig von ASC/DESC am Ende landen – nicht +fälschlich als „$0" einsortiert. + +Tie-Breaker bei gleichem Sort-Schlüssel: `fingerprint, provider, model_name` ASC. + +**Java-Aggregation der Totals (Pseudocode):** + +```java +BigInteger totalInputCostNanoUsd = null; +BigInteger totalOutputCostNanoUsd = null; +BigInteger totalInputTokens = null; +BigInteger totalOutputTokens = null; +boolean hasPartial = false, hasMissingPrice = false, hasCacheIgnored = false; + +for (ProviderModelTotalRow row : queryProviderModelTotals(...)) { + if (row.sumInputCostNanoUsd() != null) { + totalInputCostNanoUsd = (totalInputCostNanoUsd == null ? BigInteger.ZERO : totalInputCostNanoUsd) + .add(BigInteger.valueOf(row.sumInputCostNanoUsd())); + } + if (row.sumOutputCostNanoUsd() != null) { + totalOutputCostNanoUsd = (totalOutputCostNanoUsd == null ? BigInteger.ZERO : totalOutputCostNanoUsd) + .add(BigInteger.valueOf(row.sumOutputCostNanoUsd())); + } + if (row.sumInput() != null) totalInputTokens = (totalInputTokens == null ? BigInteger.ZERO : totalInputTokens) .add(BigInteger.valueOf(row.sumInput())); + if (row.sumOutput() != null) totalOutputTokens = (totalOutputTokens == null ? BigInteger.ZERO : totalOutputTokens).add(BigInteger.valueOf(row.sumOutput())); + hasPartial |= row.hasPartial(); + hasMissingPrice |= row.hasMissingPrice(); + hasCacheIgnored |= row.hasCacheIgnored(); +} + +return new CostAnalysisTotals( + totalInputTokens, totalOutputTokens, + totalInputCostNanoUsd, totalOutputCostNanoUsd, + /* hasAny... */ totalInputTokens != null, + totalOutputTokens != null, + totalInputCostNanoUsd != null, + totalOutputCostNanoUsd != null, + cacheOnlyCount, + hasPartial, hasMissingPrice, hasCacheIgnored +); +``` + +#### Performance-Hinweis Pagination + +Bei sehr tiefen OFFSETs wird die Query langsamer (SQLite muss bis OFFSET +hochzählen). Realistisch werden Nutzer bei großen Ergebnismengen den +Zeitraum einschränken oder sortieren. Cursor-basierte Pagination wäre +performanter; V3.x falls Performance-Probleme in der Praxis auftreten. + +#### SQLite-Concurrency mit Scheduler + +| Aspekt | Festlegung | +|---|---| +| Journal-Modus | `WAL` | +| Busy-Timeout | 5 Sekunden, **pro Connection** (`PRAGMA busy_timeout=5000`) | +| Lese-Transaktionen | **Read-Transaction für vollständige Tab-Aktualisierung**; sofort schließen nach Query-Block | +| Schreibpfad | Atomare Einzel-Insert | +| GUI bei `database is locked` | Deutsche Fehlermeldung; UI nicht blockiert | +| Sichtbarkeit laufender Runs | Versuche werden sichtbar sobald persistiert | +| RunLock-Mechanismus | Bleibt unverändert | +| SQL-Boolean-Resultsets | Adapter mappt via `getInt(...) != 0` | + +### Hook im `BatchRunProcessingUseCase` + +``` +1. AiInvocationPort.invoke(...) → AiInvocationResult +2. Bei AiInvocationSuccess: + a. Versuch: ModelPriceRepository.findByProviderAndModelName(...) + → Optional + b. Bei technischem Fehler beim Preis-Lookup: + → ERROR-Log + → Snapshot-Preis-Felder werden NULL gesetzt + → Attempt wird trotzdem persistiert + c. processing_attempt-Record bauen + d. ProcessingAttemptRepository.save(...) (atomar) +3. Bei AiInvocationTechnicalFailure: + - processing_attempt-Record mit Token- und Preis-Feldern auf NULL +4. Im Headless-Modus zusätzlich: Bei Modell ohne Preis-Eintrag + WARN-Log mit Hinweis auf CLI-Befehl --upsert-model-price +``` + +### GUI – Tab „Modell-Preise" + +``` +┌── Modell-Preise ──────────────────────────────────────────────────┐ +│ Provider Modellname In/1M Out/1M Währung ⌫ │ +│ ─────────────────────────────────────────────────────────────────│ +│ openai-compatible gpt-4o-mini $0.15 $0.60 USD ⌫ │ +│ ... │ +│ unknown-router ⓘ custom-model $1.00 $5.00 USD ⌫ │ +│ │ +│ [Modell hinzufügen] [Speichern] │ +└────────────────────────────────────────────────────────────────────┘ +``` + +- **Tabelle** in `$ pro 1M Tokens`; intern Nano-USD/Token. Eingabe max 6 Nachkommastellen. +- **Provider und Modellname** read-only nach Speichern (Composite Primary Key) +- **Unbekannte Provider** (nicht `openai-compatible`/`anthropic`): Zeile **read-only mit Tooltip-Icon** `ⓘ` „Unbekannter Provider – Bearbeitung in V3.3 nicht unterstützt"; **Löschen ist erlaubt** +- **Lösch-Button** mit Bestätigungsdialog +- **„Modell hinzufügen"**-Dialog: Provider-Combobox nur `openai-compatible` und `anthropic` +- **„Speichern"**: transaktionaler Batch +- **`updatedAt = null`** → Spalte zeigt „ungültig" mit Tooltip + +### GUI – Konfigurations-Tab Erweiterung + +Beim Speichern mit einem Modell ohne Preis-Eintrag: Warnung „Für dieses +Modell ist kein Preis hinterlegt; Tokens werden erfasst, Kosten können +jedoch nicht vollständig berechnet werden." Keine Blockade. + +### GUI – Tab „Kosten-Analyse" + +``` +┌── Kosten-Analyse ─────────────────────────────────────────────────┐ +│ Zeitraum: ( ) 24h (●) 7 Tage ( ) 1 Monat ( ) Benutzerdefiniert│ +│ Von: [____________] Bis einschließlich: [____________]│ +│ [Aktualisieren] │ +│ │ +│ Gesamt-Tokens: Input 412.230 Output 7.846 │ +│ Gesamt-Kosten: ~$0.0792 (unvollständig) │ +│ ⚠ 3 Versuche ohne Preis-Snapshot │ +│ ℹ 2 Cache-only Versuche (in Kosten nicht enthalten) │ +│ │ +│ Page-Größe: [ 100 ▼ ] Sortiert nach: Letztes Datum (absteigend) │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐│ +│ │ Letztes Datum ▼│ Quellname │ Neuster Name │ Modell ⓘ │ In │ Out │ Kosten ││ +│ │ ... ││ +│ └────────────────────────────────────────────────────────────────┘│ +│ │ +│ ⏮ ⏪ Seite 1 / 24 ⏩ ⏭ │ +└────────────────────────────────────────────────────────────────────┘ +``` + +#### Pagination und Sortierung + +- **Page-Größen**: 50 / 100 / 250 / 500 (Combobox) +- **Default**: 100 +- **Pagination-Komponente**: Erste / Vorherige / Seitenanzeige / Nächste / Letzte +- **Sortierung**: Klick auf Spaltenkopf wechselt zwischen DESC → ASC → DESC; Sortierpfeil im Header +- **Default-Sortierung**: `LAST_STARTED_AT DESC` +- **NULL-Werte**: NULLS LAST per zweistufiger Sortierlogik + +#### Page-Navigation (Trigger-Logik) + +| Trigger | Page-Verhalten | +|---|---| +| Zeitraumwechsel | Page 0 | +| Sortierwechsel | Page 0 | +| Page-Größe-Wechsel | `neueSeite = floor(alteSeite × alteGröße / neueGröße)` (User bleibt grob beim selben Datenbereich) | +| Manuelles Blättern | Auf gewählte Seite | +| Refresh / „Aktualisieren" | Aktuelle Page beibehalten | +| Page out-of-range nach Refresh (z.B. Daten gelöscht oder eingefroren) | Auf **letzte gültige Page** korrigieren; Banner „Page automatisch angepasst" | + +#### Modell-Spalte Tooltip `ⓘ` + +„Provider: {provider}". + +#### Zeitraum-Auswahl + +Wie in V5: 24h / 7 Tage (Default) / 1 Monat / Benutzerdefiniert; „Bis +einschließlich" → Tagesbeginn des Folgetags; halboffener DB-Filter. + +#### Aggregations-Logik + +Eine Zeile pro **(Fingerprint × Provider × Modellname)**. +Pro-Attempt-Snapshot. Identischer Modellname zwei Provider → zwei Zeilen. + +#### Status-Einschluss + +`input_tokens` ODER `output_tokens` nicht NULL. Cache-only separat über +`cacheOnlyAttemptCount`. + +#### Quellname / Neuster Name + +`last_known_source_file_name` (LEFT JOIN, NULL→„–"); `final_target_file_name` +des jüngsten Versuchs (NULL→„–"). + +#### Banner und Gesamt-Kosten-Kopfzeile + +Gemäß Anzeige-Semantik. Kopfzeile bezieht sich auf vollständigen Zeitraum. + +#### Threading-Modell + +- DB-Zugriff im Worker-Thread (`Task`) +- UI-Updates auf JavaFX Application Thread +- Aktualisieren-Button und Pagination-Steuerung während Query deaktiviert +- **Stale-Result-Schutz:** + - Beim Apply prüft GUI Query-Parameter (Zeitraum, pageNumber, pageSize, sortField, sortDirection) + - Stimmen sie nicht mit aktuellem Tab-Zustand überein: Ergebnis verwerfen + - `Task.cancel()` wird aufgerufen, ist aber nicht korrektheitsrelevant +- **Page-/Sort-Wechsel** triggert nur die `queryCostAnalysisFull(...)`-Methode; **Zeitraum-Wechsel** ebenso (alle Queries der Read-Transaction laufen gemeinsam) +- Bei `database is locked` oder anderen SQL-Exceptions: deutsche Inline-Fehlermeldung; vorigen Zustand behalten + +### GUI – Erweiterung History-Tab + +Drei zusätzliche Spalten: `Input-Tokens`, `Output-Tokens`, `Kosten`. +Cache-only Attempts mit Status-Text „nur Cache-Tokens, keine +Standardkosten". `updatedAt = null` → „ungültig". + +### GUI – Erweiterung Summary-Banner + +``` +Lauf abgeschlossen: 12 erfolgreich / 1 fehlgeschlagen +Tokens: Input 13.812 Output 947 +Kosten: $0.0023 +ℹ 1 Cache-only Versuch (in Kosten nicht enthalten) +``` + +Mix-Status nach Anzeige-Semantik. Cache-only-Zeile nur wenn `count > 0`. + +### Edge Cases und Fehlerbehandlung + +| Fall | Verhalten | +|---|---| +| API liefert kein `usage`-Feld | Token-Felder NULL; KEIN Fehler | +| API liefert nur ein Token-Feld | Vorhandenes gespeichert, fehlendes NULL | +| API liefert Tokenwert > 10 Mio | Adapter setzt NULL, WARN | +| API liefert negativen Tokenwert | Adapter setzt NULL, WARN | +| `processing_attempt` aus V3.2-Bestand | Token-/Preis-Felder NULL; Anzeige „keine Token-Daten" | +| Modell verwendet, kein Preis-Eintrag | Tokens gespeichert, Snapshot NULL | +| Modell-Preis nachträglich geändert | Künftige Versuche mit neuem Preis; historische Snapshots stabil | +| Modell-Preis nachträglich gelöscht | Historische Anzeigen bleiben | +| Modell-Preis innerhalb eines Laufs geändert | Pro-Attempt-Snapshot greift | +| Datum-Filter Von > Bis | Inline-Validierung; Aktualisieren deaktiviert | +| Modell-Preis-Tabelle leer | Tab funktioniert | +| Validierung Preis-Tab fehlgeschlagen | Speichern blockiert; deutsche Meldung | +| Cache-Tokens vorhanden, keine Standard-Tokens | In Aggregation unsichtbar; in History sichtbar; im Banner gezählt | +| Cache-Tokens zusätzlich zu Standard-Tokens | Suffix `(ohne Cache-Anteil)` | +| Technischer Fehler beim Preis-Lookup | Tokens persistiert, Snapshot NULL, ERROR; Attempt geht NICHT verloren | +| Attempt ohne `document_record` | Sichtbar (LEFT JOIN); Quellname „–" | +| `database is locked` während Lese-Transaktion | Deutsche Fehlermeldung; vorigen Zustand behalten | +| Manuell gelöschte Tabelle `model_price` | Keine automatische Reparatur; siehe `betrieb.md` | +| `updated_at`-Wert in DB nicht parsebar | WARN; `updatedAt=null` im View; GUI „ungültig" | +| `pageNumber` außerhalb Bereich nach Refresh | Letzte gültige Page; Banner „Page automatisch angepasst" | +| Page-Größe-Wechsel | Approximative neue Page berechnet | +| Page-Parameter ungültig (negativ, falsche Größe) | Use-Case-Validierungsfehler; deutsche Meldung | +| Offset-Overflow (`pageNumber × pageSize > Long.MAX`) | `ArithmeticException`, deutsche Fehlermeldung | +| Sortierung nach Kosten mit gemischten Status | NULLS LAST greift; nicht-berechenbare Werte am Ende | +| Pro-Group-Aggregation bei extrem vielen Attempts (>1000) | Verhalten undefiniert; V3.x-Fix | + +--- + +## #98 – Modell-Combobox: Filterung ungeeigneter Modelle + +### Fachliche Beschreibung + +Heuristischer UI-Vorfilter, keine Sicherheits- oder +Korrektheitsgarantie. Bekannte False-Negatives (Namespace-Notationen mit +`:`, achtstellige Datumssuffixe ohne Trennstriche) sind dokumentiert. + +### Filterregeln + +#### Normative Regel + +Ausgeschlossen werden Modelle, deren primäre Aufgabe nicht +Textgenerierung oder Reasoning für die PDF-Umbenennung ist. Auch +textfähige, aber für den Use Case fachlich ungeeignete Spezialmodelle +(z.B. `instruct`-Legacy oder `codex`-Code-Spezialmodelle) werden +ausgeblendet. + +#### Blacklist (Segment-Match, case-insensitive) + +Trennzeichen: `-`, `_`, `.`, `/`. (Bewusst kein `:` – False-Negatives bei +Namespaces sind dokumentierter Trade-off.) + +``` +embedding, embeddings, embed, +dalle, dall, image, images, +tts, audio, voice, speech, +whisper, transcribe, transcription, +realtime, moderation, +sora, video, +babbage, davinci, instruct, +codex, search, +rerank, ranker, guardrail +``` + +#### Snapshot-Filter (Regex) + +- `.*-\d{4}-\d{2}-\d{2}$` (ISO-Datum) +- `.*-\d{4}$` (Kurzformat) + +Anthropic-Modell-IDs mit achtstelligem Datumssuffix ohne Trennstriche +(z.B. `claude-haiku-4-5-20251001`) bleiben erhalten. + +#### Fallback bei leerer gefilterter Liste + +- Combobox leer +- Hinweis „Keine geeigneten Modelle gefunden..." +- Button „Ungefilterte Liste anzeigen" +- Klick: ungefilterte Liste + dauerhafter Warn-Hinweis + +### Implementierung + +`ModelListFilter` als package-private Klasse in +`adapter-in-gui.modelcatalog`. Einhängepunkt: +`GuiModelCatalogCoordinator.applyResult()` bei `Success`. + +--- + +## #99 – CLI für Modell-Preise + +### CLI-Vertrag + +**Befehle:** + +| Befehl | Beschreibung | +|---|---| +| `--list-model-prices` | Listet alle persistierten Modell-Preise als Tabelle (Provider, Modellname, In/1M, Out/1M, Währung, Letzte Änderung) | +| `--upsert-model-price ` | Fügt einen Preis hinzu oder aktualisiert ihn | +| `--delete-model-price ` | Löscht einen Preis-Eintrag | + +**Vertragspunkte:** + +| Aspekt | Festlegung | +|---|---| +| Exklusivität | CLI-Preisbefehle sind **exklusiv**: Programm führt nur den Befehl aus und beendet sich. Keine Kombination mit `--headless` (Verarbeitungslauf). Bei kombinierter Angabe → Validierungsfehler, Exit-Code 1. | +| `--config ` | **Pflichtparameter**, identisch zur Headless-Lade-Logik. Fehlende oder defekte Config → Exit-Code 3. | +| Flyway-Migration | Läuft **vor** jedem CLI-DB-Zugriff (Schema-Aktualität sicherstellen) | +| Argumentquoting | Standard-Shell-Verhalten. Modellnamen mit Sonderzeichen oder Leerzeichen müssen vom User in Anführungszeichen gesetzt werden (z.B. `--upsert-model-price openai-compatible "gpt-4o-mini" 0.15 0.60`). Java-`args[]`-Parsing verwendet die bereits durch die Shell aufgespaltenen Argumente. | +| Validierung | Identisch zur GUI-Validierung (`ManageModelPricesUseCase`). Verstöße → deutsche Fehlermeldung auf STDERR, Exit-Code 1. | +| Whitelist-Validierung | `--upsert-model-price` lehnt unbekannte Provider ab (nur `openai-compatible`/`anthropic` erlaubt). `--delete-model-price` akzeptiert auch unbekannte Provider, damit verwaiste Einträge entfernbar bleiben. | +| Ausgabe-Format | `--list-model-prices`: feste Spaltenbreiten oder einfache Tabular-Ausgabe nach STDOUT, eine Zeile pro Eintrag, Header-Zeile. | + +**Exit-Code-Matrix:** + +| Code | Bedeutung | +|---|---| +| `0` | Erfolg | +| `1` | Validierungsfehler (ungültige Argumente, falsche Werte, falsche Anzahl Parameter, kombinierte unverträgliche Optionen) | +| `2` | DB-Fehler (Migration fehlgeschlagen, `database is locked` über Timeout, SQL-Fehler) | +| `3` | Konfigurationsfehler (`--config` fehlt, Datei nicht lesbar, Pflichtfelder fehlen) | +| `4` | Unbekannter Fehler (alle anderen Exceptions) | + +### Headless-Lauf-Hinweis + +Bei einem `--headless`-Lauf, der Modelle ohne Preis-Eintrag verwendet: +zusätzlich zur normalen WARN-Logmeldung ein einmaliger Hinweis: +„Hinweis: Modell-Preise können mit `--upsert-model-price` ergänzt werden. +Siehe `betrieb.md`." + +### Implementation + +CLI-Befehle nutzen den bestehenden Use Case `ManageModelPricesUseCase` +und das Repository. Anschluss an das bestehende +Befehls-Registrierungs-Pattern in `adapter-in-cli` (zu verifizieren per +Code-Read). + +--- + +## Logging-Matrix + +| Level | Wann | Beispiel | +|---|---|---| +| INFO | Token-Usage extrahiert | `Token-Usage erfasst: Provider={}, Modell={}, Input={}, Output={}, CacheCreation={}, CacheRead={}` | +| INFO | Preis-Snapshot persistiert | `Preis-Snapshot persistiert: Provider={}, Modell={}, ...` | +| INFO | Modell-Preis aktualisiert/gelöscht | – | +| INFO | Preis-Batch persistiert | `Modell-Preis-Batch persistiert: {} Upserts, {} Deletions` | +| INFO | Preis-Batch leer (No-op) | – | +| INFO | Kosten-Analyse aufgerufen | `Kosten-Analyse: Zeitraum=[{},{}), Page={}, Größe={}, Sort={} {}, Zeilen={}, Total={}` | +| INFO | Modellliste gefiltert | – | +| INFO | CLI: Preis-Aktion | `CLI {}: Provider={}, Modell={}` | +| WARN | API-Response ohne usage | – | +| WARN | Token-Wert ungültig | – | +| WARN | Filter ergibt 0 Einträge | – | +| WARN | Versuch ohne Preis-Snapshot | – | +| WARN | Headless-Lauf mit fehlendem Preis | – | +| WARN | `database is locked` | – | +| WARN | `updated_at` nicht parsebar | – | +| WARN | ChangeSet-Konflikt | – | +| WARN | Page automatisch korrigiert | `Page-Korrektur: angeforderte Page={}, korrigiert auf={} (totalRowCount={})` | +| ERROR | Flyway-Migration V2 fehlgeschlagen | – | +| ERROR | Modell-Preis-Batch gescheitert (Rollback) | – | +| ERROR | Preis-Lookup-Fehler vor Attempt-Insert | – | + +--- + +## Fertigstellungs-Checkliste + +### Vor Implementierungsstart (Freigabe-Gate) + +- [ ] Alle freigabe-blockierenden Code-Reads im Schlussabschnitt mit „bestätigt" oder „abweichend (Spec angepasst auf: …)" markiert +- [ ] **Spec nach Code-Reads aktualisiert und freigegeben** + +### AP-A: Token- und Kosten-Tracking (Schema, Adapter, Persistenz) + +- [ ] `V2__token_tracking.sql` erstellt und auf V1-DB getestet +- [ ] WAL + `busy_timeout` 5s pro Connection verifiziert +- [ ] `AiUsageMetadata`, `AiInvocationSuccess` erweitert +- [ ] `ModelPriceEntry` (non-null updatedAt), `ModelPriceView` (nullable) +- [ ] `ModelPriceChangeSet`, `ModelPriceKey` +- [ ] `ModelPriceRepository` (View für Lese, Entry für Schreib) +- [ ] `ManageModelPricesUseCase` mit ChangeSet-Konfliktvalidierung +- [ ] Anthropic- und OpenAI-Adapter Token-Extraktion mit Validierung +- [ ] `processing_attempt`-Schreibpfad mit allen sechs neuen Spalten +- [ ] `BatchRunProcessingUseCase` lädt Snapshot, Lookup-Fehler verliert keinen Attempt +- [ ] `SqliteModelPriceRepositoryAdapter` mit UPSERT, transaktionalem Batch, View-Mapping +- [ ] History-Tab um drei Spalten erweitert +- [ ] Summary-Banner um Tokens, Kosten, Cache-only-Zeile erweitert +- [ ] Konfigurations-Tab Warnung bei Modell ohne Preis + +### AP-B: Kosten-Analyse mit Pagination + +- [ ] `TokenStatisticsReadModelPort` im Package `application.reporting.costanalysis` +- [ ] DTOs `CostAnalysisFullResult`, `CostAnalysisHeaderResult`, `CostAnalysisRow`, `CostAnalysisTotals` mit hasAny-Flags, `RunSummaryResult`, `RunModelTokenSummary` +- [ ] Enums `CostAnalysisSortField`, `SortDirection` +- [ ] `CostResult` mit Boolean-Flags inkl. `hasAnyCalculatedCost` +- [ ] `CostCalculator` mit `formatRow`, `formatTotal` (BigInteger-Eingabe, hasAny-Flags), `calculateAttempt` +- [ ] `QueryCostAnalysisFullUseCase` mit Page-Parameter-Validierung und Auto-Korrektur bei out-of-range +- [ ] `QueryCostAnalysisHeaderOnlyUseCase` +- [ ] `QueryRunSummaryUseCase` +- [ ] `SqliteTokenStatisticsReadModelAdapter` mit gemeinsamen CTEs/WHERE-Konstanten +- [ ] **Read-Transaction für vollständige Aktualisierung** (`autoCommit=false`, `setReadOnly=true`) +- [ ] BigInteger-Aggregation der Totals in Java +- [ ] Sortierspalten-Whitelist +- [ ] Zweistufige NULLS-LAST-Sortierung für Gesamt-Kosten +- [ ] LEFT JOIN document_record +- [ ] Tab „Kosten-Analyse" mit Pagination-Komponente, Spalten, Sortierung +- [ ] Page-Navigation gemäß Trigger-Logik +- [ ] Stale-Result-Schutz auf alle Page-Parameter +- [ ] Banner-Logik komplett (partial, missingPrice, cache, cacheOnly, pageAdjusted) +- [ ] Gesamt-Kosten-Kopfzeile mit Mix-Status + +### AP-C: Modell-Filter (#98) und CLI (#99) + +- [ ] `ModelListFilter` segmentbasiert +- [ ] Filter-Fallback bei leerer Liste mit Button und dauerhaftem Warn-Hinweis +- [ ] CLI-Befehle: `--list-model-prices`, `--upsert-model-price`, `--delete-model-price` +- [ ] CLI-Vertrag: Exklusivität, `--config`-Pflicht, Flyway vor DB-Zugriff, Exit-Code-Matrix +- [ ] CLI-Whitelist-Verhalten: Upsert nur Whitelist; Delete auch unbekannt +- [ ] Headless-Hinweis bei fehlendem Preis + +### Logging und Doku + +- [ ] Logging-Matrix vollständig +- [ ] Code-Kommentare auf Deutsch; Logging auf Deutsch +- [ ] JavaDoc auf neuen öffentlichen Ports/Use-Cases/DTOs +- [ ] `betrieb.md` mit CLI-Beispielen +- [ ] `gui-bedienanleitung.md` auf V3.3-Stand inkl. Pagination/Sortierung +- [ ] `freigabe-v3_3.md` erstellt +- [ ] Manueller Produkttest abgeschlossen + +--- + +## Produkttest-Matrix (selbsttragend, ohne Verweis auf frühere Versionen) + +### AP-A: Token-Erfassung und Persistenz + +| Testfall | Erwartung | +|---|---| +| OpenAI-Lauf, Modell `gpt-4o-mini` | Tokens und Snapshot-Preise gefüllt; Cache NULL | +| Anthropic-Lauf, Modell `claude-haiku-4-5-20251001` | Standard- und Cache-Felder gefüllt; Snapshot-Preise gefüllt | +| OpenAI-Endpunkt liefert kein `usage` | Tokens und Snapshot NULL; Verarbeitung erfolgreich; WARN | +| API liefert negative Token-Zahl | Adapter setzt NULL; WARN | +| API liefert Tokenwert > 10 Mio | Adapter setzt NULL; WARN | +| Whole-run failure | Keine `processing_attempt`-Einträge | +| Per-document failure (technical) | Record mit allen neuen Feldern NULL | +| Modell ohne Preis-Eintrag | Tokens gespeichert, Snapshot NULL; WARN | +| Preis-Lookup wirft Exception | Tokens trotzdem persistiert, Snapshot NULL, ERROR; Attempt geht NICHT verloren | +| Identischer Modellname zwei Provider | Lookup pro Provider liefert passenden Eintrag | + +### AP-A: Modell-Preise-Tab + +| Testfall | Erwartung | +|---|---| +| Tab öffnen mit Default-Preisen (10 Einträge) | Tabelle in `$/1M` | +| Hinzufügen mit gültigen Werten | Speichern erfolgreich | +| Hinzufügen mit negativem Preis | Validierungsfehler | +| Hinzufügen mit > 6 Nachkommastellen | Validierungsfehler | +| Hinzufügen mit Preis > Maximum | Validierungsfehler | +| Hinzufügen mit nicht-numerischem Preis | Validierungsfehler | +| Hinzufügen mit (Provider, Modell)-Duplikat | Validierungsfehler | +| Hinzufügen mit identischem Modellnamen, anderem Provider | Erlaubt | +| Drei Modelle ändern, Speichern | Atomar persistiert | +| Drei Modelle ändern, simulierter Fehler beim zweiten | Rollback aller; deutsche Meldung; DB unverändert | +| Editieren, Speichern | Wert in DB; `updated_at` aus Clock | +| Löschen, Bestätigung OK / Abbrechen | Eintrag entfernt / bleibt | +| Eingabe `$0.123456 / 1M` | Konvertierung zu 123 nano-USD/Token (HALF_UP) | +| Anzeige nach Roundtrip | Wert wird wieder lesbar dargestellt | +| Unbekannter Provider in DB | Read-only Anzeige mit Tooltip; Löschen erlaubt | +| `updated_at` ungültig in DB | Spalte zeigt „ungültig" | +| Konfigurations-Tab: Modell ohne Preis ausgewählt, Speichern | Warnung; Speichern erfolgreich | +| Konfigurations-Tab: Modell mit Preis ausgewählt | Keine Warnung | + +### AP-A: History-Tab + +| Testfall | Erwartung | +|---|---| +| Tab öffnen | Drei neue Spalten sichtbar | +| Eintrag aus V3.2-Bestand | „keine Token-Daten" | +| Eintrag mit Tokens, kein Snapshot | Token-Spalten gefüllt, Kosten „Preis fehlt" | +| Eintrag mit Tokens und Snapshot | Alle Spalten korrekt | +| Modell-Preis nachträglich geändert | History-Eintrag stabil | +| Modell-Preis nachträglich gelöscht | History-Eintrag stabil | +| Cache-only Attempt | Status „nur Cache-Tokens, keine Standardkosten" | + +### AP-A: Summary-Banner + +| Testfall | Erwartung | +|---|---| +| Lauf mit 1 PDF, alles vorhanden | Tokens und exakte Kosten | +| Lauf mit 5 PDFs, alle Modelle bekannt | Σ je Attempt × Snapshot | +| Lauf mit Modell ohne Preis | Kosten `unvollständig` | +| Lauf nur fehlgeschlagen | Tokens 0/0; Kosten $0.0000 | +| Lauf mit Cache-Tokens | Suffix `(ohne Cache-Anteil)` | +| Lauf nur mit Cache-only | „nichts berechnet"-artige Anzeige plus Cache-only-Zeile | +| Preisänderung während Lauf | Pro-Attempt-Snapshot greift | + +### AP-B: Kosten-Analyse-Tab + +| Testfall | Erwartung | +|---|---| +| Default 7 Tage öffnen | Page 0 mit 100 Zeilen, Aggregation der letzten 7 Tage | +| 24h / 7T / 1M umschalten | Tabelle aktualisiert; Page 0 | +| Benutzerdefiniert, Von > Bis | Validierungsmeldung | +| Benutzerdefiniert, `Von=Bis=heute` | Heutige Versuche | +| Datei mit selbem (Provider, Modell) mehrfach, Preisänderung dazwischen | Eine Zeile, Σ je Attempt × eigener Snapshot | +| Datei mit zwei Modellen | Zwei Zeilen | +| Identischer Modellname zwei Provider | Zwei Zeilen | +| Modell-Spalte Tooltip | „Provider: {provider}" | +| 9 berechenbar + 1 ohne Preis | Zeile `~$X (unvollständig)`; Banner zählt | +| Versuch nur Input mit Preis | `~$X` | +| Versuch nur Input ohne Preis | `unvollständig` | +| Versuch mit Cache-Tokens | Suffix `(ohne Cache-Anteil)` | +| Cache-only Versuche im Zeitraum | Banner zählt; nicht in Aggregation | +| Bestandseintrag ohne Tokens | Nicht in Aggregation | +| Versuch ohne `document_record` | Sichtbar; Quellname „–" | +| Leerer Zeitraum | Tabelle leer; Gesamt-Tokens 0; Kosten $0.0000 | +| 10.000+ Versuche | Lade-Indikator; UI responsiv | +| 1 Mio Attempts | Pagination funktioniert; Performance akzeptabel (Erwartung: Page-Wechsel < 2s bei niedrigen OFFSETs) | +| Page-Navigation: Erste/Vorherige/Nächste/Letzte | Korrekte Ergebnisse | +| Page-Sprung manuell | Korrekte Ergebnisse | +| Sortierung über alle Spalten, beide Richtungen | Korrekt | +| NULL-Werte beim Sortieren | NULLS LAST für ASC und DESC | +| Sortierung nach Gesamtkosten, gemischte Status | Nicht-berechenbare am Ende, nicht als $0 einsortiert | +| Page-Größe-Wechsel | Approximative neue Page; User bleibt grob im Datenbereich | +| Zeitraum-Wechsel | Page 0 | +| Sortier-Wechsel | Page 0 | +| Page out-of-range nach Datenänderung | Letzte gültige Page; Banner „Page automatisch angepasst" | +| Ungültige Page-Parameter (Test-Hack) | Use-Case-Validierungsfehler | +| Schneller Wechsel von Page/Sort/Zeitraum | Stale Results verworfen | +| Gesamt-Kopfzeile bezieht sich auf vollen Zeitraum | Nicht nur aktuelle Page | +| Konsistenz Page/Totals/Count/Cache-only | Alle aus demselben Read-Snapshot (Test mit parallelem Insert) | +| Mikro-Kosten | `< $0.0001` | +| Tooltips bei kombiniertem Status | Alle Ursachen | +| Gleichzeitiger Scheduler-Schreibvorgang | Lese-Query funktioniert; partiell sichtbarer Lauf akzeptabel | +| `database is locked` simulieren | Deutsche Fehlermeldung; vorigen Zustand behalten | + +### AP-C: Modell-Combobox-Filter (#98) + +| Testfall | Erwartung | +|---|---| +| OpenAI-Endpunkt, „Modelle neu laden" | Combobox deutlich reduziert | +| Beispiele ausgeschlossen | `text-embedding-3-large`, `whisper-1`, `dall-e-3`, `tts-1`, `gpt-4o-2024-05-13`, `gpt-3.5-turbo-0125` | +| Beispiele erhalten | `gpt-4o`, `gpt-4o-mini`, `gpt-4.1`, `o3`, `chat-latest` | +| Anthropic-Endpunkt | Alle erhalten, insbesondere `claude-haiku-4-5-20251001` | +| Hypothetisch `research-bot-v1` | NICHT entfernt | +| Hypothetisch `gpt-4o-search-preview` | DURCH `search` entfernt | +| Hypothetisch mit Doppelpunkt: `provider:whisper`, `text:embedding` | NICHT entfernt (dokumentierter Trade-off) | +| Hypothetisch mit Doppelpunkt: `openai:gpt-4o-20240513` | NICHT entfernt durch Datums-Regex (acht zusammenhängende Ziffern bei Anthropic-Schema; dokumentierter Trade-off) | +| Endpunkt nur Embeddings | Combobox leer; Hinweis; Button | +| Klick auf „Ungefilterte Liste anzeigen" | Originalliste; dauerhafter Warn-Hinweis | +| Vorher gewähltes Modell im Filterergebnis | Selektion bleibt | +| Vorher gewähltes Modell nicht im Filterergebnis | Bestehende Selektionslogik | + +### AP-C: CLI (#99) + +| Testfall | Erwartung | +|---|---| +| `--list-model-prices` mit gültiger Config | Tabelle ausgegeben; Exit 0 | +| `--list-model-prices` ohne `--config` | Exit 3 | +| `--list-model-prices` mit defekter Config | Exit 3 | +| `--upsert-model-price openai-compatible gpt-4o-mini 0.15 0.60` | INSERT/UPDATE; Exit 0 | +| `--upsert-model-price` mit unbekanntem Provider (z.B. `mistral`) | Validierungsfehler; Exit 1 | +| `--upsert-model-price` mit > 6 Nachkommastellen | Exit 1 | +| `--upsert-model-price` mit zu wenigen Argumenten | Exit 1 | +| `--upsert-model-price` mit negativem Preis | Exit 1 | +| `--delete-model-price openai-compatible gpt-4o-mini` | DELETE; Exit 0 | +| `--delete-model-price unknown-router foobar` | DELETE für unbekannten Provider erlaubt; Exit 0 | +| Kombination `--upsert-model-price ... --headless` | Exklusivitätsfehler; Exit 1 | +| Modellname mit Leerzeichen, korrekt gequotet | Funktioniert; Eintrag korrekt | +| Modellname mit Leerzeichen ohne Quoting | Shell teilt auf → Argumentanzahl falsch → Exit 1 | +| `--upsert-model-price` mit DB-Fehler (z.B. simuliert) | Exit 2 | +| Headless-Lauf mit fehlendem Preis | WARN-Log mit CLI-Hinweis | + +### Fehlerpfade + +| Testfall | Erwartung | +|---|---| +| `V2__token_tracking.sql` schlägt fehl | Flyway-Fehler; Reparatur-Banner | +| Datentyp-Fehler bei Snapshot-Preis (negativ) | CHECK-Constraint feuert | +| Wertüberschreitung in DB direkt eingefügt | CHECK-Constraint feuert | +| `database is locked` im Schreibpfad | `busy_timeout` 5s greift | +| Manuell gelöschte Tabelle `model_price` | Keine automatische Reparatur | +| `updated_at` in DB nicht parsebar | WARN; `updatedAt=null`; GUI „ungültig" | + +--- + +## Empfohlene Unit- und Integrationstests + +| Testklasse | Schwerpunkte | +|---|---| +| `AnthropicTokenExtractionTest` | Standard- und Cache-Tokens; ungültige Werte | +| `OpenAiTokenExtractionTest` | Mapping; Cache NULL; Edge Cases | +| `AiUsageMetadataTest` | Empty; `hasAnyTokenData`; `hasCacheTokens` | +| `ModelPriceEntryTest` | Konstruktor-Validierung | +| `ModelPriceViewMapperTest` | Parsing-Fehler `updated_at` → null + invalid-Flag | +| `ModelPriceEntryInvalidUpdatedAtRoundtripTest` | View darf nicht versehentlich als Entry gespeichert werden | +| `SqliteModelPriceRepositoryAdapterTest` | UPSERT, Composite-Key, identischer Modellname zwei Provider | +| `ModelPriceBatchSaveTransactionTest` | Atomarer Erfolg; Rollback bei Fehler | +| `ModelPriceChangeSetConflictTest` | Key in Upsert+Delete; Duplikate | +| `ModelPriceChangeSetEmptyNoOpTest` | Leeres ChangeSet → No-op | +| `SqliteTokenStatisticsReadModelAdapterPageTest` | Page-Query mit allen Sortierfeldern und Richtungen | +| `SqliteTokenStatisticsReadModelAdapterTotalsTest` | Totals-Query liefert korrekte Aggregate | +| `SqliteCacheOnlyCountTest` | Cache-only-Anzahl korrekt | +| `CostAnalysisProviderGroupingTest` | Gleicher `model_name`, andere Provider → zwei Zeilen | +| `RunSummaryProviderGroupingTest` | Gleiches im Run-Summary | +| `ProviderDisplayedInCostAnalysisTest` | GUI Tooltip | +| `CostAnalysisMixedSnapshotPriceTest` | Pro-Attempt-Snapshot in Aggregation | +| `CostAnalysisMixedStatusAggregationTest` | 9 berechenbar + 1 missing → `~$X (unvollständig)` | +| `CostAnalysisHeaderTotalMixedStatusTest` | Kopfzeile mit Mix-Status | +| `CostAnalysisHeaderTotalsFullTimeRangeTest` | Kopfzeile = voller Zeitraum, nicht nur Page | +| **`CostAnalysisReadConsistencyTest`** | Page, Count, Totals, Cache-only aus konsistentem Read-Snapshot trotz parallelem Insert | +| `CostAnalysisTotalsNullSemanticsTest` | Totals unterscheiden „keine Werte"/„0"/„missing price"/„Mikrobetrag" | +| `CostCalculatorTest` | Alle Flag-Kombinationen | +| `CostCalculatorBigIntegerTotalsTest` | `formatTotal` mit BigInteger und hasAny-Flags | +| `CostCalculatorTinyAmountFormattingTest` | `< $0.0001` | +| `CostResultMultipleStatusTest` | Mehrere Flags gleichzeitig | +| `ManageModelPricesUseCaseTest` | Validierung in Application | +| `QueryCostAnalysisFullUseCaseTest` | Mapping; Auto-Korrektur bei out-of-range Page; Validierung der Page-Parameter | +| `QueryCostAnalysisHeaderOnlyUseCaseTest` | Header-Only-Pfad | +| `QueryRunSummaryUseCaseTest` | Aggregation über (Provider, Modell)-Gruppen | +| `ModelListFilterTest` | Segment-Match; Anthropic 8-stelliges Datum bleibt; alle Blacklist-Tokens | +| `ModelListFilterNamespaceFalseNegativeTest` | `provider:whisper`, `text:embedding`, `openai:gpt-4o-20240513` Verhalten dokumentiert | +| `GuiModelCatalogCoordinatorFilterIntegrationTest` | Filter-Integration mit Fallback | +| `FlywayV2MigrationTest` | V2 auf produktivem V3.2-Schema-Zustand; Default-Preise; CHECK-Constraints | +| `BatchRunProcessingTokenPersistenceTest` | Atomarer Insert; Failure → NULL | +| `PriceLookupFailureDoesNotLoseAttemptTest` | Lookup-Exception verliert Attempt nicht | +| `PriceChangeHistoryRobustnessTest` | History stabil bei Preisänderung/-löschung | +| `ProviderModelKeyCollisionTest` | Identischer Modellname zwei Provider | +| `SqliteConcurrencyTest` | Paralleler Schreib/Lese-Pfad; `busy_timeout` | +| `GuiThreadingTest` | Worker-Erfolg, Stale-Verwerfung | +| `GuiPaginationStaleResultTest` | Schneller Page/Sort-Wechsel verwirft alte Ergebnisse | +| `GuiPaginationSortNullsLastTest` | NULL-Werte am Ende für beide Richtungen | +| `CostSortMissingPriceNullsLastTest` | „Preis fehlt" wird nicht wie 0-Kosten sortiert | +| `PageNumberOutOfRangeTest` | Datenänderung → ungültige Page sauber korrigiert | +| `PageSizeChangeTest` | Approximative neue Page nach Größenwechsel | +| `CostAnalysisPartialTokenTest` | NULL-Semantik der Aggregation | +| `CacheTokensIgnoredCostWarningTest` | `(ohne Cache-Anteil)`-Anzeige | +| `CacheOnlyAttemptHistoryVisibilityTest` | Cache-only in History sichtbar | +| `CacheOnlyAttemptCountTest` | `cacheOnlyAttemptCount` korrekt; Banner und Summary-Zeile | +| `CacheOnlyRunSummaryVisibilityTest` | Lauf nur mit Cache-only nicht „nichts passiert" | +| `DatePickerInclusiveDayRangeTest` | Folgetags-Konvertierung | +| `CostAnalysisWithoutDocumentRecordTest` | LEFT JOIN | +| `TokenOverflowAndNegativeParsingTest` | Negative/zu große Werte → NULL+WARN | +| `SqliteCostOverflowProtectionTest` | DB-CHECK gegen Maxima | +| `SqliteAggregateOverflowBoundaryTest` | Pro-Group-long bei realistischen 100 Attempts; Über-Group-BigInteger sicher | +| **`SqliteGroupAggregationOverflowGuardTest`** | Maximalwerte in einer realistischen Group ergeben kein still falsches Ergebnis | +| `ModelPriceRoundtripTest` | `$0.123456 / 1M` → 123 nano-USD/Token, HALF_UP | +| `ModelPriceMaxNachkommastellenTest` | > 6 Nachkommastellen → Validierungsfehler | +| `ConfigSaveWithoutPriceWarningTest` | Modell ohne Preis → Warnung | +| `ConfigSaveWithPriceNoWarningTest` | Modell mit Preis → keine Warnung | +| `BooleanResultsetMappingTest` | `getInt(...) != 0`-Mapping | +| `CliListModelPricesTest` | Tabellen-Ausgabe | +| `CliUpsertModelPriceValidationTest` | Validierungsfehler → Exit 1 | +| `CliUpsertModelPriceUnknownProviderRejectedTest` | Unbekannter Provider beim Upsert → Exit 1 | +| `CliDeleteModelPriceTest` | DELETE-Pfad (Whitelist und unbekannter Provider) | +| `CliCommandExclusivityTest` | CLI-Preisbefehle und `--headless` schließen sich aus | +| `CliConfigResolutionTest` | Gleicher DB-/Config-Kontext wie Headless | +| `CliExitCodeMatrixTest` | 0/1/2/3/4 entsprechend Vertrag | +| `HeadlessMissingPriceWarningTest` | WARN mit CLI-Hinweis | +| `UnknownProviderReadOnlyDisplayTest` | Unbekannter Provider in DB → GUI read-only | + +--- + +## Code-Reads vor Implementierung + +### Freigabe-blockierend + +| # | Code-Read | Erwartetes Ergebnis | Konsequenz bei Abweichung | Status | +|---|---|---|---|---| +| 1 | `ai_provider`-Werte in `processing_attempt` | `openai-compatible`, `anthropic` | Default-Inserts und Lookup-Logik anpassen | offen | +| 2 | Primärschlüsselspalte `processing_attempt.id` | `id` (AUTOINCREMENT, monoton) | Pseudo-SQL und Tie-Breaker anpassen | offen | +| 3 | Insert-Pfad für `processing_attempt` | Eine zentrale Stelle | Hook für Tokens und Snapshot dort einbauen | offen | +| 4 | Stabilität von `last_known_source_file_name` | Wird nach Initial-Scan nicht überschrieben | Tooltip ggf. anpassen | offen | +| 5 | SQLite-JDBC-Window-Function-Unterstützung | `ROW_NUMBER() OVER (...)` ab SQLite 3.25 | Korrelierte Subquery als Fallback | offen | +| 6 | Connection-Setup: WAL und `busy_timeout` | Beide aktiv; pro Connection | Im Adapter ergänzen | offen | +| 7 | CLI-Adapter-Modul: Befehlsregistrierung | Vorhandenes Pattern nutzen | An bestehendes Pattern anschließen | offen | + +### Nicht blockierend + +| # | Code-Read | Zweck | Status | +|---|---|---|---| +| 8 | Spaltenmenü im History-Tab | Falls vorhanden: neue Spalten registrieren | offen | +| 9 | Lade-Indikator in der GUI | Wiederverwenden falls zentral | offen | +| 10 | JavaFX `Pagination`-Komponente in der Codebasis | Pattern übernehmen falls vorhanden | offen | + +**Hinweis zum Status:** Solange einer der freigabe-blockierenden Reads +mit „offen" markiert ist, gilt die Spec als „implementierungsvorbereitend". +Erst wenn alle Reads erledigt sind und die Ergebnisse hier dokumentiert +sind, ist das Freigabe-Gate offen.