diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index e527ce3..10dd085 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -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. + * + *

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 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. + * + *

Liegt kein Eintrag vor, wird eine deutsche Warnung im zentralen + * Meldungsbereich erzeugt. Das Speichern selbst wird durch diese Pruefung + * nicht blockiert; sie dient ausschließlich dem Hinweis, daß + * 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 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. + * + *

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. *

@@ -1746,7 +1850,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 diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java index 5291370..618e036 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiStartupContext.java @@ -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 applicationContextError, Optional schedulerControlUseCase, Optional configurationFileLockPort, - GuiApplicationContextInitializer applicationContextInitializer) { + GuiApplicationContextInitializer applicationContextInitializer, + Optional 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()); } /** diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBanner.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBanner.java index f41a141..7d1fb06 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBanner.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/batchrun/BatchRunSummaryBanner.java @@ -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. + * + *

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 counts) { + update(counts, BatchRunTokenSummary.empty()); + } + + /** + * Aktualisiert das Banner mit Status-Zaehlern und Token-/Kosten-Aggregaten. + * + *

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. + * + *

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 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. + * + *

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. * diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java index c934dbc..b385d9e 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/history/GuiHistoryTab.java @@ -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 inputTokensCol = new TableColumn<>("Input-Tokens"); + inputTokensCol.setCellValueFactory(c -> + new SimpleStringProperty(formatTokenCount(c.getValue().inputTokens()))); + inputTokensCol.setPrefWidth(110); + + TableColumn outputTokensCol = new TableColumn<>("Output-Tokens"); + outputTokensCol.setCellValueFactory(c -> + new SimpleStringProperty(formatTokenCount(c.getValue().outputTokens()))); + outputTokensCol.setPrefWidth(110); + + TableColumn 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. + * + *

Cache-only-Versuche zeigen den Hinweis "nur Cache-Tokens, keine + * Standardkosten". Bei fehlendem Preis-Snapshot erscheint + * "Preis fehlt". Mikrobetraege werden als "< + * $0.0001" 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; } // ========================================================================= diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/GuiModelPriceManagementPort.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/GuiModelPriceManagementPort.java new file mode 100644 index 0000000..3f9d902 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/GuiModelPriceManagementPort.java @@ -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. + * + *

Dieser Port ist kein 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. + * + *

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. + * + *

Threading: 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 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 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); +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/GuiModelPricesTab.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/GuiModelPricesTab.java new file mode 100644 index 0000000..2b4eb19 --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/GuiModelPricesTab.java @@ -0,0 +1,562 @@ +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. + * + *

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. + * + *

Der Tab arbeitet ausschließlich gegen den + * {@link GuiModelPriceManagementPort}. Bootstrap verdrahtet den Port mit + * einer Implementierung, die anhand der aktuell geladenen Konfigurationsdatei + * eine SQLite-Verbindung aufbaut. + * + *

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 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 tableView = new TableView<>(); + private final ObservableList 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> configPathSupplier; + private final Set originalKeys = new HashSet<>(); + private final List 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> 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; + } + + /** + * Triggert ein Neuladen der Tabelle aus der aktuell geladenen Konfiguration. + */ + public void reloadFromCurrentConfig() { + Optional 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 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 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 providerCol = new TableColumn<>("Provider"); + providerCol.setCellValueFactory(c -> c.getValue().providerProperty); + providerCol.setPrefWidth(150); + + TableColumn modelCol = new TableColumn<>("Modellname"); + modelCol.setCellValueFactory(c -> c.getValue().modelNameProperty); + modelCol.setPrefWidth(220); + + TableColumn inCol = new TableColumn<>("In/1M USD"); + inCol.setCellValueFactory(c -> c.getValue().inputPriceTextProperty); + inCol.setPrefWidth(120); + inCol.setCellFactory(col -> new PriceEditCell(true)); + + TableColumn outCol = new TableColumn<>("Out/1M USD"); + outCol.setCellValueFactory(c -> c.getValue().outputPriceTextProperty); + outCol.setPrefWidth(120); + outCol.setCellFactory(col -> new PriceEditCell(false)); + + TableColumn currencyCol = new TableColumn<>("Waehrung"); + currencyCol.setCellValueFactory(c -> c.getValue().currencyProperty); + currencyCol.setPrefWidth(80); + + TableColumn updatedCol = new TableColumn<>("Letzte Aenderung"); + updatedCol.setCellValueFactory(c -> c.getValue().updatedAtTextProperty); + updatedCol.setPrefWidth(180); + + TableColumn 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 = configPathSupplier.get(); + boolean active = port != null && path.isPresent(); + addButton.setDisable(!active); + saveButton.setDisable(!active); + reloadButton.setDisable(!active); + } + + private void openAddDialog() { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Modell hinzufuegen"); + dialog.setHeaderText("Neuen Modell-Preis erfassen"); + + ChoiceBox 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 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 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 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. + * + *

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 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 { + 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 { + 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); + } + } + } +} diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/package-info.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/package-info.java new file mode 100644 index 0000000..aa12faf --- /dev/null +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/modelprices/package-info.java @@ -0,0 +1,9 @@ +/** + * GUI-Bestandteile fuer die Verwaltung der persistierten Modell-Preise. + * + *

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; diff --git a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java index 4004608..e846fe7 100644 --- a/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java +++ b/pdf-umbenenner-adapter-in-gui/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiAdapterSmokeTest.java @@ -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()), diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java index 26de81e..b2fb213 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/AnthropicClaudeHttpAdapter.java @@ -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. + * + *

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}. + * + *

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. + * + *

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. *

diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java index 9855b21..9cd502c 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/ai/OpenAiHttpAdapter.java @@ -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. + * + *

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}. + * + *

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. + * + *

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. *

diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/ModelPriceRepositoryException.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/ModelPriceRepositoryException.java new file mode 100644 index 0000000..2615c93 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/ModelPriceRepositoryException.java @@ -0,0 +1,24 @@ +package de.gecheckt.pdf.umbenenner.adapter.out.sqlite; + +/** + * Technischer Fehler im SQLite-Adapter fuer Modell-Preise. + * + *

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); + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteConnectionFactory.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteConnectionFactory.java new file mode 100644 index 0000000..bdfd85c --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteConnectionFactory.java @@ -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. + * + *

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. + * + *

Folgende PRAGMAs werden auf jeder Connection gesetzt: + *

+ * + *

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ß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. + * + *

Die Foreign-Key-Pruefung wird hier nicht 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"); + } + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java index 9789247..61eb04e 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteDocumentRecordRepositoryAdapter.java @@ -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); } } \ No newline at end of file diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java index dbe419e..352d2d5 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteHistoryQueryAdapter.java @@ -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); } /** diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteModelPriceRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteModelPriceRepositoryAdapter.java new file mode 100644 index 0000000..d191a51 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteModelPriceRepositoryAdapter.java @@ -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}. + * + *

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. + * + *

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 "ungültig" 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 findAll() { + List 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 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}. + * + *

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); + } +} diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java index d4d6579..d3c8762 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteProcessingAttemptRepositoryAdapter.java @@ -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); } } diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java index 28ac472..530af06 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapter.java @@ -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. + * + *

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 kein Konformitaetskriterium, + * weil die Schema-Pruefung in Fall 2 ausschließlich gegen das + * V1-Schema arbeitet. Die V2-Spalten werden nach der Baseline-Eintragung + * durch Flyway ergaenzt. + */ private static final Set 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}. + * + *

Spaetere Migrationen koennen additiv weitere Indizes anlegen; sie + * sind kein Konformitaetskriterium fuer Fall 2. + */ private static final Set EXPECTED_INDEXES = Set.of( "idx_processing_attempt_fingerprint", "idx_processing_attempt_run_id", diff --git a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java index c455681..b85e9b0 100644 --- a/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java +++ b/pdf-umbenenner-adapter-out/src/main/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteUnitOfWorkAdapter.java @@ -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 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 { diff --git a/pdf-umbenenner-adapter-out/src/main/resources/db/migration/V2__token_tracking.sql b/pdf-umbenenner-adapter-out/src/main/resources/db/migration/V2__token_tracking.sql new file mode 100644 index 0000000..6f1bf59 --- /dev/null +++ b/pdf-umbenenner-adapter-out/src/main/resources/db/migration/V2__token_tracking.sql @@ -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; diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java index e641e33..852ce6a 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/out/sqlite/SqliteSchemaInitializationAdapterTest.java @@ -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" ); } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/CostCalculator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/CostCalculator.java new file mode 100644 index 0000000..0e97a88 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/CostCalculator.java @@ -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}. + * + *

Der CostCalculator fuehrt selbst keine Multiplikation + * Tokens×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. + * + *

Es findet keine interne Rundung statt; Rundung + * auf vier Nachkommastellen erfolgt ausschließlich im GUI-Layer + * (CostFormatter). + */ +public final class CostCalculator { + + /** Divisor fuer die Umrechnung Nano-USD → 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. + * + *

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. + * + *

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). + * + *

Multipliziert Tokens×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); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/CostResult.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/CostResult.java new file mode 100644 index 0000000..0850cc8 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/CostResult.java @@ -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. + * + *

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. + * + *

Flag-Semantik: + *

+ * + * @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) { +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/package-info.java new file mode 100644 index 0000000..4061c54 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/cost/package-info.java @@ -0,0 +1,9 @@ +/** + * Application-Komponenten fuer die Interpretation aggregierter Token-Kosten. + * + *

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, "< $0.0001") zustaendig. + */ +package de.gecheckt.pdf.umbenenner.application.cost; diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/AiUsageMetadata.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/AiUsageMetadata.java new file mode 100644 index 0000000..17a3a13 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/AiUsageMetadata.java @@ -0,0 +1,61 @@ +package de.gecheckt.pdf.umbenenner.application.dto; + +/** + * Token-Verbrauchsmetadaten eines erfolgreichen KI-Aufrufs. + * + *

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. + * + *

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. + * + *

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. + * + *

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); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceChangeSet.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceChangeSet.java new file mode 100644 index 0000000..5089a70 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceChangeSet.java @@ -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. + * + *

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 vor dem + * Aufruf der Repository-Methode. Die Repository-Implementierung darf das + * Set damit als bereits konsistent voraussetzen und beschrä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 upserts, + List 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(); + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceEntry.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceEntry.java new file mode 100644 index 0000000..2c383c0 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceEntry.java @@ -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. + * + *

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. + * + *

Wertebereiche: + *

+ * + * @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"); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceKey.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceKey.java new file mode 100644 index 0000000..8af46c5 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceKey.java @@ -0,0 +1,34 @@ +package de.gecheckt.pdf.umbenenner.application.dto; + +import java.util.Objects; + +/** + * Composite-Key fuer einen Modell-Preis-Eintrag. + * + *

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"); + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceView.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceView.java new file mode 100644 index 0000000..4957367 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/ModelPriceView.java @@ -0,0 +1,37 @@ +package de.gecheckt.pdf.umbenenner.application.dto; + +import java.time.Instant; + +/** + * Lese- und Anzeige-DTO fuer Modell-Preise. + * + *

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­liche String in + * {@link #invalidUpdatedAtRaw()} gehalten, damit die GUI "ungültig" + * anzeigen kann. + * + *

Dieses DTO darf nicht 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) { +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/package-info.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/package-info.java new file mode 100644 index 0000000..9db5d01 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/dto/package-info.java @@ -0,0 +1,8 @@ +/** + * Application-Schicht-DTOs fuer den Token- und Kosten-Tracking-Pfad. + * + *

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; diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationSuccess.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationSuccess.java index 62a96db..7dcb9d7 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationSuccess.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/AiInvocationSuccess.java @@ -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 *

  • {@link #rawResponse()} — the uninterpreted response body returned by the AI, * which may be valid JSON, malformed, empty, or otherwise problematic
  • + *
  • {@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
  • * *

    * The Application layer is responsible for: @@ -29,22 +33,27 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation; * *

    * Persistence: 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 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"); } } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ModelPriceRepository.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ModelPriceRepository.java new file mode 100644 index 0000000..995e028 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ModelPriceRepository.java @@ -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. + * + *

    Schreibpfad-Konvention: GUI und CLI nutzen ausschließlich + * {@link #saveAllChanges(ModelPriceChangeSet)} fuer transaktionale Batch- + * Speicherungen. Die Methoden {@link #upsert(ModelPriceEntry)} und + * {@link #delete(String, String)} sind ausschließlich fuer Tests und + * Werkzeuge gedacht; sie sind keine Bestandteile des regulären + * Bedienpfads. + * + *

    Lesemethoden liefern {@link ModelPriceView} (mit nullable + * {@link ModelPriceView#updatedAt()} bei beschädigten DB-Werten). + * Schreibmethoden akzeptieren ausschließlich {@link ModelPriceEntry}, + * dessen Konstruktor sämtliche Wertgrenzen prüft. + */ +public interface ModelPriceRepository { + + /** + * Liefert alle persistierten Modell-Preise. + * + * @return unveraenderbare Liste aller Eintraege; nie {@code null} + */ + List 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 findByProviderAndModelName(String provider, String modelName); + + /** + * Atomarer Insert oder Update. + * + *

    @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. + * + *

    @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. + * + *

    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); +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java index 0951d64..8d64de9 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/port/out/ProcessingAttempt.java @@ -25,71 +25,39 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId; * ({@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED}, * {@link ProcessingStatus#SKIPPED_FINAL_FAILURE}). *

    - * Field semantics: + * Token- und Preis-Felder: *

    * - * @param fingerprint content-based document identity; never null - * @param runId identifier of the batch run; never null - * @param attemptNumber monotonic sequence number per fingerprint; must be >= 1 - * @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 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 for SUCCESS attempts, - * or {@code null} + * @param fingerprint content-based document identity; never null + * @param runId identifier of the batch run; never null + * @param attemptNumber monotonic sequence number per fingerprint; must be >= 1 + * @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} + * @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} + * @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 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. + * + *

    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 >= 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. *

    * 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); } } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingService.java index e85dd48..689bc9c 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingService.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingService.java @@ -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 -> diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java index 60d9019..1247780 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java @@ -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. + * + *

    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. + * + *

    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. + * + *

    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­ + * 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. + * + *

    Bei fehlendem Repository (Verdrahtung ohne Token-Tracking) liefert die + * Methode einen leeren Snapshot ({@code (null, null)}) und protokolliert nichts. + * + *

    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. + * + *

    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 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. */ diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManageModelPricesUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManageModelPricesUseCase.java new file mode 100644 index 0000000..f80be18 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultManageModelPricesUseCase.java @@ -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. + * + *

    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. + * + *

    Der {@link ClockPort} liefert den {@code updatedAt}-Wert fuer alle + * Upserts, so daß die Schreiblogik testbar bleibt. + * + *

    Konfliktvalidierungsregeln (alle vier sind in dieser Reihenfolge wirksam): + *

      + *
    1. Doppelte (provider, modelName)-Schluessel innerhalb der Upsert-Liste werden abgewiesen.
    2. + *
    3. Doppelte (provider, modelName)-Schluessel innerhalb der Deletions-Liste werden abgewiesen.
    4. + *
    5. Ein Schluessel darf nicht zugleich in {@code upserts} und {@code deletions} stehen.
    6. + *
    7. Beim Upsert ist nur die V3.3-Provider-Whitelist erlaubt + * ({@code openai-compatible}, {@code claude}).
    8. + *
    + * + *

    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. + * + *

    Wird beim Upsert geprueft. Loeschungen umgehen die Whitelist + * absichtlich, damit verwaiste Eintraege entfernbar bleiben. + */ + public static final Set 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 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 findByProviderAndModelName(String provider, String modelName) { + return repository.findByProviderAndModelName(provider, modelName); + } + + /** + * Validiert das ChangeSet und persistiert es transaktional. + * + *

    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. + * + *

    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 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 upserts) { + Set 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 deletions) { + Set 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 upserts, List deletions) { + Set 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 upserts) { + for (ModelPriceEntry entry : upserts) { + if (!SUPPORTED_PROVIDERS.contains(entry.provider())) { + throw new ModelPriceValidationException( + "Unbekannter Provider beim Upsert: \"" + entry.provider() + + "\". Zulaessig sind: " + SUPPORTED_PROVIDERS); + } + } + } +} diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/ModelPriceValidationException.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/ModelPriceValidationException.java new file mode 100644 index 0000000..c258087 --- /dev/null +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/ModelPriceValidationException.java @@ -0,0 +1,26 @@ +package de.gecheckt.pdf.umbenenner.application.usecase; + +/** + * Validierungsfehler beim Speichern von Modell-Preisen. + * + *

    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ßt (z.B. unbekannter + * Provider beim Upsert). Die Exception enthaelt eine deutsche + * Fehlermeldung, die ohne weitere Verarbeitung an die GUI/CLI durch­ + * 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); + } +} diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java index adc7595..b06aec1 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java @@ -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) { diff --git a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java index 96ccd2e..6cc50d3 100644 --- a/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java +++ b/pdf-umbenenner-bootstrap/src/main/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunner.java @@ -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. + * + *

    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 + 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 + 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. diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/StubAiInvocationPort.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/StubAiInvocationPort.java index 64d56f5..b0f9123 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/StubAiInvocationPort.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/StubAiInvocationPort.java @@ -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()); } } diff --git a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiAttemptContext.java b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiAttemptContext.java index 8091140..5616858 100644 --- a/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiAttemptContext.java +++ b/pdf-umbenenner-domain/src/main/java/de/gecheckt/pdf/umbenenner/domain/model/AiAttemptContext.java @@ -11,25 +11,37 @@ import java.util.Objects; *

  • AI infrastructure details (model name, prompt identifier)
  • *
  • Request size metrics (processed pages, sent character count)
  • *
  • Raw AI output (for audit and diagnostics; stored in SQLite, not in log files)
  • + *
  • 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}, < 0, > 10 Mio.) auf {@code null} + * gesetzt werden koennen.
  • * *

    * This context is produced whenever an AI call is attempted, regardless of whether * the call succeeded or failed. Fields that could not be determined (e.g. raw response * on connection failure) may be {@code null}. * - * @param modelName the AI model name used in the request; never null - * @param promptIdentifier stable identifier of the prompt template; never null - * @param processedPageCount number of PDF pages included in the extraction; must be >= 1 - * @param sentCharacterCount number of document-text characters sent to the AI; must be >= 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 modelName the AI model name used in the request; never null + * @param promptIdentifier stable identifier of the prompt template; never null + * @param processedPageCount number of PDF pages included in the extraction; must be >= 1 + * @param sentCharacterCount number of document-text characters sent to the AI; must be >= 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. + * + *

    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 >= 1 + * @param sentCharacterCount number of characters sent to AI; must be >= 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); + } }