1593 lines
74 KiB
Markdown
1593 lines
74 KiB
Markdown
# 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<ModelPriceView> findAll();
|
||
Optional<ModelPriceView> 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<ModelPriceEntry> upserts,
|
||
List<ModelPriceKey> 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<CostAnalysisRow> 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<RunModelTokenSummary> 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 <sort>; 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)) <direction>` |
|
||
|
||
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<ModelPriceView>
|
||
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<CostAnalysisFullResult>`)
|
||
- 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 <provider> <modelName> <inputUsdPer1M> <outputUsdPer1M>` | Fügt einen Preis hinzu oder aktualisiert ihn |
|
||
| `--delete-model-price <provider> <modelName>` | 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 <path>` | **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.
|