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 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 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 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 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 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 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 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 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 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 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 Spaetere Migrationen koennen additiv weitere Indizes anlegen; sie
+ * sind kein Konformitaetskriterium fuer Fall 2.
+ */
private static final Set 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:
+ * 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 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:
+ * 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 urspruengliche 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
*
* 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 @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:
* 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 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):
+ * 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 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 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
* 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);
+ }
}
+ *
+ *
+ *
+ *
+ *
+ * @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.
+ *
+ *
+ *
+ *
+ * @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.
+ *
+ *
- *
*
- * @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.
+ *
+ *
+ *
+ *
+ *