4 Commits

Author SHA1 Message Date
marcus 20722d4365 fix: Modell-Preise Tab Buttons nach Start aktivieren (#74) 2026-05-11 06:58:09 +02:00
marcus cb3fa143fb docs: AP-A Zusammenfassung aller implementierten Klassen, Methoden und Dateien
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:51:31 +02:00
marcus 08ec021b5f feat: AP-A Token-Tracking Fundament - Schema, Adapter, Use Cases, GUI (#74)
Erste Stufe der V3.3-Spezifikation: Token- und Kosten-Tracking-Fundament.

Schema und Persistenz:
- Neue Flyway-Migration V2__token_tracking.sql mit sechs Token-/Preis-Snapshot-
  Spalten in processing_attempt, neuer model_price-Tabelle (Composite-Key
  provider+model_name) und Default-Preisen fuer beide Provider-Familien.
- SqliteModelPriceRepositoryAdapter mit UPSERT, transaktionalem Batch und
  invalidUpdatedAt-Mapping.
- Zentrale SqliteConnectionFactory; alle direkten DriverManager.getConnection-
  Stellen in den Repository-Adaptern (Document, Attempt, History, UnitOfWork)
  auf die Factory umgezogen, damit WAL und busy_timeout pro Connection greifen.

Application und Domain:
- Neue DTOs AiUsageMetadata, ModelPriceEntry/View/Key/ChangeSet, CostResult.
- AiInvocationSuccess um usageMetadata erweitert; AiAttemptContext um vier
  nullable Token-Felder.
- ProcessingAttempt um sechs Token-/Preis-Snapshot-Felder erweitert
  (Convenience-Konstruktor und withoutAiFields-Factory unveraendert).
- ModelPriceRepository-Port mit Schreib-/Lese-Trennung.
- DefaultManageModelPricesUseCase mit ChangeSet-Konfliktvalidierung,
  Provider-Whitelist und Clock-Stempel.
- CostCalculator (formatRow + calculateAttempt; formatTotal als Stub fuer AP-B).

KI-Adapter:
- AnthropicClaudeHttpAdapter und OpenAiHttpAdapter extrahieren Token-Daten
  aus den Response-Bodies inklusive Validierung (negativ, > 10 Mio., nicht
  numerisch -> NULL + WARN-Log).

BatchRunProcessingUseCase-Hook:
- DocumentProcessingCoordinator erhaelt optional ModelPriceRepository und ein
  Headless-Flag. Beim Bau eines KI-Versuchs wird der Snapshot-Preis fuer
  (Provider, Modell) geladen und mit den Token-Daten am ProcessingAttempt
  persistiert. Lookup-Fehler verlieren keinen Attempt.

GUI:
- Neuer Tab "Modell-Preise" (TableView mit Editierfeldern, Add-Dialog,
  Loesch-Bestaetigung, Konvertierung Nano-USD <-> $/1M Tokens).
- History-Tab um drei Spalten erweitert: Input-Tokens, Output-Tokens, Kosten.
- Summary-Banner um Token-, Kosten- und Cache-only-Zeile erweitert
  (Default-Werte; AP-B liefert spaeter die echten Aggregate).
- Konfigurations-Tab warnt beim Speichern, wenn das aktive Modell keinen
  Preis-Eintrag hat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:49:50 +02:00
marcus b63dcf5efa docs: Spec V3.3 nach Code-Reads finalisiert (#74 #98 #99) 2026-05-09 08:36:32 +02:00
42 changed files with 2990 additions and 140 deletions
+168
View File
@@ -0,0 +1,168 @@
# AP-A Token-Tracking Fundament Zusammenfassung
Dieses Dokument fasst alle Klassen, Methoden und Dateien zusammen, die im Zuge
von AP-A (Token- und Kosten-Tracking-Fundament der V3.3-Spezifikation, #74)
neu erstellt oder substanziell erweitert wurden.
## Schema-Migration
- `pdf-umbenenner-adapter-out/src/main/resources/db/migration/V2__token_tracking.sql`
- Sechs neue Spalten in `processing_attempt`:
`input_tokens`, `output_tokens`,
`cache_creation_input_tokens`, `cache_read_input_tokens`,
`price_input_per_token_nano_usd`, `price_output_per_token_nano_usd`.
- Neue Tabelle `model_price` mit Composite Primary Key
`(provider, model_name)`, NOT-NULL-Preisen, Currency-CHECK auf `'USD'`,
`updated_at`-Spalte.
- Zwei zusätzliche Indizes auf `processing_attempt`:
`idx_processing_attempt_started_at_provider_fp_model`,
`idx_processing_attempt_run_id_provider_model`.
- Default-Preise für gpt-4o-mini, gpt-4o, gpt-4.1*, gpt-5*, claude-haiku-4-5,
claude-sonnet-4-6 und claude-opus-4-7 (`ON CONFLICT DO NOTHING`).
## Application-Modul
### DTOs (`application/dto`)
- `AiUsageMetadata` Token-Verbrauchsmetadaten mit `empty()`,
`hasAnyTokenData()`, `hasCacheTokens()`.
- `ModelPriceEntry` Schreib-/Validierungs-DTO mit Wertgrenzen-Validierung im
Konstruktor.
- `ModelPriceView` Lese-/Anzeige-DTO mit nullable `updatedAt` und
`invalidUpdatedAt`-Flag.
- `ModelPriceKey` Composite-Key für Löschungen.
- `ModelPriceChangeSet` atomarer Block aus Upserts und Deletions, defensive
Listen-Kopie.
### Cost-Komponenten (`application/cost`)
- `CostResult` interpretierte Kosten-Anzeige mit Status-Flags.
- `CostCalculator` `formatRow(...)` und `calculateAttempt(...)` (echt
implementiert), `formatTotal(...)` als Stub für AP-B.
### Ports (`application/port/out`)
- `ModelPriceRepository` `findAll`, `findByProviderAndModelName`, `upsert`,
`delete`, `saveAllChanges`.
- `AiInvocationSuccess` (erweitert) neues Feld `usageMetadata`.
### Use Cases (`application/usecase`)
- `DefaultManageModelPricesUseCase` CRUD-Fassade mit ChangeSet-Konflikt-
validierung (vier Regeln) und Provider-Whitelist beim Upsert.
- `ModelPriceValidationException` deutsche Validierungsfehler-Exception.
### Application-Service-Anpassungen
- `AiNamingService` (erweitert) reicht `AiUsageMetadata` aus dem
`AiInvocationSuccess` als Token-Felder in den `AiAttemptContext` weiter.
- `DocumentProcessingCoordinator` (erweitert)
- neuer optionaler Konstruktor mit `ModelPriceRepository` und
`headlessMode`-Flag.
- `loadPriceSnapshot(modelName)` lädt Snapshot-Preis pro Versuch; Lookup-
Fehler liefern leeren Snapshot ohne Attempt-Verlust.
- `buildAttempt(...)` befüllt jetzt Token- und Preis-Snapshot-Felder im
`ProcessingAttempt`.
### Domain-Anpassungen
- `AiAttemptContext` (erweitert) vier nullable Token-Felder
(`inputTokens`, `outputTokens`, `cacheCreationInputTokens`,
`cacheReadInputTokens`); Backward-compatible Convenience-Konstruktor.
- `ProcessingAttempt` (erweitert) sechs nullable Token-/Preis-Snapshot-
Felder; Convenience-Konstruktor und `withoutAiFields(...)` ohne Verhaltens-
änderung.
## Adapter-Out-Modul
- `SqliteConnectionFactory` (neu) zentrale Connection-Factory; setzt
`PRAGMA journal_mode=WAL` und `PRAGMA busy_timeout=5000`.
Foreign-Key-Pragma wird bewusst nicht implizit gesetzt (Verhalten der
bisherigen `DriverManager.getConnection`-Stellen erhalten).
- `SqliteUnitOfWorkAdapter`, `SqliteProcessingAttemptRepositoryAdapter`,
`SqliteHistoryQueryAdapter`, `SqliteDocumentRecordRepositoryAdapter`
(jeweils geändert) nutzen die neue Factory.
- `SqliteProcessingAttemptRepositoryAdapter.save()` (erweitert)
INSERT um sechs neue Spalten erweitert, neue Hilfsmethode
`setNullableLong(...)`.
- `SqliteHistoryQueryAdapter.mapToProcessingAttempt(...)` (erweitert)
liest die sechs neuen Spalten via `readNullableLong(...)`.
- `SqliteSchemaInitializationAdapter` (geändert) erwartete Spalten/Indizes
bleiben am V1-Zielschema; Doc-Klarstellung, dass V2 additiv auf der Baseline
arbeitet.
- `SqliteModelPriceRepositoryAdapter` (neu) `findAll`,
`findByProviderAndModelName`, `upsert`, `delete`, `saveAllChanges`
(UPSERT via `ON CONFLICT(provider, model_name) DO UPDATE`, transaktionaler
Batch). Lese-Mapping behandelt `DateTimeParseException` als
`invalidUpdatedAt`.
- `ModelPriceRepositoryException` (neu) technischer JDBC-Fehler.
### KI-Adapter
- `AnthropicClaudeHttpAdapter` (geändert) neue Methode
`extractTokenUsageFromResponse(JSONObject)` für `usage.input_tokens`,
`usage.output_tokens`, `usage.cache_creation_input_tokens`,
`usage.cache_read_input_tokens` mit Validierung (negativ, > 10 Mio.,
nicht-numerisch → NULL + WARN).
- `OpenAiHttpAdapter` (geändert) analoge Methode mit Mapping
`prompt_tokens → input_tokens`, `completion_tokens → output_tokens`;
Cache-Felder bleiben null.
## GUI-Modul
### Neuer Tab "Modell-Preise"
- `adapter-in-gui/modelprices/GuiModelPriceManagementPort` (neu)
Bridge-Port für GUI-Zugriff auf Modell-Preise.
- `adapter-in-gui/modelprices/GuiModelPricesTab` (neu) TableView mit
editierbaren Preisspalten (`In/1M USD`, `Out/1M USD`), Lösch-Button mit
Bestätigungsdialog, Add-Dialog mit Provider-Auswahl, Speichern-Aktion über
`ModelPriceChangeSet`. Konvertierung Nano-USD ↔ `$/1M Tokens` mit
HALF-UP-Rundung; unbekannte Provider werden read-only mit Tooltip
angezeigt; `updatedAt = null` als "ungueltig".
### Anbindung im Workspace
- `GuiConfigurationEditorWorkspace` (geändert) sechster Tab "Modell-
Preise" wird angelegt; neue Methode `warnIfActiveModelHasNoPriceEntry()`
zeigt vor dem Speichern eine deutsche Warnung an, wenn das aktuell
ausgewählte Modell keinen Preis-Eintrag besitzt.
- `GuiStartupContext` (geändert) neues optionales Feld
`modelPriceManagementPort` mit Backward-Kompatibilität.
- `BootstrapRunner` (geändert) neue Methode
`buildGuiModelPriceManagementPort()` und Helfer für die Verdrahtung;
Coordinator wird mit `ModelPriceRepository` und `headlessMode`-Flag
versorgt.
### History-Tab
- `GuiHistoryTab` (geändert) drei zusätzliche Spalten in der
Versuchstabelle: Input-Tokens, Output-Tokens, Kosten. Cache-only-Versuche
zeigen "nur Cache-Tokens, keine Standardkosten"; fehlender Preis-Snapshot
führt zu "Preis fehlt"; Mikrobeträge als "< $0.0001"; Cache-Beteiligung
ergänzt Suffix "(ohne Cache-Anteil)".
### Summary-Banner
- `BatchRunSummaryBanner` (geändert) aus einzeiliger HBox wurde eine
vierzeilige VBox: Status-Zeile, Token-Zeile, Kosten-Zeile, optionale
Cache-only-Zeile. Neue Record-Klasse `BatchRunTokenSummary` mit
`empty()`-Default; bestehende `update(Map)`-Aufrufer bleiben funktionsfähig.
## Testanpassungen
- `pdf-umbenenner-application/.../service/AiNamingServiceTest` und
`pdf-umbenenner-bootstrap/.../e2e/StubAiInvocationPort` alte
`AiInvocationSuccess`-Konstruktoraufrufe um `AiUsageMetadata.empty()`
ergänzt.
- `SqliteSchemaInitializationAdapterTest.fall1_leereDb_processingAttemptHatAlleErwartetenSpalten`
prüft jetzt zusätzlich die sechs Token-/Preis-Spalten.
- `GuiAdapterSmokeTest.editorWorkspace_startStateShowsEmptyHeaderDefaultsAndOneTab`
erwartet jetzt sechs Tabs inkl. "Modell-Preise".
## Build und Verifizierung
- `mvn clean verify` läuft auf dem Reactor `pdf-umbenenner-parent` durch
(Tests grün auf allen Modulen).
- Commit `08ec021` auf `main` gepusht.
## Bewusst ausgesparte Bereiche (für AP-B / AP-C)
- `CostCalculator.formatTotal(...)` ist ein Stub und wirft
`UnsupportedOperationException`.
- `TokenStatisticsReadModelPort`, `QueryCostAnalysisFullUseCase`,
`QueryCostAnalysisHeaderOnlyUseCase`, `QueryRunSummaryUseCase`,
`SqliteTokenStatisticsReadModelAdapter` sind nicht enthalten.
- Summary-Banner zeigt aktuell `0/0` Tokens und `$0.0000` Kosten, da das
Read-Model erst in AP-B verdrahtet wird.
- CLI-Befehle für Modell-Preise (#99) und Modell-Combobox-Filter (#98)
sind AP-C.
+30 -20
View File
@@ -1,6 +1,6 @@
# V3.3 Token- und Kosten-Tracking
**Status:** Implementierungsvorbereitend Code-Reads vor Implementierungsstart erforderlich (siehe Schlussabschnitt)
**Status:** Implementierungsbereit alle Code-Reads erledigt (siehe Schlussabschnitt)
**Erstellt:** 2026-05-08
**Aktualisiert:** 2026-05-08 (V6 nach fünftem Review)
**Autor:** Marcus (mit Claude als Mentor)
@@ -250,7 +250,7 @@ also auch Aggregationsschlüssel:
### V3.3-Provider-Whitelist
V3.3 unterstützt fachlich **nur** `openai-compatible` und `anthropic`.
V3.3 unterstützt fachlich **nur** `openai-compatible` und `claude`.
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.
@@ -408,9 +408,9 @@ VALUES
('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')
('claude', 'claude-haiku-4-5-20251001', 1000, 5000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-sonnet-4-6', 3000, 15000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-opus-4-7', 5000, 25000, 'USD', '2026-05-08T00:00:00Z')
ON CONFLICT (provider, model_name) DO NOTHING;
```
@@ -951,6 +951,13 @@ performanter; V3.x falls Performance-Probleme in der Praxis auftreten.
| RunLock-Mechanismus | Bleibt unverändert |
| SQL-Boolean-Resultsets | Adapter mappt via `getInt(...) != 0` |
**Implementierungshinweis:** Code-Read hat ergeben, dass mehrere Repository-Adapter direkte
`DriverManager.getConnection`-Aufrufe ohne `SQLiteConfig` verwenden
(`SqliteProcessingAttemptRepositoryAdapter`, `SqliteUnitOfWorkAdapter` u.a.). Eine zentrale
DataSource würde diese nicht abdecken. AP-A umfasst daher zwingend die Einführung einer
zentralen Connection-Factory sowie den Umzug aller direkten `DriverManager.getConnection`-Stellen
auf diese Factory, bevor WAL und `busy_timeout` wirksam sind.
### Hook im `BatchRunProcessingUseCase`
```
@@ -986,9 +993,9 @@ performanter; V3.x falls Performance-Probleme in der Praxis auftreten.
- **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**
- **Unbekannte Provider** (nicht `openai-compatible`/`claude`): 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`
- **„Modell hinzufügen"**-Dialog: Provider-Combobox nur `openai-compatible` und `claude`
- **„Speichern"**: transaktionaler Batch
- **`updatedAt = null`** → Spalte zeigt „ungültig" mit Tooltip
@@ -1063,9 +1070,13 @@ Pro-Attempt-Snapshot. Identischer Modellname zwei Provider → zwei Zeilen.
#### Quellname / Neuster Name
`last_known_source_file_name` (LEFT JOIN, NULL→„–"); `final_target_file_name`
`last_known_source_file_name` (mutabel wird bei jedem Verarbeitungslauf auf
den aktuellen Quellpfad aktualisiert; zeigt daher den zuletzt bekannten Quellnamen,
nicht zwingend den ursprünglichen) (LEFT JOIN, NULL→„–"); `final_target_file_name`
des jüngsten Versuchs (NULL→„–").
Tooltip Quellname-Spalte: „letzter bekannter Quellname (wird bei Wiederholung aktualisiert)".
#### Banner und Gesamt-Kosten-Kopfzeile
Gemäß Anzeige-Semantik. Kopfzeile bezieht sich auf vollständigen Zeitraum.
@@ -1210,7 +1221,7 @@ Anthropic-Modell-IDs mit achtstelligem Datumssuffix ohne Trennstriche
| 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. |
| Whitelist-Validierung | `--upsert-model-price` lehnt unbekannte Provider ab (nur `openai-compatible`/`claude` 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:**
@@ -1277,6 +1288,7 @@ Code-Read).
- [ ] `V2__token_tracking.sql` erstellt und auf V1-DB getestet
- [ ] WAL + `busy_timeout` 5s pro Connection verifiziert
- [ ] Zentrale Connection-Factory eingeführt; alle direkten `DriverManager.getConnection`-Aufrufe in Repository-Adaptern auf Factory umgezogen (Voraussetzung für wirksames WAL + `busy_timeout`)
- [ ] `AiUsageMetadata`, `AiInvocationSuccess` erweitert
- [ ] `ModelPriceEntry` (non-null updatedAt), `ModelPriceView` (nullable)
- [ ] `ModelPriceChangeSet`, `ModelPriceKey`
@@ -1570,13 +1582,13 @@ Code-Read).
| # | 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 |
| 1 | `ai_provider`-Werte in `processing_attempt` | `openai-compatible`, `anthropic` | Default-Inserts und Lookup-Logik anpassen | abweichend Provider-Wert ist `claude` (nicht `anthropic`); Spec angepasst |
| 2 | Primärschlüsselspalte `processing_attempt.id` | `id` (AUTOINCREMENT, monoton) | Pseudo-SQL und Tie-Breaker anpassen | bestätigt `id INTEGER PRIMARY KEY AUTOINCREMENT`; einzige Migration: V1 |
| 3 | Insert-Pfad für `processing_attempt` | Eine zentrale Stelle | Hook für Tokens und Snapshot dort einbauen | bestätigt `SqliteProcessingAttemptRepositoryAdapter.save()`, 20 Spalten |
| 4 | Stabilität von `last_known_source_file_name` | Wird nach Initial-Scan nicht überschrieben | Tooltip ggf. anpassen | abweichend Feld mutabel, wird bei jedem Lauf überschrieben; Spec klargestellt |
| 5 | SQLite-JDBC-Window-Function-Unterstützung | `ROW_NUMBER() OVER (...)` ab SQLite 3.25 | Korrelierte Subquery als Fallback | bestätigt sqlite-jdbc 3.45.1.0; SQLite 3.45.1; `ROW_NUMBER()` unterstützt |
| 6 | Connection-Setup: WAL und `busy_timeout` | Beide aktiv; pro Connection | Im Adapter ergänzen | abweichend WAL und `busy_timeout` fehlen; `DriverManager`-Stellen ohne Config; Spec um Connection-Factory-Pflicht ergänzt |
| 7 | CLI-Adapter-Modul: Befehlsregistrierung | Vorhandenes Pattern nutzen | An bestehendes Pattern anschließen | bestätigt Eigenbau-Switch-Case in `CliArgumentParser`; Einhänge-Punkt: vor `default` in switch (~Z.95), neuer `StartupMode` in `BootstrapRunner` |
### Nicht blockierend
@@ -1586,7 +1598,5 @@ Code-Read).
| 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.
**Hinweis zum Status:** Alle freigabe-blockierenden Reads sind erledigt. Das Freigabe-Gate
ist offen. Die Implementierung kann mit AP-A beginnen.
@@ -489,6 +489,22 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiPromptEditorTab promptEditorTab;
/**
* Sechster Haupt-Tab: Modell-Preise. Verwaltet die persistierten
* {@code model_price}-Eintraege fuer das Token-/Kosten-Tracking.
*/
private final de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPricesTab modelPricesTab;
/**
* Optionaler Bridge-Port zur Abfrage der Modell-Preise.
*
* <p>Wird beim Speichern der Konfiguration zur Pruefung herangezogen,
* ob das aktuell gewaehlte Modell einen Preis-Eintrag besitzt. Fehlt der
* Eintrag, wird eine deutsche Warnung angezeigt; das Speichern bleibt
* trotzdem erlaubt.
*/
private final java.util.Optional<de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort> modelPricePortForConfig;
/**
* Hint banner shown at the top of the configuration tab while a processing run or
* the automatic scheduler is active. Visible + managed state are controlled by
@@ -613,6 +629,11 @@ public final class GuiConfigurationEditorWorkspace {
this.promptEditorTab = new GuiPromptEditorTab(
effectiveContext.promptEditorPort(), configuredPromptPath, maxTitleLength);
this.modelPricePortForConfig = effectiveContext.modelPriceManagementPort();
this.modelPricesTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPricesTab(
this.modelPricePortForConfig.orElse(null),
() -> java.util.Optional.ofNullable(loadedConfigurationPath()));
configureRoot();
configureHeader(effectiveContext.startupNotice());
configureTabs();
@@ -1054,6 +1075,7 @@ public final class GuiConfigurationEditorWorkspace {
* "Speichern unter" to let the user choose a target path.
*/
public void requestSaveConfiguration() {
warnIfActiveModelHasNoPriceEntry();
if (editorState.isNewConfiguration()) {
requestSaveConfigurationAs();
return;
@@ -1062,6 +1084,88 @@ public final class GuiConfigurationEditorWorkspace {
saveToPath(targetPath);
}
/**
* Prueft, ob das aktuell ausgewaehlte Modell einen Preis-Eintrag hat.
*
* <p>Liegt kein Eintrag vor, wird eine deutsche Warnung im zentralen
* Meldungsbereich erzeugt. Das Speichern selbst wird durch diese Pruefung
* nicht blockiert; sie dient ausschlie&szlig;lich dem Hinweis, da&szlig;
* Token-Tracking ohne Preis-Snapshot fortgesetzt wird.
*/
private void warnIfActiveModelHasNoPriceEntry() {
if (modelPricePortForConfig.isEmpty()) {
return;
}
Path configPath = loadedConfigurationPath();
if (configPath == null) {
return;
}
String activeProvider = editorState.values().activeProviderFamily();
if (activeProvider == null || activeProvider.isBlank()) {
return;
}
de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily family;
try {
family = de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily
.fromIdentifier(activeProvider).orElse(null);
} catch (RuntimeException ex) {
family = null;
}
if (family == null) {
return;
}
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState providerState =
editorState.values().providerConfigurations().get(family);
if (providerState == null) {
return;
}
String modelName = providerState.model();
if (modelName == null || modelName.isBlank()) {
return;
}
try {
java.util.Optional<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView> view =
modelPricePortForConfig.get().findByProviderAndModelName(
configPath, family.getIdentifier(), modelName);
if (view.isEmpty()) {
LOG.warn("Kein Modell-Preis fuer Provider \"{}\" und Modell \"{}\" hinterlegt "
+ "Tokens werden erfasst, Kosten koennen jedoch nicht vollstaendig berechnet werden.",
family.getIdentifier(), modelName);
showSensitiveMessage(
"Warnung: Fuer das aktuell gewaehlte Modell \"" + modelName
+ "\" ist kein Preis hinterlegt. Tokens werden weiterhin erfasst, "
+ "Kosten koennen jedoch nicht vollstaendig berechnet werden.");
}
} catch (RuntimeException ex) {
LOG.warn("Pruefung auf Modell-Preis fehlgeschlagen: {}", ex.getMessage());
}
}
/**
* Zeigt eine deutsche Warnung als nicht-blockierenden Alert an.
*
* <p>Die Warnung erscheint nur wenn ein FX-Fenster aktiv ist; in
* Headless-Smoke-Tests faellt die Methode auf einen Log-Eintrag zurueck.
*
* @param message Warntext
*/
private void showSensitiveMessage(String message) {
if (root.getScene() == null || root.getScene().getWindow() == null) {
LOG.warn(message);
return;
}
try {
javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.WARNING, message,
javafx.scene.control.ButtonType.OK);
alert.setHeaderText("Modell-Preis fehlt");
alert.show();
} catch (RuntimeException ex) {
LOG.warn("Konnte Modell-Preis-Warnung nicht anzeigen: {} Originalmeldung: {}",
ex.getMessage(), message);
}
}
/**
* Handles the explicit "Speichern unter" action.
* <p>
@@ -1625,6 +1729,8 @@ public final class GuiConfigurationEditorWorkspace {
statusBarStateListener.accept(newState);
// Prompt-Tab mit neuem Pfad und Port versorgen
notifyPromptTabConfigChanged(newState);
// Modell-Preise-Tab ueber neue Konfiguration informieren (aktiviert die Buttons)
modelPricesTab.notifyConfigurationChanged();
}
/**
@@ -1746,7 +1852,8 @@ public final class GuiConfigurationEditorWorkspace {
scrollPane.setPadding(new Insets(0));
editorTab.setContent(scrollPane);
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), schedulerTab.tab(), historyTab.tab(), promptEditorTab.tab());
tabPane.getTabs().setAll(editorTab, batchRunTab.tab(), schedulerTab.tab(),
historyTab.tab(), promptEditorTab.tab(), modelPricesTab.tab());
root.setCenter(tabPane);
// Tab-Wechsel-Schutz: Beim Wechsel weg vom Verarbeitungslauf-Tab prüfen ob
@@ -17,6 +17,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
@@ -105,7 +106,8 @@ public record GuiStartupContext(
Optional<String> applicationContextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase,
Optional<ConfigurationFileLockPort> configurationFileLockPort,
GuiApplicationContextInitializer applicationContextInitializer) {
GuiApplicationContextInitializer applicationContextInitializer,
Optional<GuiModelPriceManagementPort> modelPriceManagementPort) {
private static final String NO_PROMPT_PORT_MSG = "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.";
private static final String NO_PORT_MSG = "Kein Port in diesem Startkontext.";
@@ -195,6 +197,7 @@ public record GuiStartupContext(
configurationFileLockPort = Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
applicationContextInitializer = applicationContextInitializer == null
? GuiApplicationContextInitializer.noOp() : applicationContextInitializer;
modelPriceManagementPort = Objects.requireNonNullElse(modelPriceManagementPort, Optional.empty());
}
/**
@@ -263,7 +266,7 @@ public record GuiStartupContext(
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, Optional.empty(), Optional.empty(),
GuiApplicationContextInitializer.noOp());
GuiApplicationContextInitializer.noOp(), Optional.empty());
}
/**
@@ -335,7 +338,7 @@ public record GuiStartupContext(
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, schedulerControlUseCase, Optional.empty(),
GuiApplicationContextInitializer.noOp());
GuiApplicationContextInitializer.noOp(), Optional.empty());
}
/**
@@ -1,8 +1,11 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@@ -10,6 +13,7 @@ import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
/**
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
@@ -61,9 +65,29 @@ public final class BatchRunSummaryBanner {
/** Wurzel-Container des Banners wird in das Tab-Layout eingebettet. */
private final HBox container;
/**
* Vertikale Halterung fuer mehrzeilige Banner-Inhalte.
*
* <p>Enthaelt die Status-Zeile (bestehend), die Token-Zeile, die
* Kosten-Zeile und optional eine Cache-only-Zeile. Die Token-/Kosten-
* /Cache-Zeilen werden in V3.3 mit einem leeren {@link BatchRunTokenSummary}
* vorbelegt; die echten Aggregat-Werte werden durch das nachfolgende
* Arbeitspaket geliefert.
*/
private final VBox lineContainer;
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
private final Label contentLabel;
/** Label fuer die Tokenzeile (Input/Output). */
private final Label tokenLabel;
/** Label fuer die Kosten-Zeile. */
private final Label costLabel;
/** Label fuer die optionale Cache-only-Hinweiszeile. */
private final Label cacheOnlyLabel;
/**
* Erstellt ein neues, initial unsichtbares Summary-Banner.
*/
@@ -72,7 +96,19 @@ public final class BatchRunSummaryBanner {
contentLabel.setStyle(STYLE_DEFAULT);
contentLabel.setWrapText(false);
container = new HBox(SPACING, contentLabel);
tokenLabel = new Label();
tokenLabel.setStyle(STYLE_DEFAULT);
costLabel = new Label();
costLabel.setStyle(STYLE_DEFAULT);
cacheOnlyLabel = new Label();
cacheOnlyLabel.setStyle(STYLE_DEFAULT);
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
lineContainer = new VBox(2, contentLabel, tokenLabel, costLabel, cacheOnlyLabel);
lineContainer.setAlignment(Pos.CENTER_LEFT);
container = new HBox(SPACING, lineContainer);
container.setAlignment(Pos.CENTER_LEFT);
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
@@ -92,6 +128,11 @@ public final class BatchRunSummaryBanner {
*/
public void clear() {
contentLabel.setText("");
tokenLabel.setText("");
costLabel.setText("");
cacheOnlyLabel.setText("");
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
container.setVisible(false);
container.setManaged(false);
}
@@ -108,19 +149,115 @@ public final class BatchRunSummaryBanner {
* fehlende Status werden als 0 interpretiert; darf nicht null sein
*/
public void update(Map<DocumentCompletionStatus, Integer> counts) {
update(counts, BatchRunTokenSummary.empty());
}
/**
* Aktualisiert das Banner mit Status-Zaehlern und Token-/Kosten-Aggregaten.
*
* <p>Zeigt die Status-Zeile (wenn nicht leer) sowie die Token- und Kosten-
* Zeilen. Die Cache-only-Zeile erscheint nur, wenn {@link
* BatchRunTokenSummary#cacheOnlyAttemptCount()} groesser als 0 ist.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param counts Zaehler je Status; nicht {@code null}
* @param tokenSummary Aggregat-Werte fuer Tokens und Kosten; nicht {@code null}
*/
public void update(Map<DocumentCompletionStatus, Integer> counts,
BatchRunTokenSummary tokenSummary) {
Objects.requireNonNull(counts, "counts darf nicht null sein");
Objects.requireNonNull(tokenSummary, "tokenSummary darf nicht null sein");
String text = buildBannerText(counts);
if (text.isEmpty()) {
contentLabel.setText(text);
tokenLabel.setText(buildTokenLine(tokenSummary));
costLabel.setText(buildCostLine(tokenSummary));
if (tokenSummary.cacheOnlyAttemptCount() > 0) {
cacheOnlyLabel.setText(buildCacheOnlyLine(tokenSummary.cacheOnlyAttemptCount()));
cacheOnlyLabel.setVisible(true);
cacheOnlyLabel.setManaged(true);
} else {
cacheOnlyLabel.setText("");
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
}
if (text.isEmpty() && !tokenSummary.hasAnyData()) {
clear();
return;
}
contentLabel.setText(text);
container.setVisible(true);
container.setManaged(true);
}
/**
* Token-/Kosten-Aggregat fuer einen Banner-Eintrag.
*
* <p>Ein {@link #empty()}-Default reicht, solange das Read-Model fuer
* Aggregate noch nicht implementiert ist. AP-B liefert spaeter die echten
* Werte ueber den {@code TokenStatisticsReadModelPort}.
*
* @param totalInputTokens Summe Input-Tokens; ggf. {@code 0}
* @param totalOutputTokens Summe Output-Tokens; ggf. {@code 0}
* @param totalCostUsd Summe der Kosten in USD; ggf. {@code BigDecimal.ZERO}
* @param hasMissingPriceSnapshot {@code true}, wenn mind. ein Versuch ohne Preis-Snapshot vorlag
* @param hasCacheTokensIgnored {@code true}, wenn Cache-Tokens vorkamen
* @param cacheOnlyAttemptCount Anzahl Cache-only-Versuche im Lauf
*/
public record BatchRunTokenSummary(
long totalInputTokens,
long totalOutputTokens,
BigDecimal totalCostUsd,
boolean hasMissingPriceSnapshot,
boolean hasCacheTokensIgnored,
long cacheOnlyAttemptCount) {
/**
* Liefert ein leeres Aggregat (alle Zaehler null, Kosten 0).
*
* @return leeres Aggregat
*/
public static BatchRunTokenSummary empty() {
return new BatchRunTokenSummary(0L, 0L, BigDecimal.ZERO, false, false, 0L);
}
/**
* Pruefung, ob ueberhaupt Daten zum Anzeigen vorliegen.
*
* @return {@code true} bei Werten ungleich 0
*/
public boolean hasAnyData() {
return totalInputTokens > 0 || totalOutputTokens > 0
|| (totalCostUsd != null && totalCostUsd.signum() != 0)
|| cacheOnlyAttemptCount > 0;
}
}
private static String buildTokenLine(BatchRunTokenSummary s) {
return String.format(Locale.GERMAN, "Tokens: Input %,d Output %,d",
s.totalInputTokens(), s.totalOutputTokens());
}
private static String buildCostLine(BatchRunTokenSummary s) {
BigDecimal cost = s.totalCostUsd() != null ? s.totalCostUsd() : BigDecimal.ZERO;
BigDecimal rounded = cost.setScale(4, RoundingMode.HALF_UP);
StringBuilder sb = new StringBuilder("Kosten: $").append(rounded.toPlainString());
if (s.hasCacheTokensIgnored()) {
sb.append(" (ohne Cache-Anteil)");
}
if (s.hasMissingPriceSnapshot()) {
sb.append(" (unvollstaendig)");
}
return sb.toString();
}
private static String buildCacheOnlyLine(long count) {
return " " + count + " Cache-only Versuche (in Kosten nicht enthalten)";
}
/**
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
*
@@ -519,7 +519,84 @@ public final class GuiHistoryTab {
? c.getValue().finalTargetFileName() : ""));
fileNameCol.setCellFactory(col -> ellipsisCell());
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol, fileNameCol);
TableColumn<ProcessingAttempt, String> inputTokensCol = new TableColumn<>("Input-Tokens");
inputTokensCol.setCellValueFactory(c ->
new SimpleStringProperty(formatTokenCount(c.getValue().inputTokens())));
inputTokensCol.setPrefWidth(110);
TableColumn<ProcessingAttempt, String> outputTokensCol = new TableColumn<>("Output-Tokens");
outputTokensCol.setCellValueFactory(c ->
new SimpleStringProperty(formatTokenCount(c.getValue().outputTokens())));
outputTokensCol.setPrefWidth(110);
TableColumn<ProcessingAttempt, String> costCol = new TableColumn<>("Kosten");
costCol.setCellValueFactory(c -> new SimpleStringProperty(formatAttemptCost(c.getValue())));
costCol.setPrefWidth(140);
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol,
fileNameCol, inputTokensCol, outputTokensCol, costCol);
}
/**
* Formatiert eine Token-Anzahl mit deutscher Tausenderfassung.
*
* @param value Wert oder {@code null}
* @return Anzeigetext, "" bei {@code null}
*/
private static String formatTokenCount(Long value) {
if (value == null) {
return "";
}
return String.format(java.util.Locale.GERMAN, "%,d", value);
}
/**
* Bestimmt den Anzeigetext fuer die Kosten-Spalte eines Versuchs.
*
* <p>Cache-only-Versuche zeigen den Hinweis &quot;nur Cache-Tokens, keine
* Standardkosten&quot;. Bei fehlendem Preis-Snapshot erscheint
* &quot;Preis fehlt&quot;. Mikrobetraege werden als &quot;&lt;
* $0.0001&quot; dargestellt.
*
* @param attempt Versuchseintrag
* @return Anzeigetext fuer die Kosten-Spalte
*/
private static String formatAttemptCost(ProcessingAttempt attempt) {
boolean hasStandardTokens = attempt.inputTokens() != null || attempt.outputTokens() != null;
boolean hasCacheTokens =
(attempt.cacheCreationInputTokens() != null && attempt.cacheCreationInputTokens() > 0)
|| (attempt.cacheReadInputTokens() != null && attempt.cacheReadInputTokens() > 0);
if (!hasStandardTokens) {
if (hasCacheTokens) {
return "nur Cache-Tokens, keine Standardkosten";
}
return "";
}
de.gecheckt.pdf.umbenenner.application.cost.CostCalculator calc =
new de.gecheckt.pdf.umbenenner.application.cost.CostCalculator();
de.gecheckt.pdf.umbenenner.application.cost.CostResult result = calc.calculateAttempt(
attempt.inputTokens(),
attempt.outputTokens(),
attempt.priceInputPerTokenNanoUsd(),
attempt.priceOutputPerTokenNanoUsd(),
hasCacheTokens);
if (result.amountUsd() == null) {
if (result.missingPriceSnapshot()) {
return "Preis fehlt";
}
return "";
}
java.math.BigDecimal amount = result.amountUsd();
java.math.BigDecimal threshold = new java.math.BigDecimal("0.0001");
if (amount.signum() > 0 && amount.compareTo(threshold) < 0) {
return "< $0.0001";
}
java.math.BigDecimal rounded = amount.setScale(4, java.math.RoundingMode.HALF_UP);
String suffix = result.cacheTokensIgnored() ? " (ohne Cache-Anteil)" : "";
if (result.partialTokens()) {
return "~$" + rounded.toPlainString() + suffix;
}
return "$" + rounded.toPlainString() + suffix;
}
// =========================================================================
@@ -0,0 +1,53 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
/**
* GUI-interner Bridge-Port fuer die Verwaltung von Modell-Preisen.
*
* <p>Dieser Port ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Er ist eine modul-interne Bruecke, ueber die Bootstrap die SQLite-basierte
* Verwaltung der Tabelle {@code model_price} fuer den GUI-Tab bereitstellt,
* ohne dass der GUI-Adapter direkt auf Repository-Implementierungen zugreift.
*
* <p>Der Parameter {@code configFilePath} wird benoetigt, damit die
* Bootstrap-Implementierung die SQLite-Datenbank aus der aktuell geladenen
* Konfigurationsdatei ableiten kann, ohne den Pfad global zu speichern.
*
* <p><strong>Threading:</strong> Implementierungen muessen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden koennen. Aufrufe blockieren,
* bis das Ergebnis vollstaendig vorliegt.
*/
public interface GuiModelPriceManagementPort {
/**
* Liefert alle persistierten Modell-Preise.
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @return Liste der Modell-Preise; nie {@code null}
*/
List<ModelPriceView> findAll(Path configFilePath);
/**
* Sucht den Eintrag fuer (Provider, Modellname).
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @param provider Provider-Identifikator
* @param modelName Modellname
* @return Eintrag oder {@link Optional#empty()}
*/
Optional<ModelPriceView> findByProviderAndModelName(Path configFilePath, String provider, String modelName);
/**
* Persistiert ein {@link ModelPriceChangeSet} atomar.
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @param changeSet Sammlung aus Upserts und Loeschungen; nicht {@code null}
*/
void saveAllChanges(Path configFilePath, ModelPriceChangeSet changeSet);
}
@@ -0,0 +1,573 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import javafx.application.Platform;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* GUI-Tab fuer die Verwaltung der persistierten Modell-Preise.
*
* <p>Zeigt die Tabelle {@code model_price} aufbereitet als
* {@code $/1M Tokens} an. Eintraege bekannter Provider sind editierbar;
* Eintraege unbekannter Provider werden read-only mit Tooltip dargestellt
* und koennen lediglich geloescht werden.
*
* <p>Der Tab arbeitet ausschlie&szlig;lich gegen den
* {@link GuiModelPriceManagementPort}. Bootstrap verdrahtet den Port mit
* einer Implementierung, die anhand der aktuell geladenen Konfigurationsdatei
* eine SQLite-Verbindung aufbaut.
*
* <p>Threading: alle DB-Operationen laufen auf einem dedizierten
* Hintergrund-Worker-Thread; UI-Updates erfolgen ueber
* {@link Platform#runLater(Runnable)}.
*/
public final class GuiModelPricesTab {
private static final Logger LOG = LogManager.getLogger(GuiModelPricesTab.class);
private static final String TAB_TITLE = "Modell-Preise";
/** V3.3-Whitelist der unterstuetzten Provider. */
public static final List<String> SUPPORTED_PROVIDERS = List.of("openai-compatible", "claude");
private static final BigDecimal NANO_TO_USD_PER_MILLION = new BigDecimal("1000000000")
.divide(new BigDecimal("1000000"));
private final Tab tab = new Tab(TAB_TITLE);
private final TableView<EditableEntry> tableView = new TableView<>();
private final ObservableList<EditableEntry> rows = FXCollections.observableArrayList();
private final Label statusLabel = new Label();
private final Button addButton = new Button("Modell hinzufuegen");
private final Button saveButton = new Button("Speichern");
private final Button reloadButton = new Button("Neu laden");
private final GuiModelPriceManagementPort port;
private final Supplier<Optional<Path>> configPathSupplier;
private final Set<ModelPriceKey> originalKeys = new HashSet<>();
private final List<ModelPriceKey> pendingDeletions = new ArrayList<>();
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "gui-model-prices");
t.setDaemon(true);
return t;
});
/**
* Erstellt den Tab und verdrahtet die Bedienelemente.
*
* @param port Bridge-Port fuer DB-Zugriff; darf {@code null} sein (Tab zeigt dann Hinweis)
* @param configPathSupplier Liefert den aktuell geladenen Konfigurationspfad oder leer; nicht {@code null}
*/
public GuiModelPricesTab(GuiModelPriceManagementPort port,
Supplier<Optional<Path>> configPathSupplier) {
this.port = port;
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier");
tab.setClosable(false);
buildUi();
updateButtonStates();
}
/**
* Liefert den JavaFX-Tab-Knoten.
*
* @return der Tab; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Benachrichtigt den Tab ueber eine geaenderte Konfiguration.
*
* <p>Aktualisiert den Aktivierungsstatus der Buttons anhand des aktuell vom
* {@code configPathSupplier} gelieferten Pfads. Muss auf dem JavaFX Application Thread
* aufgerufen werden.
*/
public void notifyConfigurationChanged() {
updateButtonStates();
}
/**
* Triggert ein Neuladen der Tabelle aus der aktuell geladenen Konfiguration.
*/
public void reloadFromCurrentConfig() {
Optional<Path> currentPath = configPathSupplier.get();
if (port == null || currentPath.isEmpty()) {
Platform.runLater(() -> {
rows.clear();
pendingDeletions.clear();
originalKeys.clear();
statusLabel.setText("Bitte zuerst eine Konfigurationsdatei laden.");
updateButtonStates();
});
return;
}
Path configPath = currentPath.get();
statusLabel.setText("Lade Modell-Preise ...");
workerExecutor.submit(() -> {
try {
List<ModelPriceView> views = port.findAll(configPath);
Platform.runLater(() -> applyLoadedRows(views));
} catch (RuntimeException ex) {
LOG.error("Modell-Preise konnten nicht geladen werden: {}", ex.getMessage(), ex);
Platform.runLater(() -> statusLabel.setText("Fehler beim Laden: " + ex.getMessage()));
}
});
}
private void applyLoadedRows(List<ModelPriceView> views) {
rows.clear();
pendingDeletions.clear();
originalKeys.clear();
for (ModelPriceView view : views) {
rows.add(EditableEntry.fromView(view));
originalKeys.add(new ModelPriceKey(view.provider(), view.modelName()));
}
statusLabel.setText("Geladen: " + views.size() + " Eintraege.");
updateButtonStates();
}
private void buildUi() {
tableView.setItems(rows);
tableView.setEditable(true);
tableView.setPlaceholder(new Label("Keine Modell-Preise vorhanden."));
TableColumn<EditableEntry, String> providerCol = new TableColumn<>("Provider");
providerCol.setCellValueFactory(c -> c.getValue().providerProperty);
providerCol.setPrefWidth(150);
TableColumn<EditableEntry, String> modelCol = new TableColumn<>("Modellname");
modelCol.setCellValueFactory(c -> c.getValue().modelNameProperty);
modelCol.setPrefWidth(220);
TableColumn<EditableEntry, String> inCol = new TableColumn<>("In/1M USD");
inCol.setCellValueFactory(c -> c.getValue().inputPriceTextProperty);
inCol.setPrefWidth(120);
inCol.setCellFactory(col -> new PriceEditCell(true));
TableColumn<EditableEntry, String> outCol = new TableColumn<>("Out/1M USD");
outCol.setCellValueFactory(c -> c.getValue().outputPriceTextProperty);
outCol.setPrefWidth(120);
outCol.setCellFactory(col -> new PriceEditCell(false));
TableColumn<EditableEntry, String> currencyCol = new TableColumn<>("Waehrung");
currencyCol.setCellValueFactory(c -> c.getValue().currencyProperty);
currencyCol.setPrefWidth(80);
TableColumn<EditableEntry, String> updatedCol = new TableColumn<>("Letzte Aenderung");
updatedCol.setCellValueFactory(c -> c.getValue().updatedAtTextProperty);
updatedCol.setPrefWidth(180);
TableColumn<EditableEntry, Void> deleteCol = new TableColumn<>("Aktion");
deleteCol.setCellFactory(col -> new DeleteButtonCell());
deleteCol.setPrefWidth(80);
tableView.getColumns().setAll(List.of(providerCol, modelCol, inCol, outCol,
currencyCol, updatedCol, deleteCol));
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_LAST_COLUMN);
addButton.setOnAction(e -> openAddDialog());
saveButton.setOnAction(e -> handleSave());
reloadButton.setOnAction(e -> reloadFromCurrentConfig());
HBox buttonBar = new HBox(8, addButton, saveButton, reloadButton);
buttonBar.setAlignment(Pos.CENTER_LEFT);
VBox.setVgrow(tableView, Priority.ALWAYS);
VBox content = new VBox(8, tableView, buttonBar, statusLabel);
content.setPadding(new Insets(12));
tab.setContent(content);
statusLabel.setText("Klicken Sie auf \"Neu laden\", um die aktuellen Modell-Preise anzuzeigen.");
}
private void updateButtonStates() {
Optional<Path> path = configPathSupplier.get();
boolean active = port != null && path.isPresent();
addButton.setDisable(!active);
saveButton.setDisable(!active);
reloadButton.setDisable(!active);
}
private void openAddDialog() {
Dialog<EditableEntry> dialog = new Dialog<>();
dialog.setTitle("Modell hinzufuegen");
dialog.setHeaderText("Neuen Modell-Preis erfassen");
ChoiceBox<String> providerBox = new ChoiceBox<>(FXCollections.observableArrayList(SUPPORTED_PROVIDERS));
providerBox.getSelectionModel().selectFirst();
TextField modelField = new TextField();
TextField inputField = new TextField();
TextField outputField = new TextField();
GridPane grid = new GridPane();
grid.setHgap(8);
grid.setVgap(8);
grid.add(new Label("Provider"), 0, 0);
grid.add(providerBox, 1, 0);
grid.add(new Label("Modellname"), 0, 1);
grid.add(modelField, 1, 1);
grid.add(new Label("In/1M USD"), 0, 2);
grid.add(inputField, 1, 2);
grid.add(new Label("Out/1M USD"), 0, 3);
grid.add(outputField, 1, 3);
dialog.getDialogPane().setContent(grid);
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
dialog.setResultConverter(button -> {
if (button != ButtonType.OK) {
return null;
}
String provider = providerBox.getValue();
String modelName = modelField.getText() != null ? modelField.getText().trim() : "";
if (provider == null || modelName.isEmpty()) {
showError("Provider und Modellname sind Pflichtfelder.");
return null;
}
try {
long inputNano = parseUsdPerMillionToNano(inputField.getText());
long outputNano = parseUsdPerMillionToNano(outputField.getText());
EditableEntry entry = new EditableEntry(provider, modelName,
inputNano, outputNano, "USD", null, false, true);
return entry;
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
return null;
}
});
Optional<EditableEntry> result = dialog.showAndWait();
result.ifPresent(entry -> {
for (EditableEntry existing : rows) {
if (existing.providerProperty.get().equals(entry.providerProperty.get())
&& existing.modelNameProperty.get().equals(entry.modelNameProperty.get())) {
showError("Eintrag fuer Provider \"" + entry.providerProperty.get()
+ "\" und Modell \"" + entry.modelNameProperty.get()
+ "\" existiert bereits.");
return;
}
}
rows.add(entry);
statusLabel.setText("Neuer Eintrag vorgemerkt; bitte speichern.");
});
}
private void handleSave() {
Optional<Path> currentPath = configPathSupplier.get();
if (port == null || currentPath.isEmpty()) {
return;
}
ModelPriceChangeSet changeSet;
try {
changeSet = buildChangeSet();
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
return;
}
if (changeSet.isEmpty()) {
statusLabel.setText("Keine Aenderungen zu speichern.");
return;
}
Path configPath = currentPath.get();
statusLabel.setText("Speichere ...");
saveButton.setDisable(true);
workerExecutor.submit(() -> {
try {
port.saveAllChanges(configPath, changeSet);
Platform.runLater(() -> {
statusLabel.setText("Aenderungen gespeichert.");
reloadFromCurrentConfig();
});
} catch (RuntimeException ex) {
LOG.error("Modell-Preis-Speichern fehlgeschlagen: {}", ex.getMessage(), ex);
Platform.runLater(() -> {
statusLabel.setText("Fehler beim Speichern: " + ex.getMessage());
saveButton.setDisable(false);
});
}
});
}
private ModelPriceChangeSet buildChangeSet() {
List<ModelPriceEntry> upserts = new ArrayList<>();
Instant placeholder = Instant.now();
for (EditableEntry row : rows) {
if (!row.editable) {
continue;
}
if (!row.dirty && originalKeys.contains(
new ModelPriceKey(row.providerProperty.get(), row.modelNameProperty.get()))) {
continue;
}
try {
upserts.add(new ModelPriceEntry(
row.providerProperty.get(),
row.modelNameProperty.get(),
row.inputPriceNanoUsd,
row.outputPriceNanoUsd,
"USD",
placeholder));
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Eintrag (" + row.providerProperty.get()
+ ", " + row.modelNameProperty.get() + ") ungueltig: " + ex.getMessage());
}
}
return new ModelPriceChangeSet(upserts, List.copyOf(pendingDeletions));
}
private void showError(String message) {
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setHeaderText("Eingabefehler");
alert.showAndWait();
}
/**
* Konvertiert eine $/1M-Tokens-Eingabe in Nano-USD/Token.
*
* <p>Akzeptiert Komma oder Punkt als Dezimaltrenner. Maximal sechs
* Nachkommastellen sind erlaubt; mehr fuehrt zur
* {@link IllegalArgumentException}. Negative Werte und Nicht-Numerisches
* werden ebenfalls abgewiesen.
*
* @param raw Eingabetext
* @return umgerechneter Nano-USD-Wert
* @throws IllegalArgumentException bei ungueltiger Eingabe
*/
static long parseUsdPerMillionToNano(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("Preis darf nicht leer sein.");
}
String normalized = raw.trim().replace(',', '.');
BigDecimal value;
try {
value = new BigDecimal(normalized);
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Preis ist nicht numerisch: " + raw);
}
if (value.signum() < 0) {
throw new IllegalArgumentException("Preis darf nicht negativ sein.");
}
if (value.scale() > 6) {
throw new IllegalArgumentException("Maximal 6 Nachkommastellen erlaubt.");
}
BigDecimal nanoPerToken = value.multiply(BigDecimal.valueOf(1000L))
.setScale(0, RoundingMode.HALF_UP);
long nanoLong = nanoPerToken.longValueExact();
if (nanoLong > ModelPriceEntry.MAX_PRICE_PER_TOKEN_NANO_USD) {
throw new IllegalArgumentException("Preis ueberschreitet Maximum.");
}
return nanoLong;
}
/**
* Formatiert einen Nano-USD-Wert als $/1M-Tokens-Text.
*
* @param nanoPerToken Nano-USD pro Token
* @return Formatierter Text mit bis zu sechs Nachkommastellen
*/
static String formatNanoAsUsdPerMillion(long nanoPerToken) {
BigDecimal usdPerMillion = BigDecimal.valueOf(nanoPerToken)
.multiply(NANO_TO_USD_PER_MILLION)
.divide(new BigDecimal("1000000000"), 6, RoundingMode.HALF_UP)
.stripTrailingZeros();
return usdPerMillion.toPlainString();
}
/**
* Mutable Tabellenzeile. Kapselt View-Felder als Properties und einen
* Dirty-Flag fuer den ChangeSet-Bau.
*/
private static final class EditableEntry {
final SimpleStringProperty providerProperty;
final SimpleStringProperty modelNameProperty;
final SimpleStringProperty inputPriceTextProperty;
final SimpleStringProperty outputPriceTextProperty;
final SimpleStringProperty currencyProperty;
final SimpleStringProperty updatedAtTextProperty;
final SimpleObjectProperty<Boolean> invalidUpdatedAtProperty;
long inputPriceNanoUsd;
long outputPriceNanoUsd;
boolean editable;
boolean dirty;
EditableEntry(String provider, String modelName,
long inputNano, long outputNano, String currency,
Instant updatedAt, boolean invalidUpdatedAt, boolean editable) {
this.providerProperty = new SimpleStringProperty(provider);
this.modelNameProperty = new SimpleStringProperty(modelName);
this.inputPriceNanoUsd = inputNano;
this.outputPriceNanoUsd = outputNano;
this.inputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(inputNano));
this.outputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(outputNano));
this.currencyProperty = new SimpleStringProperty(currency);
this.updatedAtTextProperty = new SimpleStringProperty(
invalidUpdatedAt ? "ungueltig"
: updatedAt == null ? "" : DateTimeFormatter.ISO_INSTANT.format(updatedAt));
this.invalidUpdatedAtProperty = new SimpleObjectProperty<>(invalidUpdatedAt);
this.editable = editable;
this.dirty = false;
}
static EditableEntry fromView(ModelPriceView view) {
boolean editable = SUPPORTED_PROVIDERS.contains(view.provider());
EditableEntry entry = new EditableEntry(
view.provider(), view.modelName(),
view.priceInputPerTokenNanoUsd(), view.priceOutputPerTokenNanoUsd(),
view.currency(), view.updatedAt(), view.invalidUpdatedAt(), editable);
return entry;
}
}
/**
* Editierbare Zelle fuer Input-/Output-Preisfelder.
*/
private final class PriceEditCell extends TableCell<EditableEntry, String> {
private final TextField textField = new TextField();
private final boolean isInputColumn;
PriceEditCell(boolean isInputColumn) {
this.isInputColumn = isInputColumn;
textField.focusedProperty().addListener((obs, was, focused) -> {
if (!focused) {
commit();
}
});
textField.setOnAction(e -> commit());
}
private void commit() {
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row == null || !row.editable) {
return;
}
String text = textField.getText();
try {
long nano = parseUsdPerMillionToNano(text);
if (isInputColumn) {
if (row.inputPriceNanoUsd != nano) {
row.inputPriceNanoUsd = nano;
row.dirty = true;
}
row.inputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
} else {
if (row.outputPriceNanoUsd != nano) {
row.outputPriceNanoUsd = nano;
row.dirty = true;
}
row.outputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
}
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
String revert = isInputColumn
? formatNanoAsUsdPerMillion(row.inputPriceNanoUsd)
: formatNanoAsUsdPerMillion(row.outputPriceNanoUsd);
textField.setText(revert);
}
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
return;
}
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row != null && !row.editable) {
setText(item);
setGraphic(null);
setTooltip(new Tooltip("Unbekannter Provider Bearbeitung in V3.3 nicht unterstuetzt."));
return;
}
textField.setText(item);
setText(null);
setGraphic(textField);
}
}
/**
* Loesch-Button-Spalte mit Bestaetigungsdialog.
*/
private final class DeleteButtonCell extends TableCell<EditableEntry, Void> {
private final Button button = new Button("Loeschen");
DeleteButtonCell() {
button.setOnAction(e -> {
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row == null) {
return;
}
String message = String.format(Locale.GERMAN,
"Eintrag fuer Provider \"%s\" und Modell \"%s\" wirklich loeschen?",
row.providerProperty.get(), row.modelNameProperty.get());
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, message, ButtonType.OK, ButtonType.CANCEL);
alert.setHeaderText("Loeschen bestaetigen");
alert.showAndWait().ifPresent(button -> {
if (button == ButtonType.OK) {
ModelPriceKey key = new ModelPriceKey(
row.providerProperty.get(), row.modelNameProperty.get());
if (originalKeys.contains(key)) {
pendingDeletions.add(key);
}
rows.remove(row);
}
});
});
}
@Override
protected void updateItem(Void item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setGraphic(null);
} else {
setGraphic(button);
}
}
}
}
@@ -0,0 +1,9 @@
/**
* GUI-Bestandteile fuer die Verwaltung der persistierten Modell-Preise.
*
* <p>Enthaelt den Bridge-Port {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices
* .GuiModelPriceManagementPort} und den zugehoerigen Tab. Der Port wird von
* Bootstrap mit einer Lambda-Implementierung gefuellt, die anhand der aktuell
* geladenen Konfigurationsdatei eine SQLite-Repository-Instanz aufbaut.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
@@ -244,8 +244,9 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
assertEquals(5, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab, scheduler tab, history tab and prompt editor tab must all be present");
assertEquals(6, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab, scheduler tab, history tab, "
+ "prompt editor tab and model-prices tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
@@ -256,6 +257,8 @@ class GuiAdapterSmokeTest {
"The fourth tab must host the history view");
assertEquals("Prompt", workspace.tabPane().getTabs().get(4).getText(),
"The fifth tab must host the prompt editor");
assertEquals("Modell-Preise", workspace.tabPane().getTabs().get(5).getText(),
"The sixth tab must host the model prices view");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),
@@ -14,6 +14,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
@@ -356,7 +357,8 @@ public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
"Anthropic response contained no text-type content blocks");
}
return new AiInvocationSuccess(request, new AiRawResponse(extractedText));
return new AiInvocationSuccess(request, new AiRawResponse(extractedText),
extractTokenUsageFromResponse(json));
} catch (JSONException e) {
LOG.warn("Claude AI response could not be parsed as JSON: {}", e.getMessage());
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
@@ -364,6 +366,64 @@ public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
}
}
/**
* Extrahiert Token-Verbrauchsmetadaten aus der Anthropic-Response.
*
* <p>Anthropic Messages API liefert im Top-Level-Feld {@code usage}:
* {@code input_tokens}, {@code output_tokens},
* {@code cache_creation_input_tokens}, {@code cache_read_input_tokens}.
*
* <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
* Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
*
* @param root die geparste Anthropic-Response
* @return befuelltes {@link AiUsageMetadata}; nie {@code null}
*/
private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
JSONObject usage = root.optJSONObject("usage");
if (usage == null) {
LOG.warn("Anthropic-Response enthielt kein usage-Feld Token-Daten werden nicht erfasst");
return AiUsageMetadata.empty();
}
Long inputTokens = readTokenField(usage, "input_tokens");
Long outputTokens = readTokenField(usage, "output_tokens");
Long cacheCreation = readTokenField(usage, "cache_creation_input_tokens");
Long cacheRead = readTokenField(usage, "cache_read_input_tokens");
return new AiUsageMetadata(inputTokens, outputTokens, cacheCreation, cacheRead);
}
/**
* Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
*
* <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
* Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
* mit WARN-Log und gibt {@code null} zurueck.
*
* @param usage das JSON-Objekt mit den Token-Feldern
* @param fieldName Name des Feldes
* @return validierter Token-Wert oder {@code null}
*/
private Long readTokenField(JSONObject usage, String fieldName) {
if (!usage.has(fieldName) || usage.isNull(fieldName)) {
return null;
}
try {
long value = usage.getLong(fieldName);
if (value < 0L) {
LOG.warn("Anthropic-Token-Feld {} ist negativ ({}) Wert verworfen", fieldName, value);
return null;
}
if (value > 10_000_000L) {
LOG.warn("Anthropic-Token-Feld {} uebersteigt Maximum (10 Mio.): {} Wert verworfen", fieldName, value);
return null;
}
return value;
} catch (JSONException e) {
LOG.warn("Anthropic-Token-Feld {} ist nicht numerisch Wert verworfen: {}", fieldName, e.getMessage());
return null;
}
}
/**
* Package-private accessor for the last constructed JSON body.
* <p>
@@ -14,6 +14,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
@@ -268,7 +269,8 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
"OpenAI response message.content is absent or blank");
}
return new AiInvocationSuccess(request, new AiRawResponse(content));
return new AiInvocationSuccess(request, new AiRawResponse(content),
extractTokenUsageFromResponse(json));
} catch (JSONException e) {
LOG.warn("OpenAI response could not be parsed as JSON: {}", e.getMessage());
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
@@ -276,6 +278,63 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
}
}
/**
* Extrahiert Token-Verbrauchsmetadaten aus der OpenAI-Response.
*
* <p>Mapping: {@code usage.prompt_tokens -> inputTokens},
* {@code usage.completion_tokens -> outputTokens}. Cache-Felder sind in der
* OpenAI-kompatiblen Schnittstelle nicht standardisiert und bleiben immer
* {@code null}.
*
* <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
* Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
*
* @param root die geparste OpenAI-Response (Envelope)
* @return befuelltes {@link AiUsageMetadata}; nie {@code null}
*/
private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
JSONObject usage = root.optJSONObject("usage");
if (usage == null) {
LOG.warn("OpenAI-Response enthielt kein usage-Feld Token-Daten werden nicht erfasst");
return AiUsageMetadata.empty();
}
Long inputTokens = readTokenField(usage, "prompt_tokens");
Long outputTokens = readTokenField(usage, "completion_tokens");
return new AiUsageMetadata(inputTokens, outputTokens, null, null);
}
/**
* Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
*
* <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
* Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
* mit WARN-Log und gibt {@code null} zurueck.
*
* @param usage das JSON-Objekt mit den Token-Feldern
* @param fieldName Name des Feldes
* @return validierter Token-Wert oder {@code null}
*/
private Long readTokenField(JSONObject usage, String fieldName) {
if (!usage.has(fieldName) || usage.isNull(fieldName)) {
return null;
}
try {
long value = usage.getLong(fieldName);
if (value < 0L) {
LOG.warn("OpenAI-Token-Feld {} ist negativ ({}) Wert verworfen", fieldName, value);
return null;
}
if (value > 10_000_000L) {
LOG.warn("OpenAI-Token-Feld {} uebersteigt Maximum (10 Mio.): {} Wert verworfen", fieldName, value);
return null;
}
return value;
} catch (JSONException e) {
LOG.warn("OpenAI-Token-Feld {} ist nicht numerisch Wert verworfen: {}", fieldName, e.getMessage());
return null;
}
}
/**
* Builds an OpenAI Chat Completions API request from the request representation.
* <p>
@@ -0,0 +1,24 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
/**
* Technischer Fehler im SQLite-Adapter fuer Modell-Preise.
*
* <p>Wird vom {@link SqliteModelPriceRepositoryAdapter} geworfen, wenn ein
* JDBC-Fehler beim Lesen, Schreiben oder Loeschen aufgetreten ist. Die
* Application-Schicht und die GUI behandeln diese Exception als
* technischen Fehler mit deutscher Meldung.
*/
public class ModelPriceRepositoryException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Erzeugt eine neue Ausnahme mit Meldung und Ursache.
*
* @param message deutsche Meldung
* @param cause urspruenglicher Fehler
*/
public ModelPriceRepositoryException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,81 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
/**
* Zentrale Factory fuer SQLite-Connections.
*
* <p>Wird von allen Repository-Adaptern und vom UnitOfWork-Adapter genutzt,
* um Connections mit einheitlichen PRAGMA-Einstellungen zu oeffnen. Damit
* sind WAL-Modus, {@code busy_timeout} und {@code foreign_keys} fuer alle
* Schreib- und Lesepfade wirksam.
*
* <p>Folgende PRAGMAs werden auf jeder Connection gesetzt:
* <ul>
* <li>{@code PRAGMA journal_mode=WAL} Reader werden nicht durch Writer blockiert.</li>
* <li>{@code PRAGMA busy_timeout=5000} wartet bis zu 5 Sekunden, bevor
* {@code SQLITE_BUSY} geworfen wird.</li>
* <li>{@code PRAGMA foreign_keys=ON} aktiviert die Pruefung von Fremdschluesseln
* (entspricht dem bestehenden Verhalten der bisherigen Adapter).</li>
* </ul>
*
* <p>Die Factory ist stateless; die Methode {@link #open(String)} liefert
* jeweils eine frische {@link Connection}, die der Aufrufer (typisch via
* try-with-resources) wieder schlie&szlig;t.
*/
public final class SqliteConnectionFactory {
private SqliteConnectionFactory() {
// Utility class
}
/**
* Oeffnet eine neue SQLite-Connection und setzt die Standard-PRAGMAs.
*
* @param jdbcUrl JDBC-URL zur SQLite-Datenbank, z.B. {@code jdbc:sqlite:/pfad/db.sqlite}
* @return eine neue, eingerichtete {@link Connection}
* @throws SQLException wenn die Verbindung nicht hergestellt oder die
* PRAGMAs nicht gesetzt werden koennen
*/
public static Connection open(String jdbcUrl) throws SQLException {
Connection connection = DriverManager.getConnection(jdbcUrl);
try {
applyDefaultPragmas(connection);
} catch (SQLException ex) {
try {
connection.close();
} catch (SQLException ignored) {
// Schliessfehler maskieren den eigentlichen Setup-Fehler nicht
}
throw ex;
}
return connection;
}
/**
* Setzt die Standard-PRAGMAs auf einer bereits geoeffneten Connection.
*
* <p>Die Foreign-Key-Pruefung wird hier <strong>nicht</strong> aktiviert,
* um das bisher faktisch praktizierte Verhalten von Repository-Connections
* zu erhalten. Die Foreign-Key-Pruefung wird durch
* {@code SqliteSchemaInitializationAdapter} auf der zentralen DataSource
* fuer Schema-Operationen explizit aktiviert; einzelne Repository-
* Connections, die ueber diese Factory geoeffnet werden, behalten das
* bisherige Verhalten der direkten {@code DriverManager.getConnection}-
* Aufrufe und setzen Foreign-Keys nicht implizit.
*
* @param connection bestehende Connection; nicht {@code null}
* @throws SQLException wenn ein PRAGMA-Statement fehlschlaegt
*/
private static void applyDefaultPragmas(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
// WAL: Reader werden nicht durch Writer blockiert.
statement.execute("PRAGMA journal_mode=WAL");
// 5 Sekunden Wartezeit pro Connection bei SQLITE_BUSY.
statement.execute("PRAGMA busy_timeout=5000");
}
}
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -373,6 +372,6 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
* @throws SQLException if the connection cannot be established
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
return SqliteConnectionFactory.open(jdbcUrl);
}
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -340,7 +339,26 @@ public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
resolvedDate,
dateSource,
rs.getString("validated_title"),
rs.getString("final_target_file_name"));
rs.getString("final_target_file_name"),
readNullableLong(rs, "input_tokens"),
readNullableLong(rs, "output_tokens"),
readNullableLong(rs, "cache_creation_input_tokens"),
readNullableLong(rs, "cache_read_input_tokens"),
readNullableLong(rs, "price_input_per_token_nano_usd"),
readNullableLong(rs, "price_output_per_token_nano_usd"));
}
/**
* Liest einen nullable {@link Long}-Wert aus einer Spalte.
*
* @param rs das ResultSet
* @param columnName Spaltenname
* @return Wert oder {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private static Long readNullableLong(ResultSet rs, String columnName) throws SQLException {
long value = rs.getLong(columnName);
return rs.wasNull() ? null : value;
}
// -------------------------------------------------------------------------
@@ -377,7 +395,7 @@ public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
return SqliteConnectionFactory.open(jdbcUrl);
}
/**
@@ -0,0 +1,259 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;
/**
* SQLite-Implementierung des {@link ModelPriceRepository}.
*
* <p>Persistiert Modell-Preise in der Tabelle {@code model_price}. Inserts und
* Updates erfolgen via {@code INSERT ... ON CONFLICT(provider, model_name)
* DO UPDATE SET ...}; Loeschungen via direktes {@code DELETE}. Der
* {@link #saveAllChanges(ModelPriceChangeSet) Batch-Pfad} faehrt eine
* JDBC-Transaktion mit {@code autoCommit=false}, ROLLBACKt bei Fehlern und
* COMMITet bei Erfolg.
*
* <p>Beim Lesen wird der DB-String {@code updated_at} als {@link Instant}
* geparst. Schlaegt das Parsing fehl, liefert die Adapter-Methode einen
* {@link ModelPriceView} mit {@code updatedAt=null} und
* {@code invalidUpdatedAt=true}; der Originalstring landet in
* {@code invalidUpdatedAtRaw}, damit GUI/CLI &quot;ung&uuml;ltig&quot; anzeigen
* koennen.
*/
public class SqliteModelPriceRepositoryAdapter implements ModelPriceRepository {
private static final Logger LOG = LogManager.getLogger(SqliteModelPriceRepositoryAdapter.class);
private static final String SQL_FIND_ALL = """
SELECT provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at
FROM model_price
ORDER BY provider, model_name
""";
private static final String SQL_FIND_BY_KEY = """
SELECT provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at
FROM model_price
WHERE provider = ? AND model_name = ?
""";
private static final String SQL_UPSERT = """
INSERT INTO model_price
(provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(provider, model_name) DO UPDATE SET
price_input_per_token_nano_usd = excluded.price_input_per_token_nano_usd,
price_output_per_token_nano_usd = excluded.price_output_per_token_nano_usd,
currency = excluded.currency,
updated_at = excluded.updated_at
""";
private static final String SQL_DELETE = """
DELETE FROM model_price WHERE provider = ? AND model_name = ?
""";
private final String jdbcUrl;
/**
* Erzeugt den Adapter mit der JDBC-URL der Ziel-Datenbank.
*
* @param jdbcUrl JDBC-URL der SQLite-Datenbank; weder {@code null} noch leer
*/
public SqliteModelPriceRepositoryAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
}
this.jdbcUrl = jdbcUrl;
}
/**
* Oeffnet eine neue Connection ueber die zentrale Connection-Factory.
*
* @return eingerichtete Connection
* @throws SQLException wenn der Verbindungsaufbau fehlschlaegt
*/
protected Connection getConnection() throws SQLException {
return SqliteConnectionFactory.open(jdbcUrl);
}
@Override
public List<ModelPriceView> findAll() {
List<ModelPriceView> result = new ArrayList<>();
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_FIND_ALL);
ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preise konnten nicht gelesen werden: " + e.getMessage(), e);
}
return List.copyOf(result);
}
@Override
public Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(modelName, "modelName");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_FIND_BY_KEY)) {
statement.setString(1, provider);
statement.setString(2, modelName);
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Lookup fehlgeschlagen fuer (" + provider + ", " + modelName
+ "): " + e.getMessage(), e);
}
}
@Override
public void upsert(ModelPriceEntry entry) {
Objects.requireNonNull(entry, "entry");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_UPSERT)) {
bindUpsert(statement, entry);
statement.executeUpdate();
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Upsert fehlgeschlagen: " + e.getMessage(), e);
}
}
@Override
public void delete(String provider, String modelName) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(modelName, "modelName");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_DELETE)) {
statement.setString(1, provider);
statement.setString(2, modelName);
statement.executeUpdate();
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Delete fehlgeschlagen: " + e.getMessage(), e);
}
}
@Override
public void saveAllChanges(ModelPriceChangeSet changeSet) {
Objects.requireNonNull(changeSet, "changeSet");
if (changeSet.isEmpty()) {
return;
}
try (Connection connection = getConnection()) {
connection.setAutoCommit(false);
try {
if (!changeSet.upserts().isEmpty()) {
try (PreparedStatement upsertStmt = connection.prepareStatement(SQL_UPSERT)) {
for (ModelPriceEntry entry : changeSet.upserts()) {
bindUpsert(upsertStmt, entry);
upsertStmt.executeUpdate();
}
}
}
if (!changeSet.deletions().isEmpty()) {
try (PreparedStatement deleteStmt = connection.prepareStatement(SQL_DELETE)) {
for (ModelPriceKey key : changeSet.deletions()) {
deleteStmt.setString(1, key.provider());
deleteStmt.setString(2, key.modelName());
deleteStmt.executeUpdate();
}
}
}
connection.commit();
} catch (SQLException txError) {
try {
connection.rollback();
} catch (SQLException rollbackError) {
LOG.error("Rollback nach Modell-Preis-Batch-Fehler ebenfalls fehlgeschlagen: {}",
rollbackError.getMessage(), rollbackError);
}
throw new ModelPriceRepositoryException(
"Modell-Preis-Batch konnte nicht persistiert werden: " + txError.getMessage(),
txError);
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Datenbankverbindung fuer Modell-Preis-Batch fehlgeschlagen: " + e.getMessage(), e);
}
}
private static void bindUpsert(PreparedStatement statement, ModelPriceEntry entry) throws SQLException {
statement.setString(1, entry.provider());
statement.setString(2, entry.modelName());
statement.setLong(3, entry.priceInputPerTokenNanoUsd());
statement.setLong(4, entry.priceOutputPerTokenNanoUsd());
statement.setString(5, entry.currency());
statement.setString(6, entry.updatedAt().toString());
}
/**
* Liest eine Zeile in einen {@link ModelPriceView}.
*
* <p>Bei nicht parsebarem {@code updated_at} wird ein WARN-Log erzeugt
* und der View mit {@code updatedAt=null}, {@code invalidUpdatedAt=true}
* sowie dem Originalstring zurueckgegeben.
*
* @param rs aktueller ResultSet, dessen Cursor auf der Zielzeile steht
* @return Lesen-DTO
* @throws SQLException bei JDBC-Fehlern
*/
private static ModelPriceView mapRow(ResultSet rs) throws SQLException {
String provider = rs.getString("provider");
String modelName = rs.getString("model_name");
long priceIn = rs.getLong("price_input_per_token_nano_usd");
long priceOut = rs.getLong("price_output_per_token_nano_usd");
String currency = rs.getString("currency");
String updatedAtRaw = rs.getString("updated_at");
Instant updatedAt = null;
boolean invalid = false;
String invalidRaw = null;
try {
updatedAt = Instant.parse(updatedAtRaw);
} catch (DateTimeParseException ex) {
LOG.warn("updated_at konnte fuer (Provider={}, Modell={}) nicht geparst werden: \"{}\"",
provider, modelName, updatedAtRaw);
invalid = true;
invalidRaw = updatedAtRaw;
} catch (NullPointerException ex) {
LOG.warn("updated_at war fuer (Provider={}, Modell={}) NULL als ungueltig markiert",
provider, modelName);
invalid = true;
invalidRaw = null;
}
return new ModelPriceView(provider, modelName, priceIn, priceOut, currency, updatedAt,
invalidRaw, invalid);
}
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -144,8 +143,14 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
resolved_date,
date_source,
validated_title,
final_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
final_target_file_name,
input_tokens,
output_tokens,
cache_creation_input_tokens,
cache_read_input_tokens,
price_input_per_token_nano_usd,
price_output_per_token_nano_usd
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = getConnection();
@@ -178,6 +183,13 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
attempt.dateSource() != null ? attempt.dateSource().name() : null);
setNullableString(statement, 19, attempt.validatedTitle());
setNullableString(statement, 20, attempt.finalTargetFileName());
// Token- und Preis-Snapshot-Felder; alle nullable
setNullableLong(statement, 21, attempt.inputTokens());
setNullableLong(statement, 22, attempt.outputTokens());
setNullableLong(statement, 23, attempt.cacheCreationInputTokens());
setNullableLong(statement, 24, attempt.cacheReadInputTokens());
setNullableLong(statement, 25, attempt.priceInputPerTokenNanoUsd());
setNullableLong(statement, 26, attempt.priceOutputPerTokenNanoUsd());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
@@ -406,6 +418,23 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
}
}
/**
* Setzt einen nullable {@link Long}-Wert auf einem PreparedStatement.
*
* @param stmt das Statement
* @param index 1-basierter Parameter-Index
* @param value Wert oder {@code null}
* @throws SQLException bei JDBC-Fehlern
*/
private static void setNullableLong(PreparedStatement stmt, int index, Long value)
throws SQLException {
if (value == null) {
stmt.setNull(index, Types.BIGINT);
} else {
stmt.setLong(index, value);
}
}
private static Object getNullableInt(ResultSet rs, String column) throws SQLException {
int value = rs.getInt(column);
return rs.wasNull() ? null : value;
@@ -457,6 +486,6 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
* Returns a JDBC connection. May be overridden in tests to provide shared connections.
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
return SqliteConnectionFactory.open(jdbcUrl);
}
}
@@ -82,7 +82,18 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
"last_target_path", "last_target_file_name"
);
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
/**
* Alle erwarteten Spalten der Tabelle {@code processing_attempt} im
* V1-Zielschema.
*
* <p>Dies ist der minimale Zielzustand nach {@code V1__initial_schema}.
* Spaetere Migrationen (z.B. {@code V2__token_tracking}) ergaenzen
* additiv weitere Spalten; das Vorhandensein dieser zusaetzlichen Spalten
* vor dem Baseline-Eintrag ist <strong>kein</strong> Konformitaetskriterium,
* weil die Schema-Pruefung in Fall 2 ausschlie&szlig;lich gegen das
* V1-Schema arbeitet. Die V2-Spalten werden nach der Baseline-Eintragung
* durch Flyway ergaenzt.
*/
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
"id", COL_FINGERPRINT, "run_id", "attempt_number", "started_at", "ended_at",
"status", "failure_class", "failure_message", "retryable",
@@ -91,7 +102,12 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
"validated_title", "final_target_file_name", "ai_provider"
);
/** Erwartete Indizes. */
/**
* Erwartete Indizes nach {@code V1__initial_schema}.
*
* <p>Spaetere Migrationen koennen additiv weitere Indizes anlegen; sie
* sind kein Konformitaetskriterium fuer Fall 2.
*/
private static final Set<String> EXPECTED_INDEXES = Set.of(
"idx_processing_attempt_fingerprint",
"idx_processing_attempt_run_id",
@@ -3,7 +3,6 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Objects;
import java.util.function.Consumer;
@@ -43,7 +42,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
public void executeInTransaction(Consumer<TransactionOperations> operations) {
Objects.requireNonNull(operations, "operations must not be null");
try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
try (Connection connection = SqliteConnectionFactory.open(jdbcUrl)) {
connection.setAutoCommit(false);
try {
@@ -0,0 +1,64 @@
-- 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: schuetzt 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'),
('claude', 'claude-haiku-4-5-20251001', 1000, 5000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-sonnet-4-6', 3000, 15000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-opus-4-7', 5000, 25000, 'USD', '2026-05-08T00:00:00Z')
ON CONFLICT (provider, model_name) DO NOTHING;
@@ -88,7 +88,11 @@ class SqliteSchemaInitializationAdapterTest {
"status", "failure_class", "failure_message", "retryable",
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
"validated_title", "final_target_file_name", "ai_provider"
"validated_title", "final_target_file_name", "ai_provider",
// Token- und Preis-Spalten ergaenzt durch V2__token_tracking
"input_tokens", "output_tokens",
"cache_creation_input_tokens", "cache_read_input_tokens",
"price_input_per_token_nano_usd", "price_output_per_token_nano_usd"
);
}
@@ -0,0 +1,183 @@
package de.gecheckt.pdf.umbenenner.application.cost;
import java.math.BigDecimal;
import java.math.BigInteger;
/**
* Interpretiert aggregierte Token-Rohkosten in {@link BigDecimal}-USD und
* bestimmt die Status-Flags des {@link CostResult}.
*
* <p>Der CostCalculator fuehrt selbst <strong>keine</strong> Multiplikation
* Tokens&times;Preis durch. Multiplikation findet im SQL-Adapter statt
* (Pro-Attempt-Snapshot); der CostCalculator interpretiert nur die bereits
* aufsummierten Roh-Werte und uebersetzt sie in einen anzeigetauglichen
* Betrag.
*
* <p>Es findet <strong>keine</strong> interne Rundung statt; Rundung
* auf vier Nachkommastellen erfolgt ausschlie&szlig;lich im GUI-Layer
* (CostFormatter).
*/
public final class CostCalculator {
/** Divisor fuer die Umrechnung Nano-USD &rarr; USD. */
private static final BigDecimal NANO_USD_PER_USD = new BigDecimal("1000000000");
/**
* Erzeugt eine Instanz. Stateless; Wiederverwendung als Singleton ist erlaubt.
*/
public CostCalculator() {
}
/**
* Interpretation aggregierter Long-Werte einer Tabellenzeile.
*
* <p>Verwendet {@code long}-Pro-Group-Aggregation aus SQL.
*
* @param sumInputCostNanoUsd Summe der Input-Kosten in Nano-USD; {@code null} wenn keiner
* @param sumOutputCostNanoUsd Summe der Output-Kosten in Nano-USD; {@code null} wenn keiner
* @param hasPartialTokenData mindestens ein Token-Feld fehlte
* @param hasMissingPriceSnapshot mindestens ein Preis-Snapshot fehlte
* @param hasCacheTokensIgnored Cache-Tokens kamen vor
* @param hasAnyTokenData es lagen ueberhaupt Token-Daten vor
* @return interpretiertes Kosten-Ergebnis
*/
public CostResult formatRow(
Long sumInputCostNanoUsd,
Long sumOutputCostNanoUsd,
boolean hasPartialTokenData,
boolean hasMissingPriceSnapshot,
boolean hasCacheTokensIgnored,
boolean hasAnyTokenData) {
BigInteger sumInput = sumInputCostNanoUsd == null ? null : BigInteger.valueOf(sumInputCostNanoUsd);
BigInteger sumOutput = sumOutputCostNanoUsd == null ? null : BigInteger.valueOf(sumOutputCostNanoUsd);
boolean hasAnyInputCost = sumInput != null;
boolean hasAnyOutputCost = sumOutput != null;
return interpret(sumInput, sumOutput, hasAnyInputCost, hasAnyOutputCost,
hasAnyTokenData, hasPartialTokenData, hasMissingPriceSnapshot, hasCacheTokensIgnored);
}
/**
* Interpretation aufsummierter BigInteger-Aggregate fuer Kopfzeilen und
* Run-Summary-Banner.
*
* <p>Wird in V3.3 in einem spaeteren Arbeitspaket vollstaendig implementiert
* (AP-B). Fuer AP-A genuegt diese Stub-Methode, die mit
* {@link UnsupportedOperationException} fehlt, sobald sie aufgerufen wird.
*
* @param totalInputCostNanoUsd aufaddierte Input-Kosten in Nano-USD oder {@code null}
* @param totalOutputCostNanoUsd aufaddierte Output-Kosten in Nano-USD oder {@code null}
* @param hasAnyInputCost Flag, ob ueberhaupt ein berechenbarer Input-Kostenanteil vorlag
* @param hasAnyOutputCost Flag, ob ueberhaupt ein berechenbarer Output-Kostenanteil vorlag
* @param hasAnyTokenData Flag, ob ueberhaupt Token-Daten vorlagen
* @param hasPartialTokenData Flag, ob mindestens ein Token-Feld fehlte
* @param hasMissingPriceSnapshot Flag, ob mindestens ein Preis-Snapshot fehlte
* @param hasCacheTokensIgnored Flag, ob Cache-Tokens vorkamen
* @return interpretiertes Kosten-Ergebnis
*/
public CostResult formatTotal(
BigInteger totalInputCostNanoUsd,
BigInteger totalOutputCostNanoUsd,
boolean hasAnyInputCost,
boolean hasAnyOutputCost,
boolean hasAnyTokenData,
boolean hasPartialTokenData,
boolean hasMissingPriceSnapshot,
boolean hasCacheTokensIgnored) {
throw new UnsupportedOperationException("formatTotal wird in einem spaeteren Arbeitspaket implementiert");
}
/**
* Interpretation eines einzelnen Versuchs (z.B. fuer den History-Tab).
*
* <p>Multipliziert Tokens&times;Preise lokal, da hier keine Aggregation
* vorliegt. Cache-Tokens werden nur ueber das {@code hasCacheTokens}-Flag
* markiert; in der Berechnung selbst werden sie nicht beruecksichtigt.
*
* @param inputTokens Anzahl Input-Tokens; {@code null} moeglich
* @param outputTokens Anzahl Output-Tokens; {@code null} moeglich
* @param priceInputPerTokenNanoUsd Snapshot-Preis Input; {@code null} moeglich
* @param priceOutputPerTokenNanoUsd Snapshot-Preis Output; {@code null} moeglich
* @param hasCacheTokens Cache-Tokens lagen im Versuch vor
* @return interpretiertes Kosten-Ergebnis
*/
public CostResult calculateAttempt(
Long inputTokens,
Long outputTokens,
Long priceInputPerTokenNanoUsd,
Long priceOutputPerTokenNanoUsd,
boolean hasCacheTokens) {
boolean hasAnyTokenData = inputTokens != null || outputTokens != null;
BigInteger inputCost = null;
BigInteger outputCost = null;
boolean partialTokens = false;
boolean missingPriceSnapshot = false;
if (inputTokens != null) {
if (priceInputPerTokenNanoUsd != null) {
inputCost = BigInteger.valueOf(inputTokens)
.multiply(BigInteger.valueOf(priceInputPerTokenNanoUsd));
} else {
missingPriceSnapshot = true;
}
} else if (outputTokens != null) {
partialTokens = true;
}
if (outputTokens != null) {
if (priceOutputPerTokenNanoUsd != null) {
outputCost = BigInteger.valueOf(outputTokens)
.multiply(BigInteger.valueOf(priceOutputPerTokenNanoUsd));
} else {
missingPriceSnapshot = true;
}
} else if (inputTokens != null) {
partialTokens = true;
}
boolean hasAnyInputCost = inputCost != null;
boolean hasAnyOutputCost = outputCost != null;
return interpret(inputCost, outputCost, hasAnyInputCost, hasAnyOutputCost,
hasAnyTokenData, partialTokens, missingPriceSnapshot, hasCacheTokens);
}
/**
* Gemeinsame Interpretationslogik fuer Row- und Attempt-Pfad.
*
* @return berechnetes {@link CostResult}
*/
private CostResult interpret(
BigInteger inputCostNano,
BigInteger outputCostNano,
boolean hasAnyInputCost,
boolean hasAnyOutputCost,
boolean hasAnyTokenData,
boolean partialTokens,
boolean missingPriceSnapshot,
boolean cacheTokensIgnored) {
boolean noTokens = !hasAnyTokenData;
boolean hasAnyCalculatedCost = hasAnyInputCost || hasAnyOutputCost;
BigDecimal amountUsd = null;
if (hasAnyCalculatedCost) {
BigInteger sum = (inputCostNano != null ? inputCostNano : BigInteger.ZERO)
.add(outputCostNano != null ? outputCostNano : BigInteger.ZERO);
amountUsd = new BigDecimal(sum).divide(NANO_USD_PER_USD);
}
boolean exact = !noTokens
&& !partialTokens
&& !missingPriceSnapshot
&& !cacheTokensIgnored
&& hasAnyCalculatedCost;
return new CostResult(
amountUsd,
exact,
partialTokens,
missingPriceSnapshot,
noTokens,
cacheTokensIgnored,
hasAnyCalculatedCost);
}
}
@@ -0,0 +1,47 @@
package de.gecheckt.pdf.umbenenner.application.cost;
import java.math.BigDecimal;
/**
* Interpretiertes Kosten-Ergebnis zu einer Tabellenzeile, einer Kopfzeile oder
* einem einzelnen Versuch.
*
* <p>Der {@code CostCalculator} fuellt diese Struktur. Die GUI nutzt die
* Boolean-Flags fuer das Anzeige-Mapping (Tabellenwert, Tooltip,
* Banner-Beitrag) und formatiert {@link #amountUsd()} schließlich auf vier
* Nachkommastellen.
*
* <p>Flag-Semantik:
* <ul>
* <li>{@link #exact()} alle Token-Werte und alle Preise vorhanden,
* Cache-Tokens spielten keine Rolle.</li>
* <li>{@link #partialTokens()} mindestens ein Token-Wert fehlte
* (z.B. Input-Tokens null, Output-Tokens vorhanden); berechneter
* Wert ist eine Untergrenze.</li>
* <li>{@link #missingPriceSnapshot()} mindestens ein Versuch ohne
* Preis-Snapshot floss ein.</li>
* <li>{@link #noTokens()} keinerlei Token-Daten erfasst.</li>
* <li>{@link #cacheTokensIgnored()} Cache-Tokens lagen vor, sind in
* V3.3 jedoch nicht in {@link #amountUsd()} enthalten.</li>
* <li>{@link #hasAnyCalculatedCost()} mindestens ein berechneter
* Anteil floss in {@link #amountUsd()} ein. Wichtig zur Unter&shy;
* scheidung von &quot;keine Werte&quot; und &quot;Mikrobetrag&quot;.</li>
* </ul>
*
* @param amountUsd Gesamtbetrag in USD; {@code null} wenn nichts berechenbar war
* @param exact {@code true}, wenn alle Daten vollstaendig waren
* @param partialTokens {@code true}, wenn mindestens ein Token-Wert fehlte
* @param missingPriceSnapshot {@code true}, wenn mindestens ein Preis-Snapshot fehlte
* @param noTokens {@code true}, wenn keinerlei Token-Daten vorlagen
* @param cacheTokensIgnored {@code true}, wenn Cache-Tokens vorkamen (in V3.3 nicht eingerechnet)
* @param hasAnyCalculatedCost {@code true}, wenn ein berechneter Anteil enthalten ist
*/
public record CostResult(
BigDecimal amountUsd,
boolean exact,
boolean partialTokens,
boolean missingPriceSnapshot,
boolean noTokens,
boolean cacheTokensIgnored,
boolean hasAnyCalculatedCost) {
}
@@ -0,0 +1,9 @@
/**
* Application-Komponenten fuer die Interpretation aggregierter Token-Kosten.
*
* <p>Enthaelt den {@code CostCalculator}, der Long-/BigInteger-Aggregate aus
* der Persistenzschicht in {@link java.math.BigDecimal}-USD ueberfuehrt und die
* Statusflags des {@code CostResult} bestimmt. Die GUI ist allein fuer die
* Endformatierung (Locale, Tilde, &quot;&lt;&nbsp;$0.0001&quot;) zustaendig.
*/
package de.gecheckt.pdf.umbenenner.application.cost;
@@ -0,0 +1,61 @@
package de.gecheckt.pdf.umbenenner.application.dto;
/**
* Token-Verbrauchsmetadaten eines erfolgreichen KI-Aufrufs.
*
* <p>Ein KI-Adapter befuellt dieses DTO mit den vom Provider zurueckgelieferten
* Token-Zaehlungen. Alle Felder sind nullable: nicht alle Provider liefern
* Cache-Tokens, und einzelne Felder koennen vom Adapter wegen ungueltiger
* Werte (negativ, > 10 Mio., nicht-numerisch) auf {@code null} gesetzt werden.
*
* <p>Die Application-Schicht verwendet dieses DTO ohne JDBC- oder Domain-Bezug;
* fuer die Persistenz werden die Werte vom {@code BatchRunProcessingUseCase}
* direkt an den {@code ProcessingAttempt}-Schreibpfad weitergereicht.
*
* @param inputTokens Anzahl Standard-Input-Tokens; {@code null} wenn nicht ermittelbar
* @param outputTokens Anzahl Standard-Output-Tokens; {@code null} wenn nicht ermittelbar
* @param cacheCreationInputTokens Anzahl Cache-Schreib-Tokens (nur Anthropic); {@code null} bei OpenAI-Adapter oder wenn nicht ermittelbar
* @param cacheReadInputTokens Anzahl Cache-Lese-Tokens (nur Anthropic); {@code null} bei OpenAI-Adapter oder wenn nicht ermittelbar
*/
public record AiUsageMetadata(
Long inputTokens,
Long outputTokens,
Long cacheCreationInputTokens,
Long cacheReadInputTokens) {
/**
* Liefert eine leere Instanz ohne jegliche Token-Daten.
*
* @return AiUsageMetadata mit allen Feldern auf {@code null}
*/
public static AiUsageMetadata empty() {
return new AiUsageMetadata(null, null, null, null);
}
/**
* Pruefung, ob ueberhaupt Standard-Token-Daten vorliegen.
*
* <p>Maßgeblich fuer das Einschlusskriterium der Aggregations-Tabellen:
* Eine Zeile fließt nur dann in die Kostenanalyse ein, wenn mindestens
* eines der Standard-Tokenfelder gesetzt ist.
*
* @return {@code true} wenn {@link #inputTokens} oder {@link #outputTokens} gesetzt sind
*/
public boolean hasAnyTokenData() {
return inputTokens != null || outputTokens != null;
}
/**
* Pruefung, ob Cache-Tokens (lesend oder schreibend) vorliegen.
*
* <p>Wird vom CostCalculator zur Setzung des {@code cacheTokensIgnored}-Flags
* herangezogen, da V3.3 Cache-Tokens persistiert, aber nicht in die
* Kostenberechnung einbezieht.
*
* @return {@code true} wenn ein Cache-Tokenfeld gesetzt und groeßer 0 ist
*/
public boolean hasCacheTokens() {
return (cacheCreationInputTokens != null && cacheCreationInputTokens > 0)
|| (cacheReadInputTokens != null && cacheReadInputTokens > 0);
}
}
@@ -0,0 +1,44 @@
package de.gecheckt.pdf.umbenenner.application.dto;
import java.util.List;
import java.util.Objects;
/**
* Atomar zu speichernder Block aus Upserts und Loeschungen.
*
* <p>Wird vom {@code ManageModelPricesUseCase} an das
* {@code ModelPriceRepository.saveAllChanges(...)} weitergereicht. Die
* Konfliktvalidierung (z.B. ein Schluessel sowohl in {@link #upserts()}
* als auch {@link #deletions()}) erfolgt im Use Case <em>vor</em> dem
* Aufruf der Repository-Methode. Die Repository-Implementierung darf das
* Set damit als bereits konsistent voraussetzen und beschr&auml;nkt sich
* auf die transaktionale Persistenz.
*
* @param upserts Liste von Eintraegen, die eingefuegt oder aktualisiert werden sollen; nicht {@code null}
* @param deletions Liste von Composite-Keys, die geloescht werden sollen; nicht {@code null}
*/
public record ModelPriceChangeSet(
List<ModelPriceEntry> upserts,
List<ModelPriceKey> deletions) {
/**
* Kompakter Konstruktor: kopiert die Listen defensiv und macht sie unveraenderlich.
*
* @throws NullPointerException wenn eine der Listen {@code null} ist
*/
public ModelPriceChangeSet {
Objects.requireNonNull(upserts, "upserts");
Objects.requireNonNull(deletions, "deletions");
upserts = List.copyOf(upserts);
deletions = List.copyOf(deletions);
}
/**
* Pruefung, ob das ChangeSet leer ist und somit keine Transaktion benoetigt.
*
* @return {@code true}, wenn weder Upserts noch Deletions enthalten sind
*/
public boolean isEmpty() {
return upserts.isEmpty() && deletions.isEmpty();
}
}
@@ -0,0 +1,71 @@
package de.gecheckt.pdf.umbenenner.application.dto;
import java.time.Instant;
import java.util.Objects;
/**
* Schreib- und Validierungs-DTO fuer Modell-Preise.
*
* <p>Wird im {@code ManageModelPricesUseCase} fuer Inserts und Updates
* verwendet. Im Gegensatz zum {@link ModelPriceView} sind alle Felder
* inklusive {@link #updatedAt()} non-null. Validierung erfolgt im
* Konstruktor; ein konstruiertes Objekt ist ein gueltiger Schreibwert.
*
* <p>Wertebereiche:
* <ul>
* <li>{@code priceInputPerTokenNanoUsd} und {@code priceOutputPerTokenNanoUsd}:
* 0 bis 100&nbsp;000&nbsp;000 (entspricht $0,10/Token bzw. $100&nbsp;000/1M Tokens)</li>
* <li>{@code currency}: ausschlie&szlig;lich {@code "USD"}</li>
* </ul>
*
* @param provider Provider-Identifikator (z.B. {@code "openai-compatible"} oder {@code "claude"}); nicht leer
* @param modelName Modellname; nicht leer
* @param priceInputPerTokenNanoUsd Input-Preis in Nano-USD pro Token; 0..100_000_000
* @param priceOutputPerTokenNanoUsd Output-Preis in Nano-USD pro Token; 0..100_000_000
* @param currency Waehrung; nur {@code "USD"} zulaessig
* @param updatedAt Zeitpunkt der letzten Aktualisierung; non-null
*/
public record ModelPriceEntry(
String provider,
String modelName,
long priceInputPerTokenNanoUsd,
long priceOutputPerTokenNanoUsd,
String currency,
Instant updatedAt) {
/** Maximaler erlaubter Preis pro Token in Nano-USD ($0,10 pro Token). */
public static final long MAX_PRICE_PER_TOKEN_NANO_USD = 100_000_000L;
/**
* Kompakter Konstruktor mit umfassender Validierung.
*
* @throws NullPointerException wenn {@code provider}, {@code modelName} oder {@code updatedAt} {@code null} sind
* @throws IllegalArgumentException bei leerem Provider/Modellname, negativen Preisen, zu hohen Preisen oder anderer Waehrung als USD
*/
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 < 0L) {
throw new IllegalArgumentException("Input-Preis darf nicht negativ sein");
}
if (priceOutputPerTokenNanoUsd < 0L) {
throw new IllegalArgumentException("Output-Preis darf nicht negativ sein");
}
if (priceInputPerTokenNanoUsd > MAX_PRICE_PER_TOKEN_NANO_USD) {
throw new IllegalArgumentException("Input-Preis ueberschreitet Maximum");
}
if (priceOutputPerTokenNanoUsd > MAX_PRICE_PER_TOKEN_NANO_USD) {
throw new IllegalArgumentException("Output-Preis ueberschreitet Maximum");
}
if (!"USD".equals(currency)) {
throw new IllegalArgumentException("Nur Waehrung USD unterstuetzt");
}
}
}
@@ -0,0 +1,34 @@
package de.gecheckt.pdf.umbenenner.application.dto;
import java.util.Objects;
/**
* Composite-Key fuer einen Modell-Preis-Eintrag.
*
* <p>Identifiziert einen Eintrag in {@code model_price} ueber den
* Composite Primary Key {@code (provider, model_name)}. Wird in
* {@link ModelPriceChangeSet#deletions()} verwendet, um Loeschungen
* unabhaengig von Wertdaten auszudruecken.
*
* @param provider Provider-Identifikator; nicht leer
* @param modelName Modellname; nicht leer
*/
public record ModelPriceKey(String provider, String modelName) {
/**
* Kompakter Konstruktor mit Nicht-Leer-Pruefung.
*
* @throws NullPointerException wenn ein Feld {@code null} ist
* @throws IllegalArgumentException wenn Provider oder Modellname leer ist
*/
public ModelPriceKey {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(modelName, "modelName");
if (provider.isBlank()) {
throw new IllegalArgumentException("provider darf nicht leer sein");
}
if (modelName.isBlank()) {
throw new IllegalArgumentException("modelName darf nicht leer sein");
}
}
}
@@ -0,0 +1,37 @@
package de.gecheckt.pdf.umbenenner.application.dto;
import java.time.Instant;
/**
* Lese- und Anzeige-DTO fuer Modell-Preise.
*
* <p>Im Gegensatz zum {@link ModelPriceEntry} darf {@link #updatedAt()}
* {@code null} sein, falls der in der Datenbank gespeicherte Wert nicht als
* {@link Instant} parsebar ist. In diesem Fall wird {@link #invalidUpdatedAt()}
* auf {@code true} gesetzt und der ursprueng&shy;liche String in
* {@link #invalidUpdatedAtRaw()} gehalten, damit die GUI &quot;ung&uuml;ltig&quot;
* anzeigen kann.
*
* <p>Dieses DTO darf <strong>nicht</strong> direkt im Schreibpfad verwendet
* werden. Schreiboperationen erfordern den vollvalidierten
* {@link ModelPriceEntry}.
*
* @param provider Provider-Identifikator
* @param modelName Modellname
* @param priceInputPerTokenNanoUsd Input-Preis in Nano-USD pro Token
* @param priceOutputPerTokenNanoUsd Output-Preis in Nano-USD pro Token
* @param currency Waehrung; in V3.3 stets {@code "USD"}
* @param updatedAt Letztes Update als {@link Instant}; {@code null} bei beschaedigtem DB-Wert
* @param invalidUpdatedAtRaw Originalstring aus DB, falls Parsing fehlgeschlagen ist; sonst {@code null}
* @param invalidUpdatedAt Flag: {@code true} wenn DB-Wert nicht parsebar war
*/
public record ModelPriceView(
String provider,
String modelName,
long priceInputPerTokenNanoUsd,
long priceOutputPerTokenNanoUsd,
String currency,
Instant updatedAt,
String invalidUpdatedAtRaw,
boolean invalidUpdatedAt) {
}
@@ -0,0 +1,8 @@
/**
* Application-Schicht-DTOs fuer den Token- und Kosten-Tracking-Pfad.
*
* <p>Dieses Paket beherbergt schmale, technologieunabhaengige Datentraeger,
* die zwischen Adaptern, Use Cases und Repositories ausgetauscht werden,
* ohne JavaFX-, JDBC- oder Domain-spezifische Typen einzuschleppen.
*/
package de.gecheckt.pdf.umbenenner.application.dto;
@@ -2,6 +2,7 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.Objects;
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
@@ -18,6 +19,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
* including prompt, document text, and character counts</li>
* <li>{@link #rawResponse()} the uninterpreted response body returned by the AI,
* which may be valid JSON, malformed, empty, or otherwise problematic</li>
* <li>{@link #usageMetadata()} Token-Verbrauchsmetadaten des Aufrufs;
* nie {@code null}, einzelne Felder koennen aber {@code null} sein,
* wenn der Provider keine Werte oder ungueltige Werte liefert</li>
* </ul>
* <p>
* The Application layer is responsible for:
@@ -29,22 +33,27 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
* </ul>
* <p>
* <strong>Persistence:</strong> Both request and response are stored in the
* processing attempt history for debugging and audit.
* processing attempt history for debugging and audit. Token-Daten aus
* {@link #usageMetadata()} werden zusammen mit einem Preis-Snapshot in
* {@code processing_attempt} persistiert.
*
* @param request the AI request that was sent; never null
* @param rawResponse the uninterpreted response body; never null (but may be empty)
* @param usageMetadata Token-Verbrauchsmetadaten; never null (kann aber {@link AiUsageMetadata#empty()} sein)
*/
public record AiInvocationSuccess(
AiRequestRepresentation request,
AiRawResponse rawResponse) implements AiInvocationResult {
AiRawResponse rawResponse,
AiUsageMetadata usageMetadata) implements AiInvocationResult {
/**
* Compact constructor validating mandatory fields.
*
* @throws NullPointerException if either field is null
* @throws NullPointerException if any field is null
*/
public AiInvocationSuccess {
Objects.requireNonNull(request, "request must not be null");
Objects.requireNonNull(rawResponse, "rawResponse must not be null");
Objects.requireNonNull(usageMetadata, "usageMetadata must not be null");
}
}
@@ -0,0 +1,77 @@
package de.gecheckt.pdf.umbenenner.application.port.out;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
/**
* Outbound-Port fuer die Verwaltung persistierter Modell-Preise.
*
* <p><strong>Schreibpfad-Konvention:</strong> GUI und CLI nutzen ausschlie&szlig;lich
* {@link #saveAllChanges(ModelPriceChangeSet)} fuer transaktionale Batch-
* Speicherungen. Die Methoden {@link #upsert(ModelPriceEntry)} und
* {@link #delete(String, String)} sind ausschlie&szlig;lich fuer Tests und
* Werkzeuge gedacht; sie sind keine Bestandteile des regul&auml;ren
* Bedienpfads.
*
* <p>Lesemethoden liefern {@link ModelPriceView} (mit nullable
* {@link ModelPriceView#updatedAt()} bei besch&auml;digten DB-Werten).
* Schreibmethoden akzeptieren ausschlie&szlig;lich {@link ModelPriceEntry},
* dessen Konstruktor s&auml;mtliche Wertgrenzen pr&uuml;ft.
*/
public interface ModelPriceRepository {
/**
* Liefert alle persistierten Modell-Preise.
*
* @return unveraenderbare Liste aller Eintraege; nie {@code null}
*/
List<ModelPriceView> findAll();
/**
* Liefert den Preis-Eintrag zu einem Composite-Key.
*
* @param provider Provider-Identifikator
* @param modelName Modellname
* @return {@link Optional} mit Eintrag, leer wenn nicht vorhanden
*/
Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName);
/**
* Atomarer Insert oder Update.
*
* <p>@internal Nicht von GUI/CLI direkt verwenden nur fuer Tests/Werkzeuge.
* Der regulaere Bedienpfad verlaeuft ueber
* {@link #saveAllChanges(ModelPriceChangeSet)}.
*
* @param entry zu schreibender Eintrag; nicht {@code null}
*/
void upsert(ModelPriceEntry entry);
/**
* Loescht den Eintrag eines Composite-Keys.
*
* <p>@internal Nicht von GUI/CLI direkt verwenden nur fuer Tests/Werkzeuge.
* Der regulaere Bedienpfad verlaeuft ueber
* {@link #saveAllChanges(ModelPriceChangeSet)}.
*
* @param provider Provider-Identifikator
* @param modelName Modellname
*/
void delete(String provider, String modelName);
/**
* Persistiert eine Sammlung von Preisaenderungen atomar.
*
* <p>Die Konfliktvalidierung des ChangeSets erfolgt vor der Transaktion im
* Use Case. Die Implementierung fuehrt alle Operationen innerhalb einer
* JDBC-Transaktion mit {@code autoCommit=false} aus und rollt bei
* Auftreten eines Fehlers vollstaendig zurueck.
*
* @param changeSet Sammlung aus Upserts und Loeschungen; nicht {@code null}
*/
void saveAllChanges(ModelPriceChangeSet changeSet);
}
@@ -25,48 +25,11 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* ({@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED},
* {@link ProcessingStatus#SKIPPED_FINAL_FAILURE}).
* <p>
* <strong>Field semantics:</strong>
* <strong>Token- und Preis-Felder:</strong>
* <ul>
* <li>{@link #fingerprint()} foreign key to the document master record.</li>
* <li>{@link #runId()} identifies the batch run during which this attempt occurred.</li>
* <li>{@link #attemptNumber()} monotonically increasing per fingerprint; assigned
* before the attempt is recorded.</li>
* <li>{@link #startedAt()} wall-clock timestamp when processing of this candidate
* began in this run.</li>
* <li>{@link #endedAt()} wall-clock timestamp when processing completed (success,
* failure, or skip).</li>
* <li>{@link #status()} outcome status of this specific attempt.</li>
* <li>{@link #failureClass()} short classification of the failure (e.g. enum constant
* name or exception class name); {@code null} for successful or skip attempts.</li>
* <li>{@link #failureMessage()} human-readable failure description; {@code null} for
* successful or skip attempts.</li>
* <li>{@link #retryable()} {@code true} if the failure is considered retryable in a
* later run; {@code false} for final failures, successes, and skip attempts.</li>
* <li>{@link #aiProvider()} opaque identifier of the AI provider that was active
* during this attempt (e.g. {@code "openai-compatible"} or {@code "claude"});
* {@code null} for attempts that did not involve an AI call (skip, pre-check
* failure) or for historical attempts recorded before this field was introduced.</li>
* <li>{@link #modelName()} the AI model name used in this attempt; {@code null} if
* no AI call was made (e.g. pre-check failures or skip attempts).</li>
* <li>{@link #promptIdentifier()} stable identifier of the prompt template used;
* {@code null} if no AI call was made.</li>
* <li>{@link #processedPageCount()} number of PDF pages processed; {@code null} if
* pages were not extracted (e.g. pre-fingerprint or skip attempts).</li>
* <li>{@link #sentCharacterCount()} number of characters sent to the AI; {@code null}
* if no AI call was made.</li>
* <li>{@link #aiRawResponse()} the complete raw AI response body; {@code null} if no
* AI call was made. Stored in SQLite but not written to log files by default.</li>
* <li>{@link #aiReasoning()} the reasoning extracted from the AI response; {@code null}
* if no valid AI response was obtained.</li>
* <li>{@link #resolvedDate()} the date resolved for the naming proposal; {@code null}
* if no naming proposal was produced.</li>
* <li>{@link #dateSource()} the origin of the resolved date; {@code null} if no
* naming proposal was produced.</li>
* <li>{@link #validatedTitle()} the validated title from the naming proposal;
* {@code null} if no naming proposal was produced.</li>
* <li>{@link #finalTargetFileName()} the final filename written to the target folder
* (including any duplicate suffix); set only for
* {@link ProcessingStatus#SUCCESS} attempts, {@code null} otherwise.</li>
* <li>{@link #inputTokens()}, {@link #outputTokens()} Standard-Token-Counts; {@code null} bei Versuchen ohne KI-Aufruf oder ohne Token-Daten in der Provider-Antwort.</li>
* <li>{@link #cacheCreationInputTokens()}, {@link #cacheReadInputTokens()} Anthropic-Cache-Token-Counts; {@code null} bei OpenAI-Adapter oder fehlenden Werten.</li>
* <li>{@link #priceInputPerTokenNanoUsd()}, {@link #priceOutputPerTokenNanoUsd()} Preis-Snapshot zum Aufrufzeitpunkt in Nano-USD pro Token; {@code null}, wenn fuer das Modell kein Preis hinterlegt war oder der Lookup fehlschlug.</li>
* </ul>
*
* @param fingerprint content-based document identity; never null
@@ -75,12 +38,12 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* @param startedAt start of this processing attempt; never null
* @param endedAt end of this processing attempt; never null
* @param status outcome status of this attempt; never null
* @param failureClass failure classification, or {@code null} for non-failure statuses
* @param failureMessage failure description, or {@code null} for non-failure statuses
* @param retryable whether this failure should be retried in a later run
* @param aiProvider opaque AI provider identifier for this attempt, or {@code null}
* @param modelName AI model name, or {@code null} if no AI call was made
* @param promptIdentifier prompt identifier, or {@code null} if no AI call was made
* @param failureClass failure classification, or {@code null}
* @param failureMessage failure description, or {@code null}
* @param retryable whether this failure should be retried later
* @param aiProvider opaque AI provider identifier, or {@code null}
* @param modelName AI model name, or {@code null}
* @param promptIdentifier prompt identifier, or {@code null}
* @param processedPageCount number of PDF pages processed, or {@code null}
* @param sentCharacterCount number of characters sent to AI, or {@code null}
* @param aiRawResponse full raw AI response, or {@code null}
@@ -88,8 +51,13 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* @param resolvedDate resolved date for naming proposal, or {@code null}
* @param dateSource origin of resolved date, or {@code null}
* @param validatedTitle validated title, or {@code null}
* @param finalTargetFileName filename written to the target folder for SUCCESS attempts,
* or {@code null}
* @param finalTargetFileName filename written to the target folder for SUCCESS attempts, or {@code null}
* @param inputTokens Standard-Input-Tokens, oder {@code null}
* @param outputTokens Standard-Output-Tokens, oder {@code null}
* @param cacheCreationInputTokens Anthropic-Cache-Schreib-Tokens, oder {@code null}
* @param cacheReadInputTokens Anthropic-Cache-Lese-Tokens, oder {@code null}
* @param priceInputPerTokenNanoUsd Snapshot Input-Preis (Nano-USD/Token), oder {@code null}
* @param priceOutputPerTokenNanoUsd Snapshot Output-Preis (Nano-USD/Token), oder {@code null}
*/
public record ProcessingAttempt(
DocumentFingerprint fingerprint,
@@ -113,7 +81,15 @@ public record ProcessingAttempt(
DateSource dateSource,
String validatedTitle,
// Target copy traceability (null for non-SUCCESS attempts)
String finalTargetFileName) {
String finalTargetFileName,
// Token- und Preis-Snapshot-Felder (null fuer Versuche ohne KI-Aufruf
// bzw. fuer V3.2-Bestand)
Long inputTokens,
Long outputTokens,
Long cacheCreationInputTokens,
Long cacheReadInputTokens,
Long priceInputPerTokenNanoUsd,
Long priceOutputPerTokenNanoUsd) {
/**
* Compact constructor validating mandatory non-null fields and numeric constraints.
@@ -133,12 +109,71 @@ public record ProcessingAttempt(
Objects.requireNonNull(status, "status must not be null");
}
/**
* Convenience-Konstruktor ohne die neuen Token- und Preis-Felder.
*
* <p>Setzt alle sechs Token- und Preis-Snapshot-Felder auf {@code null}.
* Wird von Aufrufern verwendet, die noch keine Token-Daten beistellen
* (typisch fuer Skip-/Pre-Check-Pfade) oder fuer Tests, die das
* Token-Tracking nicht beruehren.
*
* @param fingerprint document identity; never null
* @param runId batch run identifier; never null
* @param attemptNumber monotonic attempt number; must be &gt;= 1
* @param startedAt start instant; never null
* @param endedAt end instant; never null
* @param status outcome status; never null
* @param failureClass failure class, or {@code null}
* @param failureMessage failure description, or {@code null}
* @param retryable whether retryable in a later run
* @param aiProvider opaque AI provider identifier, or {@code null}
* @param modelName AI model name, or {@code null}
* @param promptIdentifier prompt identifier, or {@code null}
* @param processedPageCount number of PDF pages processed, or {@code null}
* @param sentCharacterCount number of characters sent to AI, or {@code null}
* @param aiRawResponse full raw AI response, or {@code null}
* @param aiReasoning AI reasoning text, or {@code null}
* @param resolvedDate resolved date for naming proposal, or {@code null}
* @param dateSource origin of resolved date, or {@code null}
* @param validatedTitle validated title, or {@code null}
* @param finalTargetFileName filename written to the target folder, or {@code null}
*/
public ProcessingAttempt(
DocumentFingerprint fingerprint,
RunId runId,
int attemptNumber,
Instant startedAt,
Instant endedAt,
ProcessingStatus status,
String failureClass,
String failureMessage,
boolean retryable,
String aiProvider,
String modelName,
String promptIdentifier,
Integer processedPageCount,
Integer sentCharacterCount,
String aiRawResponse,
String aiReasoning,
LocalDate resolvedDate,
DateSource dateSource,
String validatedTitle,
String finalTargetFileName) {
this(fingerprint, runId, attemptNumber, startedAt, endedAt, status,
failureClass, failureMessage, retryable,
aiProvider, modelName, promptIdentifier,
processedPageCount, sentCharacterCount,
aiRawResponse, aiReasoning,
resolvedDate, dateSource, validatedTitle, finalTargetFileName,
null, null, null, null, null, null);
}
/**
* Creates a {@link ProcessingAttempt} with no AI traceability fields set.
* <p>
* Convenience factory for pre-check failures, skip events, and any attempt
* that does not involve an AI call. The {@link #aiProvider()} field is set
* to {@code null}.
* that does not involve an AI call. The {@link #aiProvider()} field and all
* Token- und Preis-Snapshot-Felder werden auf {@code null} gesetzt.
*
* @param fingerprint document identity; must not be null
* @param runId batch run identifier; must not be null
@@ -164,6 +199,7 @@ public record ProcessingAttempt(
return new ProcessingAttempt(
fingerprint, runId, attemptNumber, startedAt, endedAt,
status, failureClass, failureMessage, retryable,
null, null, null, null, null, null, null, null, null, null, null);
null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null);
}
}
@@ -214,6 +214,7 @@ public class AiNamingService {
AiInvocationSuccess invocationSuccess) {
String rawResponseBody = invocationSuccess.rawResponse().content();
de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata usage = invocationSuccess.usageMetadata();
// Step 5: Parse the raw response for structural correctness
return switch (AiResponseParser.parse(invocationSuccess.rawResponse())) {
@@ -226,18 +227,22 @@ public class AiNamingService {
null,
new AiAttemptContext(
modelName, promptIdentifier, pageCount, sentCharacterCount,
rawResponseBody));
rawResponseBody,
usage.inputTokens(), usage.outputTokens(),
usage.cacheCreationInputTokens(), usage.cacheReadInputTokens()));
case AiResponseParsingSuccess parsingSuccess ->
// Step 6: Validate semantics (title rules, date format)
validateAndBuildOutcome(
candidate, pageCount, sentCharacterCount, promptIdentifier,
rawResponseBody, parsingSuccess.response());
rawResponseBody, parsingSuccess.response(), usage);
};
}
/**
* Validates the parsed AI response and builds the final outcome.
*
* @param usage Token-Verbrauchsmetadaten; nicht {@code null}
*/
private DocumentProcessingOutcome validateAndBuildOutcome(
SourceDocumentCandidate candidate,
@@ -245,10 +250,13 @@ public class AiNamingService {
int sentCharacterCount,
String promptIdentifier,
String rawResponseBody,
ParsedAiResponse parsedResponse) {
ParsedAiResponse parsedResponse,
de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata usage) {
AiAttemptContext aiContext = new AiAttemptContext(
modelName, promptIdentifier, pageCount, sentCharacterCount, rawResponseBody);
modelName, promptIdentifier, pageCount, sentCharacterCount, rawResponseBody,
usage.inputTokens(), usage.outputTokens(),
usage.cacheCreationInputTokens(), usage.cacheReadInputTokens());
return switch (aiResponseValidator.validate(parsedResponse)) {
case AiResponseValidator.AiValidationResult.Invalid invalid ->
@@ -10,8 +10,10 @@ import java.util.function.Function;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
@@ -163,6 +165,25 @@ public class DocumentProcessingCoordinator {
private final int maxRetriesTransient;
private final int maxTitleLength;
private final String activeProviderIdentifier;
/**
* Optionales Repository fuer Modell-Preis-Snapshots.
*
* <p>Wird beim Bau eines Attempts mit erfolgreichem KI-Aufruf konsultiert,
* um die Snapshot-Preise zum aktiven Modell zu laden. {@code null}
* bedeutet, dass kein Repository verdrahtet wurde der Coordinator
* arbeitet dann ohne Snapshot-Lookup, und alle Preis-Felder bleiben
* {@code null}. Damit bleiben bestehende Aufrufer ohne Token-Tracking
* lauffaehig.
*/
private final ModelPriceRepository modelPriceRepository;
/**
* Markierung fuer den Headless-Betrieb.
*
* <p>Im Headless-Betrieb wird zusaetzlich zum normalen WARN-Log auf
* fehlende Preis-Eintraege ein Hinweis auf den CLI-Befehl
* {@code --upsert-model-price} ausgegeben.
*/
private final boolean headlessMode;
/**
* Optional per-run completion forwarder that is consulted by
@@ -229,6 +250,40 @@ public class DocumentProcessingCoordinator {
int maxRetriesTransient,
int maxTitleLength,
String activeProviderIdentifier) {
this(documentRecordRepository, processingAttemptRepository, unitOfWorkPort,
targetFolderPort, targetFileCopyPort, logger,
maxRetriesTransient, maxTitleLength, activeProviderIdentifier,
null, false);
}
/**
* Erweiterter Konstruktor mit Modell-Preis-Repository und Headless-Hinweis.
*
* <p>Identisch zum bestehenden Konstruktor; zusaetzlich werden ein
* {@link ModelPriceRepository} fuer Snapshot-Lookups und ein
* Flag zum Headless-Modus injiziert. Beim Bau eines Attempts mit
* erfolgreichem KI-Aufruf wird der Snapshot-Preis ueber das Repository
* geladen. Faellt der Lookup mit Exception aus, wird der Attempt mit
* Snapshot-Feldern auf {@code null} persistiert und der Fehler ge&shy;
* loggt. Im Headless-Modus wird zudem ein Hinweis auf den CLI-Befehl
* {@code --upsert-model-price} ausgegeben, wenn das Modell keinen Preis
* hat.
*
* @param modelPriceRepository Repository-Port; darf {@code null} sein
* @param headlessMode {@code true} wenn der Lauf headless ist
*/
public DocumentProcessingCoordinator(
DocumentRecordRepository documentRecordRepository,
ProcessingAttemptRepository processingAttemptRepository,
UnitOfWorkPort unitOfWorkPort,
TargetFolderPort targetFolderPort,
TargetFileCopyPort targetFileCopyPort,
ProcessingLogger logger,
int maxRetriesTransient,
int maxTitleLength,
String activeProviderIdentifier,
ModelPriceRepository modelPriceRepository,
boolean headlessMode) {
if (maxRetriesTransient < 1) {
throw new IllegalArgumentException(
"maxRetriesTransient must be >= 1, got: " + maxRetriesTransient);
@@ -255,6 +310,8 @@ public class DocumentProcessingCoordinator {
this.maxRetriesTransient = maxRetriesTransient;
this.maxTitleLength = maxTitleLength;
this.activeProviderIdentifier = activeProviderIdentifier;
this.modelPriceRepository = modelPriceRepository;
this.headlessMode = headlessMode;
this.completionForwarder = null;
}
@@ -1137,6 +1194,7 @@ public class DocumentProcessingCoordinator {
case NamingProposalReady proposalReady -> {
AiAttemptContext ctx = proposalReady.aiContext();
NamingProposal proposal = proposalReady.proposal();
PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
@@ -1146,11 +1204,15 @@ public class DocumentProcessingCoordinator {
ctx.aiRawResponse(),
proposal.aiReasoning(),
proposal.resolvedDate(), proposal.dateSource(), proposal.validatedTitle(),
null // finalTargetFileName set only on SUCCESS attempts
null, // finalTargetFileName set only on SUCCESS attempts
ctx.inputTokens(), ctx.outputTokens(),
ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
);
}
case AiTechnicalFailure techFail -> {
AiAttemptContext ctx = techFail.aiContext();
PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
@@ -1159,11 +1221,15 @@ public class DocumentProcessingCoordinator {
ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(),
null, null, null, null,
null // finalTargetFileName
null, // finalTargetFileName
ctx.inputTokens(), ctx.outputTokens(),
ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
);
}
case AiFunctionalFailure funcFail -> {
AiAttemptContext ctx = funcFail.aiContext();
PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
yield new ProcessingAttempt(
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
@@ -1172,7 +1238,10 @@ public class DocumentProcessingCoordinator {
ctx.processedPageCount(), ctx.sentCharacterCount(),
ctx.aiRawResponse(),
null, null, null, null,
null // finalTargetFileName
null, // finalTargetFileName
ctx.inputTokens(), ctx.outputTokens(),
ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
);
}
default -> ProcessingAttempt.withoutAiFields(
@@ -1182,6 +1251,74 @@ public class DocumentProcessingCoordinator {
};
}
/**
* Laedt den Preis-Snapshot fuer den aktiven Provider und das uebergebene Modell.
*
* <p>Bei fehlendem Repository (Verdrahtung ohne Token-Tracking) liefert die
* Methode einen leeren Snapshot ({@code (null, null)}) und protokolliert nichts.
*
* <p>Bei fehlendem Eintrag (kein Preis konfiguriert) gibt die Methode einen
* leeren Snapshot zurueck und schreibt eine WARN-Logzeile. Im Headless-Modus
* wird zusaetzlich ein Hinweis auf den CLI-Befehl ergaenzt.
*
* <p>Bei Lookup-Exception schreibt die Methode eine ERROR-Logzeile und liefert
* einen leeren Snapshot. Der aufrufende Code verwendet ihn unveraendert weiter,
* sodass der Attempt persistiert wird (Token-Daten bleiben verfuegbar, nur die
* Preisfelder bleiben {@code null}).
*
* @param modelName aktiver Modellname; darf {@code null} sein (z.B. wenn kein KI-Aufruf erfolgte)
* @return Snapshot mit Input-/Output-Preis (Nano-USD/Token) oder {@code null}-Feldern bei Fehler
*/
private PriceSnapshot loadPriceSnapshot(String modelName) {
if (modelPriceRepository == null || modelName == null) {
return PriceSnapshot.empty();
}
try {
java.util.Optional<ModelPriceView> view =
modelPriceRepository.findByProviderAndModelName(activeProviderIdentifier, modelName);
if (view.isEmpty()) {
if (headlessMode) {
logger.warn("Kein Preis-Eintrag fuer Provider \"{}\" und Modell \"{}\" "
+ "Tokens werden persistiert, Snapshot bleibt leer. "
+ "Hinweis: Modell-Preise koennen mit --upsert-model-price ergaenzt werden. Siehe betrieb.md.",
activeProviderIdentifier, modelName);
} else {
logger.warn("Kein Preis-Eintrag fuer Provider \"{}\" und Modell \"{}\" "
+ "Tokens werden persistiert, Snapshot bleibt leer.",
activeProviderIdentifier, modelName);
}
return PriceSnapshot.empty();
}
ModelPriceView priceView = view.get();
return new PriceSnapshot(
priceView.priceInputPerTokenNanoUsd(),
priceView.priceOutputPerTokenNanoUsd());
} catch (RuntimeException ex) {
logger.error("Preis-Lookup fuer Provider \"{}\" und Modell \"{}\" fehlgeschlagen: {} "
+ "Attempt wird mit Snapshot-Feldern auf null persistiert.",
activeProviderIdentifier, modelName, ex.getMessage(), ex);
return PriceSnapshot.empty();
}
}
/**
* Halterung fuer den geladenen Preis-Snapshot.
*
* @param inputPriceNanoUsd Input-Preis in Nano-USD pro Token oder {@code null}
* @param outputPriceNanoUsd Output-Preis in Nano-USD pro Token oder {@code null}
*/
private record PriceSnapshot(Long inputPriceNanoUsd, Long outputPriceNanoUsd) {
/**
* Liefert einen Snapshot ohne Werte (beide Felder {@code null}).
*
* @return leerer Snapshot
*/
static PriceSnapshot empty() {
return new PriceSnapshot(null, null);
}
}
/**
* Builds a human-readable failure message from the pipeline outcome and status outcome.
*/
@@ -0,0 +1,200 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;
/**
* Use Case zur Verwaltung persistierter Modell-Preise.
*
* <p>Bietet eine schmale CRUD-Fassade ueber das {@link ModelPriceRepository}
* inklusive der ChangeSet-Konfliktvalidierung und einer Whitelist-Pruefung
* auf bekannte Provider beim Upsert. Loeschungen sind auch fuer unbekannte
* Provider erlaubt, damit verwaiste Eintraege entfernt werden koennen.
*
* <p>Der {@link ClockPort} liefert den {@code updatedAt}-Wert fuer alle
* Upserts, so da&szlig; die Schreiblogik testbar bleibt.
*
* <p>Konfliktvalidierungsregeln (alle vier sind in dieser Reihenfolge wirksam):
* <ol>
* <li>Doppelte (provider, modelName)-Schluessel innerhalb der Upsert-Liste werden abgewiesen.</li>
* <li>Doppelte (provider, modelName)-Schluessel innerhalb der Deletions-Liste werden abgewiesen.</li>
* <li>Ein Schluessel darf nicht zugleich in {@code upserts} und {@code deletions} stehen.</li>
* <li>Beim Upsert ist nur die V3.3-Provider-Whitelist erlaubt
* ({@code openai-compatible}, {@code claude}).</li>
* </ol>
*
* <p>Ein leeres ChangeSet erzeugt keinen Repository-Aufruf, sondern wird
* als No-op mit INFO-Log behandelt.
*/
public class DefaultManageModelPricesUseCase {
private static final Logger LOG = LogManager.getLogger(DefaultManageModelPricesUseCase.class);
/**
* Whitelist der in V3.3 fachlich unterstuetzten Provider.
*
* <p>Wird beim Upsert geprueft. Loeschungen umgehen die Whitelist
* absichtlich, damit verwaiste Eintraege entfernbar bleiben.
*/
public static final Set<String> SUPPORTED_PROVIDERS = Set.of("openai-compatible", "claude");
private final ModelPriceRepository repository;
private final ClockPort clockPort;
/**
* Erzeugt den Use Case mit allen erforderlichen Ports.
*
* @param repository Repository-Port; nicht {@code null}
* @param clockPort Clock-Port; nicht {@code null}
*/
public DefaultManageModelPricesUseCase(ModelPriceRepository repository, ClockPort clockPort) {
this.repository = Objects.requireNonNull(repository, "repository");
this.clockPort = Objects.requireNonNull(clockPort, "clockPort");
}
/**
* Liefert alle persistierten Modell-Preise.
*
* @return Liste aller Eintraege; nie {@code null}
*/
public List<ModelPriceView> findAll() {
return repository.findAll();
}
/**
* Liefert den Preis-Eintrag zu einem Composite-Key.
*
* @param provider Provider-Identifikator
* @param modelName Modellname
* @return {@link Optional} mit Eintrag, leer wenn nicht vorhanden
*/
public Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName) {
return repository.findByProviderAndModelName(provider, modelName);
}
/**
* Validiert das ChangeSet und persistiert es transaktional.
*
* <p>Bei einem leeren ChangeSet wird kein Repository-Aufruf ausgefuehrt;
* die Methode kehrt direkt zurueck. Bei Konfliktverletzungen oder
* Whitelist-Verstoessen wird eine {@link ModelPriceValidationException}
* mit deutscher Meldung geworfen, ohne dass eine Transaktion gestartet
* wird.
*
* <p>Vor der Repository-Weitergabe werden die Upsert-Eintraege auf den
* Clock-Zeitpunkt der aktuellen Aktion umgeschrieben (gleicher
* Zeitstempel fuer alle Eintraege eines ChangeSets).
*
* @param changeSet zu speicherndes ChangeSet; nicht {@code null}
* @throws ModelPriceValidationException bei Konfliktverstoessen oder unbekanntem Provider beim Upsert
*/
public void saveAllChanges(ModelPriceChangeSet changeSet) {
Objects.requireNonNull(changeSet, "changeSet");
if (changeSet.isEmpty()) {
LOG.info("Modell-Preis-ChangeSet ist leer kein Schreibvorgang durchgefuehrt");
return;
}
validateNoDuplicateUpsertKeys(changeSet.upserts());
validateNoDuplicateDeletionKeys(changeSet.deletions());
validateNoCrossOverlap(changeSet.upserts(), changeSet.deletions());
validateUpsertProviders(changeSet.upserts());
Instant now = clockPort.now();
ModelPriceChangeSet stamped = stampUpdatedAt(changeSet, now);
try {
repository.saveAllChanges(stamped);
LOG.info("Modell-Preis-Batch persistiert: {} Upserts, {} Deletions",
stamped.upserts().size(), stamped.deletions().size());
} catch (RuntimeException ex) {
LOG.error("Modell-Preis-Batch fehlgeschlagen, Rollback ausgefuehrt: {}", ex.getMessage());
throw ex;
}
}
/**
* Erstellt eine Kopie des ChangeSets, in der jeder Upsert-Eintrag den
* uebergebenen Zeitpunkt als {@code updatedAt} traegt.
*
* @param changeSet Original-ChangeSet
* @param now neuer Zeitstempel
* @return neues ChangeSet mit identischen Daten und einheitlichem {@code updatedAt}
*/
private ModelPriceChangeSet stampUpdatedAt(ModelPriceChangeSet changeSet, Instant now) {
List<ModelPriceEntry> stampedUpserts = changeSet.upserts().stream()
.map(entry -> new ModelPriceEntry(
entry.provider(),
entry.modelName(),
entry.priceInputPerTokenNanoUsd(),
entry.priceOutputPerTokenNanoUsd(),
entry.currency(),
now))
.toList();
return new ModelPriceChangeSet(stampedUpserts, changeSet.deletions());
}
private void validateNoDuplicateUpsertKeys(List<ModelPriceEntry> upserts) {
Set<String> seen = new HashSet<>();
for (ModelPriceEntry entry : upserts) {
String key = entry.provider() + "|" + entry.modelName();
if (!seen.add(key)) {
throw new ModelPriceValidationException(
"ChangeSet enthaelt doppelten Upsert fuer Provider \""
+ entry.provider() + "\" und Modell \"" + entry.modelName() + "\"");
}
}
}
private void validateNoDuplicateDeletionKeys(List<ModelPriceKey> deletions) {
Set<String> seen = new HashSet<>();
for (ModelPriceKey key : deletions) {
String composite = key.provider() + "|" + key.modelName();
if (!seen.add(composite)) {
throw new ModelPriceValidationException(
"ChangeSet enthaelt doppelte Loeschung fuer Provider \""
+ key.provider() + "\" und Modell \"" + key.modelName() + "\"");
}
}
}
private void validateNoCrossOverlap(List<ModelPriceEntry> upserts, List<ModelPriceKey> deletions) {
Set<String> upsertKeys = new HashSet<>();
for (ModelPriceEntry entry : upserts) {
upsertKeys.add(entry.provider() + "|" + entry.modelName());
}
for (ModelPriceKey key : deletions) {
String composite = key.provider() + "|" + key.modelName();
if (upsertKeys.contains(composite)) {
throw new ModelPriceValidationException(
"ChangeSet enthaelt Schluessel sowohl in Upserts als auch in Deletions: Provider \""
+ key.provider() + "\", Modell \"" + key.modelName() + "\"");
}
}
}
private void validateUpsertProviders(List<ModelPriceEntry> upserts) {
for (ModelPriceEntry entry : upserts) {
if (!SUPPORTED_PROVIDERS.contains(entry.provider())) {
throw new ModelPriceValidationException(
"Unbekannter Provider beim Upsert: \"" + entry.provider()
+ "\". Zulaessig sind: " + SUPPORTED_PROVIDERS);
}
}
}
}
@@ -0,0 +1,26 @@
package de.gecheckt.pdf.umbenenner.application.usecase;
/**
* Validierungsfehler beim Speichern von Modell-Preisen.
*
* <p>Wird vom {@link DefaultManageModelPricesUseCase} ausgeloest, wenn ein
* {@link de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet}
* intern nicht konsistent ist (z.B. Schluesselkonflikte) oder wenn ein
* Eintrag gegen die fachlichen Regeln versto&szlig;t (z.B. unbekannter
* Provider beim Upsert). Die Exception enthaelt eine deutsche
* Fehlermeldung, die ohne weitere Verarbeitung an die GUI/CLI durch&shy;
* gereicht werden kann.
*/
public class ModelPriceValidationException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Erzeugt eine neue Validierungs-Exception.
*
* @param message deutsche Fehlermeldung; nicht {@code null}
*/
public ModelPriceValidationException(String message) {
super(message);
}
}
@@ -83,7 +83,8 @@ class AiNamingServiceTest {
}
private static AiInvocationSuccess successWith(String jsonBody) {
return new AiInvocationSuccess(dummyRequest(), new AiRawResponse(jsonBody));
return new AiInvocationSuccess(dummyRequest(), new AiRawResponse(jsonBody),
de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata.empty());
}
private static AiInvocationTechnicalFailure technicalFailure(String reason, String message) {
@@ -454,7 +454,8 @@ public class BootstrapRunner {
this.useCaseFactory = (startConfig, lock) -> buildProductionBatchUseCase(
startConfig, lock,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(),
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled(),
true);
this.commandFactory = SchedulerBatchCommand::new;
this.guiAdapterFactory = GuiAdapter::new;
this.singleInstanceGuardFactory = SingleInstanceGuard::new;
@@ -479,6 +480,23 @@ public class BootstrapRunner {
RunLockPort runLockPort,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
return buildProductionBatchUseCase(
startConfig, runLockPort, progressObserver, cancellationToken, false);
}
/**
* Erweiterte Variante mit Headless-Modus-Flag fuer den Token-Tracking-Hook.
*
* @param headlessMode {@code true} fuer headless Laufkontext; aktiviert den
* CLI-Hinweis bei fehlendem Modell-Preis-Eintrag
* @return verdrahteter Use Case
*/
private BatchRunProcessingUseCase buildProductionBatchUseCase(
StartConfiguration startConfig,
RunLockPort runLockPort,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken,
boolean headlessMode) {
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
@@ -499,12 +517,16 @@ public class BootstrapRunner {
DocumentProcessingCoordinator.class, aiContentSensitivity);
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository modelPriceRepository =
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl);
DocumentProcessingCoordinator documentProcessingCoordinator =
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
startConfig.maxRetriesTransient(),
startConfig.maxTitleLength(),
activeFamily.getIdentifier());
activeFamily.getIdentifier(),
modelPriceRepository,
headlessMode);
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
ClockPort clockPort = new SystemClockAdapter();
@@ -916,6 +938,8 @@ public class BootstrapRunner {
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort modelPricePort =
buildGuiModelPriceManagementPort();
GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
GuiCreateNewDatabasePort createNewDatabasePort = this::createNewDatabaseForGui;
GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
@@ -966,7 +990,8 @@ public class BootstrapRunner {
Optional.empty(),
Optional.empty(),
Optional.empty(),
contextInitializer);
contextInitializer,
Optional.of(modelPricePort));
}
Path configPath = Paths.get(configPathOverride.get());
@@ -1002,7 +1027,8 @@ public class BootstrapRunner {
Optional.empty(),
Optional.empty(),
Optional.empty(),
contextInitializer);
contextInitializer,
Optional.of(modelPricePort));
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -1025,7 +1051,8 @@ public class BootstrapRunner {
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
this::buildGuiPromptEditorPort, createNewDatabasePort, contextError,
schedulerUseCase, guiRunLockPort, contextInitializer);
schedulerUseCase, guiRunLockPort, contextInitializer,
Optional.of(modelPricePort));
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -1057,7 +1084,8 @@ public class BootstrapRunner {
Optional.empty(),
Optional.empty(),
Optional.empty(),
contextInitializer);
contextInitializer,
Optional.of(modelPricePort));
}
}
@@ -2169,6 +2197,52 @@ public class BootstrapRunner {
migrationStep.runIfNeeded(effectiveConfigPath);
}
/**
* Erstellt eine GUI-Bridge-Implementierung fuer den Modell-Preis-Tab.
*
* <p>Pro Methodenaufruf wird ein frischer SQLite-Adapter aus der zur
* Konfigurationsdatei gehoerenden JDBC-URL aufgebaut. Die Methoden des
* Ports laufen auf einem GUI-Worker-Thread; die Connections werden
* try-with-resources sofort wieder geschlossen.
*
* @return Bridge-Port-Implementierung; nie {@code null}
*/
private de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort
buildGuiModelPriceManagementPort() {
return new de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort() {
@Override
public java.util.List<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView>
findAll(Path configFilePath) {
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
return new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl)
.findAll();
}
@Override
public Optional<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView>
findByProviderAndModelName(Path configFilePath, String provider, String modelName) {
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
return new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl)
.findByProviderAndModelName(provider, modelName);
}
@Override
public void saveAllChanges(Path configFilePath,
de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet changeSet) {
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository repository =
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl);
de.gecheckt.pdf.umbenenner.application.usecase.DefaultManageModelPricesUseCase useCase =
new de.gecheckt.pdf.umbenenner.application.usecase.DefaultManageModelPricesUseCase(
repository, new SystemClockAdapter());
useCase.saveAllChanges(changeSet);
}
};
}
/**
* Liefert die aktive JDBC-URL für GUI-Aufrufe, die keinen vollständigen
* {@link StartConfiguration} benötigen.
@@ -104,6 +104,7 @@ final class StubAiInvocationPort implements AiInvocationPort {
+ "\"title\": \"" + title + "\", "
+ "\"reasoning\": \"" + reasoning + "\""
+ "}";
return new AiInvocationSuccess(request, new AiRawResponse(rawJson));
return new AiInvocationSuccess(request, new AiRawResponse(rawJson),
de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata.empty());
}
}
@@ -11,6 +11,10 @@ import java.util.Objects;
* <li>AI infrastructure details (model name, prompt identifier)</li>
* <li>Request size metrics (processed pages, sent character count)</li>
* <li>Raw AI output (for audit and diagnostics; stored in SQLite, not in log files)</li>
* <li>Token-Verbrauch (Standard- und Anthropic-Cache-Tokens) nullable, da nicht
* jeder Provider Token-Counts liefert und einzelne Felder vom Adapter wegen
* ungueltiger Werte ({@code null}, &lt; 0, &gt; 10 Mio.) auf {@code null}
* gesetzt werden koennen.</li>
* </ul>
* <p>
* This context is produced whenever an AI call is attempted, regardless of whether
@@ -23,13 +27,21 @@ import java.util.Objects;
* @param sentCharacterCount number of document-text characters sent to the AI; must be &gt;= 0
* @param aiRawResponse the complete raw AI response body; {@code null} if the call did
* not return a response body (e.g. timeout or connection error)
* @param inputTokens Anzahl Standard-Input-Tokens; {@code null} wenn nicht ermittelbar
* @param outputTokens Anzahl Standard-Output-Tokens; {@code null} wenn nicht ermittelbar
* @param cacheCreationInputTokens Anzahl Cache-Schreib-Tokens (Anthropic); {@code null} bei OpenAI oder fehlend
* @param cacheReadInputTokens Anzahl Cache-Lese-Tokens (Anthropic); {@code null} bei OpenAI oder fehlend
*/
public record AiAttemptContext(
String modelName,
String promptIdentifier,
int processedPageCount,
int sentCharacterCount,
String aiRawResponse) {
String aiRawResponse,
Long inputTokens,
Long outputTokens,
Long cacheCreationInputTokens,
Long cacheReadInputTokens) {
/**
* Compact constructor validating mandatory fields.
@@ -50,4 +62,27 @@ public record AiAttemptContext(
"sentCharacterCount must be >= 0, but was: " + sentCharacterCount);
}
}
/**
* Convenience-Konstruktor ohne Token-Felder.
*
* <p>Setzt alle vier Token-Felder auf {@code null}. Wird von Aufrufern verwendet,
* die noch keine Token-Daten beistellen (z.B. KI-Aufrufe ohne usage-Antwort,
* frueher V3.2-Bestand oder Tests, die das Token-Tracking nicht beruehren).
*
* @param modelName AI model name; never null
* @param promptIdentifier stable prompt identifier; never null
* @param processedPageCount number of PDF pages included; must be &gt;= 1
* @param sentCharacterCount number of characters sent to AI; must be &gt;= 0
* @param aiRawResponse raw AI response body or {@code null}
*/
public AiAttemptContext(
String modelName,
String promptIdentifier,
int processedPageCount,
int sentCharacterCount,
String aiRawResponse) {
this(modelName, promptIdentifier, processedPageCount, sentCharacterCount, aiRawResponse,
null, null, null, null);
}
}