feat: AP-A Token-Tracking Fundament - Schema, Adapter, Use Cases, GUI (#74)

Erste Stufe der V3.3-Spezifikation: Token- und Kosten-Tracking-Fundament.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 09:49:50 +02:00
parent b63dcf5efa
commit 08ec021b5f
40 changed files with 2779 additions and 120 deletions
@@ -489,6 +489,22 @@ public final class GuiConfigurationEditorWorkspace {
*/
private final GuiPromptEditorTab promptEditorTab;
/**
* Sechster Haupt-Tab: Modell-Preise. Verwaltet die persistierten
* {@code model_price}-Eintraege fuer das Token-/Kosten-Tracking.
*/
private final de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPricesTab modelPricesTab;
/**
* Optionaler Bridge-Port zur Abfrage der Modell-Preise.
*
* <p>Wird beim Speichern der Konfiguration zur Pruefung herangezogen,
* ob das aktuell gewaehlte Modell einen Preis-Eintrag besitzt. Fehlt der
* Eintrag, wird eine deutsche Warnung angezeigt; das Speichern bleibt
* trotzdem erlaubt.
*/
private final java.util.Optional<de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort> modelPricePortForConfig;
/**
* Hint banner shown at the top of the configuration tab while a processing run or
* the automatic scheduler is active. Visible + managed state are controlled by
@@ -613,6 +629,11 @@ public final class GuiConfigurationEditorWorkspace {
this.promptEditorTab = new GuiPromptEditorTab(
effectiveContext.promptEditorPort(), configuredPromptPath, maxTitleLength);
this.modelPricePortForConfig = effectiveContext.modelPriceManagementPort();
this.modelPricesTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPricesTab(
this.modelPricePortForConfig.orElse(null),
() -> java.util.Optional.ofNullable(loadedConfigurationPath()));
configureRoot();
configureHeader(effectiveContext.startupNotice());
configureTabs();
@@ -1054,6 +1075,7 @@ public final class GuiConfigurationEditorWorkspace {
* "Speichern unter" to let the user choose a target path.
*/
public void requestSaveConfiguration() {
warnIfActiveModelHasNoPriceEntry();
if (editorState.isNewConfiguration()) {
requestSaveConfigurationAs();
return;
@@ -1062,6 +1084,88 @@ public final class GuiConfigurationEditorWorkspace {
saveToPath(targetPath);
}
/**
* Prueft, ob das aktuell ausgewaehlte Modell einen Preis-Eintrag hat.
*
* <p>Liegt kein Eintrag vor, wird eine deutsche Warnung im zentralen
* Meldungsbereich erzeugt. Das Speichern selbst wird durch diese Pruefung
* nicht blockiert; sie dient ausschlie&szlig;lich dem Hinweis, da&szlig;
* Token-Tracking ohne Preis-Snapshot fortgesetzt wird.
*/
private void warnIfActiveModelHasNoPriceEntry() {
if (modelPricePortForConfig.isEmpty()) {
return;
}
Path configPath = loadedConfigurationPath();
if (configPath == null) {
return;
}
String activeProvider = editorState.values().activeProviderFamily();
if (activeProvider == null || activeProvider.isBlank()) {
return;
}
de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily family;
try {
family = de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily
.fromIdentifier(activeProvider).orElse(null);
} catch (RuntimeException ex) {
family = null;
}
if (family == null) {
return;
}
de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState providerState =
editorState.values().providerConfigurations().get(family);
if (providerState == null) {
return;
}
String modelName = providerState.model();
if (modelName == null || modelName.isBlank()) {
return;
}
try {
java.util.Optional<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView> view =
modelPricePortForConfig.get().findByProviderAndModelName(
configPath, family.getIdentifier(), modelName);
if (view.isEmpty()) {
LOG.warn("Kein Modell-Preis fuer Provider \"{}\" und Modell \"{}\" hinterlegt "
+ "Tokens werden erfasst, Kosten koennen jedoch nicht vollstaendig berechnet werden.",
family.getIdentifier(), modelName);
showSensitiveMessage(
"Warnung: Fuer das aktuell gewaehlte Modell \"" + modelName
+ "\" ist kein Preis hinterlegt. Tokens werden weiterhin erfasst, "
+ "Kosten koennen jedoch nicht vollstaendig berechnet werden.");
}
} catch (RuntimeException ex) {
LOG.warn("Pruefung auf Modell-Preis fehlgeschlagen: {}", ex.getMessage());
}
}
/**
* Zeigt eine deutsche Warnung als nicht-blockierenden Alert an.
*
* <p>Die Warnung erscheint nur wenn ein FX-Fenster aktiv ist; in
* Headless-Smoke-Tests faellt die Methode auf einen Log-Eintrag zurueck.
*
* @param message Warntext
*/
private void showSensitiveMessage(String message) {
if (root.getScene() == null || root.getScene().getWindow() == null) {
LOG.warn(message);
return;
}
try {
javafx.scene.control.Alert alert = new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.WARNING, message,
javafx.scene.control.ButtonType.OK);
alert.setHeaderText("Modell-Preis fehlt");
alert.show();
} catch (RuntimeException ex) {
LOG.warn("Konnte Modell-Preis-Warnung nicht anzeigen: {} Originalmeldung: {}",
ex.getMessage(), message);
}
}
/**
* Handles the explicit "Speichern unter" action.
* <p>
@@ -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
@@ -17,6 +17,7 @@ import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryOverviewPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryResetDocumentStatusPort;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorStateFactory;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort;
import de.gecheckt.pdf.umbenenner.application.port.in.ResetDocumentStatusResult;
import de.gecheckt.pdf.umbenenner.application.port.in.SchedulerControlUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
@@ -105,7 +106,8 @@ public record GuiStartupContext(
Optional<String> applicationContextError,
Optional<SchedulerControlUseCase> schedulerControlUseCase,
Optional<ConfigurationFileLockPort> configurationFileLockPort,
GuiApplicationContextInitializer applicationContextInitializer) {
GuiApplicationContextInitializer applicationContextInitializer,
Optional<GuiModelPriceManagementPort> modelPriceManagementPort) {
private static final String NO_PROMPT_PORT_MSG = "Kein Prompt-Editor-Port in diesem Startkontext verfügbar.";
private static final String NO_PORT_MSG = "Kein Port in diesem Startkontext.";
@@ -195,6 +197,7 @@ public record GuiStartupContext(
configurationFileLockPort = Objects.requireNonNullElse(configurationFileLockPort, Optional.empty());
applicationContextInitializer = applicationContextInitializer == null
? GuiApplicationContextInitializer.noOp() : applicationContextInitializer;
modelPriceManagementPort = Objects.requireNonNullElse(modelPriceManagementPort, Optional.empty());
}
/**
@@ -263,7 +266,7 @@ public record GuiStartupContext(
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, Optional.empty(), Optional.empty(),
GuiApplicationContextInitializer.noOp());
GuiApplicationContextInitializer.noOp(), Optional.empty());
}
/**
@@ -335,7 +338,7 @@ public record GuiStartupContext(
historyOverviewPort, historyDetailsPort, historyResetDocumentStatusPort,
deleteDocumentHistoryPort, promptEditorPortFactory, createNewDatabasePort,
applicationContextError, schedulerControlUseCase, Optional.empty(),
GuiApplicationContextInitializer.noOp());
GuiApplicationContextInitializer.noOp(), Optional.empty());
}
/**
@@ -1,8 +1,11 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.batchrun;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@@ -10,6 +13,7 @@ import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
/**
* Einzeilige Zusammenfassungsleiste, die nach Abschluss eines Verarbeitungslaufs
@@ -61,9 +65,29 @@ public final class BatchRunSummaryBanner {
/** Wurzel-Container des Banners wird in das Tab-Layout eingebettet. */
private final HBox container;
/**
* Vertikale Halterung fuer mehrzeilige Banner-Inhalte.
*
* <p>Enthaelt die Status-Zeile (bestehend), die Token-Zeile, die
* Kosten-Zeile und optional eine Cache-only-Zeile. Die Token-/Kosten-
* /Cache-Zeilen werden in V3.3 mit einem leeren {@link BatchRunTokenSummary}
* vorbelegt; die echten Aggregat-Werte werden durch das nachfolgende
* Arbeitspaket geliefert.
*/
private final VBox lineContainer;
/** Label, das den kompletten Bannertext als Inline-Segmente trägt. */
private final Label contentLabel;
/** Label fuer die Tokenzeile (Input/Output). */
private final Label tokenLabel;
/** Label fuer die Kosten-Zeile. */
private final Label costLabel;
/** Label fuer die optionale Cache-only-Hinweiszeile. */
private final Label cacheOnlyLabel;
/**
* Erstellt ein neues, initial unsichtbares Summary-Banner.
*/
@@ -72,7 +96,19 @@ public final class BatchRunSummaryBanner {
contentLabel.setStyle(STYLE_DEFAULT);
contentLabel.setWrapText(false);
container = new HBox(SPACING, contentLabel);
tokenLabel = new Label();
tokenLabel.setStyle(STYLE_DEFAULT);
costLabel = new Label();
costLabel.setStyle(STYLE_DEFAULT);
cacheOnlyLabel = new Label();
cacheOnlyLabel.setStyle(STYLE_DEFAULT);
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
lineContainer = new VBox(2, contentLabel, tokenLabel, costLabel, cacheOnlyLabel);
lineContainer.setAlignment(Pos.CENTER_LEFT);
container = new HBox(SPACING, lineContainer);
container.setAlignment(Pos.CENTER_LEFT);
container.setStyle("-fx-padding: " + PADDING_V + " 0 " + PADDING_V + " 0;");
@@ -92,6 +128,11 @@ public final class BatchRunSummaryBanner {
*/
public void clear() {
contentLabel.setText("");
tokenLabel.setText("");
costLabel.setText("");
cacheOnlyLabel.setText("");
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
container.setVisible(false);
container.setManaged(false);
}
@@ -108,19 +149,115 @@ public final class BatchRunSummaryBanner {
* fehlende Status werden als 0 interpretiert; darf nicht null sein
*/
public void update(Map<DocumentCompletionStatus, Integer> counts) {
update(counts, BatchRunTokenSummary.empty());
}
/**
* Aktualisiert das Banner mit Status-Zaehlern und Token-/Kosten-Aggregaten.
*
* <p>Zeigt die Status-Zeile (wenn nicht leer) sowie die Token- und Kosten-
* Zeilen. Die Cache-only-Zeile erscheint nur, wenn {@link
* BatchRunTokenSummary#cacheOnlyAttemptCount()} groesser als 0 ist.
*
* <p>Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param counts Zaehler je Status; nicht {@code null}
* @param tokenSummary Aggregat-Werte fuer Tokens und Kosten; nicht {@code null}
*/
public void update(Map<DocumentCompletionStatus, Integer> counts,
BatchRunTokenSummary tokenSummary) {
Objects.requireNonNull(counts, "counts darf nicht null sein");
Objects.requireNonNull(tokenSummary, "tokenSummary darf nicht null sein");
String text = buildBannerText(counts);
if (text.isEmpty()) {
contentLabel.setText(text);
tokenLabel.setText(buildTokenLine(tokenSummary));
costLabel.setText(buildCostLine(tokenSummary));
if (tokenSummary.cacheOnlyAttemptCount() > 0) {
cacheOnlyLabel.setText(buildCacheOnlyLine(tokenSummary.cacheOnlyAttemptCount()));
cacheOnlyLabel.setVisible(true);
cacheOnlyLabel.setManaged(true);
} else {
cacheOnlyLabel.setText("");
cacheOnlyLabel.setVisible(false);
cacheOnlyLabel.setManaged(false);
}
if (text.isEmpty() && !tokenSummary.hasAnyData()) {
clear();
return;
}
contentLabel.setText(text);
container.setVisible(true);
container.setManaged(true);
}
/**
* Token-/Kosten-Aggregat fuer einen Banner-Eintrag.
*
* <p>Ein {@link #empty()}-Default reicht, solange das Read-Model fuer
* Aggregate noch nicht implementiert ist. AP-B liefert spaeter die echten
* Werte ueber den {@code TokenStatisticsReadModelPort}.
*
* @param totalInputTokens Summe Input-Tokens; ggf. {@code 0}
* @param totalOutputTokens Summe Output-Tokens; ggf. {@code 0}
* @param totalCostUsd Summe der Kosten in USD; ggf. {@code BigDecimal.ZERO}
* @param hasMissingPriceSnapshot {@code true}, wenn mind. ein Versuch ohne Preis-Snapshot vorlag
* @param hasCacheTokensIgnored {@code true}, wenn Cache-Tokens vorkamen
* @param cacheOnlyAttemptCount Anzahl Cache-only-Versuche im Lauf
*/
public record BatchRunTokenSummary(
long totalInputTokens,
long totalOutputTokens,
BigDecimal totalCostUsd,
boolean hasMissingPriceSnapshot,
boolean hasCacheTokensIgnored,
long cacheOnlyAttemptCount) {
/**
* Liefert ein leeres Aggregat (alle Zaehler null, Kosten 0).
*
* @return leeres Aggregat
*/
public static BatchRunTokenSummary empty() {
return new BatchRunTokenSummary(0L, 0L, BigDecimal.ZERO, false, false, 0L);
}
/**
* Pruefung, ob ueberhaupt Daten zum Anzeigen vorliegen.
*
* @return {@code true} bei Werten ungleich 0
*/
public boolean hasAnyData() {
return totalInputTokens > 0 || totalOutputTokens > 0
|| (totalCostUsd != null && totalCostUsd.signum() != 0)
|| cacheOnlyAttemptCount > 0;
}
}
private static String buildTokenLine(BatchRunTokenSummary s) {
return String.format(Locale.GERMAN, "Tokens: Input %,d Output %,d",
s.totalInputTokens(), s.totalOutputTokens());
}
private static String buildCostLine(BatchRunTokenSummary s) {
BigDecimal cost = s.totalCostUsd() != null ? s.totalCostUsd() : BigDecimal.ZERO;
BigDecimal rounded = cost.setScale(4, RoundingMode.HALF_UP);
StringBuilder sb = new StringBuilder("Kosten: $").append(rounded.toPlainString());
if (s.hasCacheTokensIgnored()) {
sb.append(" (ohne Cache-Anteil)");
}
if (s.hasMissingPriceSnapshot()) {
sb.append(" (unvollstaendig)");
}
return sb.toString();
}
private static String buildCacheOnlyLine(long count) {
return " " + count + " Cache-only Versuche (in Kosten nicht enthalten)";
}
/**
* Liefert den JavaFX-Container-Knoten zum Einbetten in das Tab-Layout.
*
@@ -519,7 +519,84 @@ public final class GuiHistoryTab {
? c.getValue().finalTargetFileName() : ""));
fileNameCol.setCellFactory(col -> ellipsisCell());
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol, fileNameCol);
TableColumn<ProcessingAttempt, String> inputTokensCol = new TableColumn<>("Input-Tokens");
inputTokensCol.setCellValueFactory(c ->
new SimpleStringProperty(formatTokenCount(c.getValue().inputTokens())));
inputTokensCol.setPrefWidth(110);
TableColumn<ProcessingAttempt, String> outputTokensCol = new TableColumn<>("Output-Tokens");
outputTokensCol.setCellValueFactory(c ->
new SimpleStringProperty(formatTokenCount(c.getValue().outputTokens())));
outputTokensCol.setPrefWidth(110);
TableColumn<ProcessingAttempt, String> costCol = new TableColumn<>("Kosten");
costCol.setCellValueFactory(c -> new SimpleStringProperty(formatAttemptCost(c.getValue())));
costCol.setPrefWidth(140);
attemptsTable.getColumns().setAll(numCol, dateCol, statusCol, providerCol, modelCol,
fileNameCol, inputTokensCol, outputTokensCol, costCol);
}
/**
* Formatiert eine Token-Anzahl mit deutscher Tausenderfassung.
*
* @param value Wert oder {@code null}
* @return Anzeigetext, "—" bei {@code null}
*/
private static String formatTokenCount(Long value) {
if (value == null) {
return "";
}
return String.format(java.util.Locale.GERMAN, "%,d", value);
}
/**
* Bestimmt den Anzeigetext fuer die Kosten-Spalte eines Versuchs.
*
* <p>Cache-only-Versuche zeigen den Hinweis &quot;nur Cache-Tokens, keine
* Standardkosten&quot;. Bei fehlendem Preis-Snapshot erscheint
* &quot;Preis fehlt&quot;. Mikrobetraege werden als &quot;&lt;
* $0.0001&quot; dargestellt.
*
* @param attempt Versuchseintrag
* @return Anzeigetext fuer die Kosten-Spalte
*/
private static String formatAttemptCost(ProcessingAttempt attempt) {
boolean hasStandardTokens = attempt.inputTokens() != null || attempt.outputTokens() != null;
boolean hasCacheTokens =
(attempt.cacheCreationInputTokens() != null && attempt.cacheCreationInputTokens() > 0)
|| (attempt.cacheReadInputTokens() != null && attempt.cacheReadInputTokens() > 0);
if (!hasStandardTokens) {
if (hasCacheTokens) {
return "nur Cache-Tokens, keine Standardkosten";
}
return "";
}
de.gecheckt.pdf.umbenenner.application.cost.CostCalculator calc =
new de.gecheckt.pdf.umbenenner.application.cost.CostCalculator();
de.gecheckt.pdf.umbenenner.application.cost.CostResult result = calc.calculateAttempt(
attempt.inputTokens(),
attempt.outputTokens(),
attempt.priceInputPerTokenNanoUsd(),
attempt.priceOutputPerTokenNanoUsd(),
hasCacheTokens);
if (result.amountUsd() == null) {
if (result.missingPriceSnapshot()) {
return "Preis fehlt";
}
return "";
}
java.math.BigDecimal amount = result.amountUsd();
java.math.BigDecimal threshold = new java.math.BigDecimal("0.0001");
if (amount.signum() > 0 && amount.compareTo(threshold) < 0) {
return "< $0.0001";
}
java.math.BigDecimal rounded = amount.setScale(4, java.math.RoundingMode.HALF_UP);
String suffix = result.cacheTokensIgnored() ? " (ohne Cache-Anteil)" : "";
if (result.partialTokens()) {
return "~$" + rounded.toPlainString() + suffix;
}
return "$" + rounded.toPlainString() + suffix;
}
// =========================================================================
@@ -0,0 +1,53 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
/**
* GUI-interner Bridge-Port fuer die Verwaltung von Modell-Preisen.
*
* <p>Dieser Port ist <em>kein</em> hexagonaler Outbound-Port der Application-Schicht.
* Er ist eine modul-interne Bruecke, ueber die Bootstrap die SQLite-basierte
* Verwaltung der Tabelle {@code model_price} fuer den GUI-Tab bereitstellt,
* ohne dass der GUI-Adapter direkt auf Repository-Implementierungen zugreift.
*
* <p>Der Parameter {@code configFilePath} wird benoetigt, damit die
* Bootstrap-Implementierung die SQLite-Datenbank aus der aktuell geladenen
* Konfigurationsdatei ableiten kann, ohne den Pfad global zu speichern.
*
* <p><strong>Threading:</strong> Implementierungen muessen sicher von einem
* Hintergrund-Worker-Thread aufgerufen werden koennen. Aufrufe blockieren,
* bis das Ergebnis vollstaendig vorliegt.
*/
public interface GuiModelPriceManagementPort {
/**
* Liefert alle persistierten Modell-Preise.
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @return Liste der Modell-Preise; nie {@code null}
*/
List<ModelPriceView> findAll(Path configFilePath);
/**
* Sucht den Eintrag fuer (Provider, Modellname).
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @param provider Provider-Identifikator
* @param modelName Modellname
* @return Eintrag oder {@link Optional#empty()}
*/
Optional<ModelPriceView> findByProviderAndModelName(Path configFilePath, String provider, String modelName);
/**
* Persistiert ein {@link ModelPriceChangeSet} atomar.
*
* @param configFilePath Pfad zur aktuell geladenen Konfigurationsdatei; nicht {@code null}
* @param changeSet Sammlung aus Upserts und Loeschungen; nicht {@code null}
*/
void saveAllChanges(Path configFilePath, ModelPriceChangeSet changeSet);
}
@@ -0,0 +1,562 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import javafx.application.Platform;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
/**
* GUI-Tab fuer die Verwaltung der persistierten Modell-Preise.
*
* <p>Zeigt die Tabelle {@code model_price} aufbereitet als
* {@code $/1M Tokens} an. Eintraege bekannter Provider sind editierbar;
* Eintraege unbekannter Provider werden read-only mit Tooltip dargestellt
* und koennen lediglich geloescht werden.
*
* <p>Der Tab arbeitet ausschlie&szlig;lich gegen den
* {@link GuiModelPriceManagementPort}. Bootstrap verdrahtet den Port mit
* einer Implementierung, die anhand der aktuell geladenen Konfigurationsdatei
* eine SQLite-Verbindung aufbaut.
*
* <p>Threading: alle DB-Operationen laufen auf einem dedizierten
* Hintergrund-Worker-Thread; UI-Updates erfolgen ueber
* {@link Platform#runLater(Runnable)}.
*/
public final class GuiModelPricesTab {
private static final Logger LOG = LogManager.getLogger(GuiModelPricesTab.class);
private static final String TAB_TITLE = "Modell-Preise";
/** V3.3-Whitelist der unterstuetzten Provider. */
public static final List<String> SUPPORTED_PROVIDERS = List.of("openai-compatible", "claude");
private static final BigDecimal NANO_TO_USD_PER_MILLION = new BigDecimal("1000000000")
.divide(new BigDecimal("1000000"));
private final Tab tab = new Tab(TAB_TITLE);
private final TableView<EditableEntry> tableView = new TableView<>();
private final ObservableList<EditableEntry> rows = FXCollections.observableArrayList();
private final Label statusLabel = new Label();
private final Button addButton = new Button("Modell hinzufuegen");
private final Button saveButton = new Button("Speichern");
private final Button reloadButton = new Button("Neu laden");
private final GuiModelPriceManagementPort port;
private final Supplier<Optional<Path>> configPathSupplier;
private final Set<ModelPriceKey> originalKeys = new HashSet<>();
private final List<ModelPriceKey> pendingDeletions = new ArrayList<>();
private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "gui-model-prices");
t.setDaemon(true);
return t;
});
/**
* Erstellt den Tab und verdrahtet die Bedienelemente.
*
* @param port Bridge-Port fuer DB-Zugriff; darf {@code null} sein (Tab zeigt dann Hinweis)
* @param configPathSupplier Liefert den aktuell geladenen Konfigurationspfad oder leer; nicht {@code null}
*/
public GuiModelPricesTab(GuiModelPriceManagementPort port,
Supplier<Optional<Path>> configPathSupplier) {
this.port = port;
this.configPathSupplier = Objects.requireNonNull(configPathSupplier, "configPathSupplier");
tab.setClosable(false);
buildUi();
updateButtonStates();
}
/**
* Liefert den JavaFX-Tab-Knoten.
*
* @return der Tab; nie {@code null}
*/
public Tab tab() {
return tab;
}
/**
* Triggert ein Neuladen der Tabelle aus der aktuell geladenen Konfiguration.
*/
public void reloadFromCurrentConfig() {
Optional<Path> currentPath = configPathSupplier.get();
if (port == null || currentPath.isEmpty()) {
Platform.runLater(() -> {
rows.clear();
pendingDeletions.clear();
originalKeys.clear();
statusLabel.setText("Bitte zuerst eine Konfigurationsdatei laden.");
updateButtonStates();
});
return;
}
Path configPath = currentPath.get();
statusLabel.setText("Lade Modell-Preise ...");
workerExecutor.submit(() -> {
try {
List<ModelPriceView> views = port.findAll(configPath);
Platform.runLater(() -> applyLoadedRows(views));
} catch (RuntimeException ex) {
LOG.error("Modell-Preise konnten nicht geladen werden: {}", ex.getMessage(), ex);
Platform.runLater(() -> statusLabel.setText("Fehler beim Laden: " + ex.getMessage()));
}
});
}
private void applyLoadedRows(List<ModelPriceView> views) {
rows.clear();
pendingDeletions.clear();
originalKeys.clear();
for (ModelPriceView view : views) {
rows.add(EditableEntry.fromView(view));
originalKeys.add(new ModelPriceKey(view.provider(), view.modelName()));
}
statusLabel.setText("Geladen: " + views.size() + " Eintraege.");
updateButtonStates();
}
private void buildUi() {
tableView.setItems(rows);
tableView.setEditable(true);
tableView.setPlaceholder(new Label("Keine Modell-Preise vorhanden."));
TableColumn<EditableEntry, String> providerCol = new TableColumn<>("Provider");
providerCol.setCellValueFactory(c -> c.getValue().providerProperty);
providerCol.setPrefWidth(150);
TableColumn<EditableEntry, String> modelCol = new TableColumn<>("Modellname");
modelCol.setCellValueFactory(c -> c.getValue().modelNameProperty);
modelCol.setPrefWidth(220);
TableColumn<EditableEntry, String> inCol = new TableColumn<>("In/1M USD");
inCol.setCellValueFactory(c -> c.getValue().inputPriceTextProperty);
inCol.setPrefWidth(120);
inCol.setCellFactory(col -> new PriceEditCell(true));
TableColumn<EditableEntry, String> outCol = new TableColumn<>("Out/1M USD");
outCol.setCellValueFactory(c -> c.getValue().outputPriceTextProperty);
outCol.setPrefWidth(120);
outCol.setCellFactory(col -> new PriceEditCell(false));
TableColumn<EditableEntry, String> currencyCol = new TableColumn<>("Waehrung");
currencyCol.setCellValueFactory(c -> c.getValue().currencyProperty);
currencyCol.setPrefWidth(80);
TableColumn<EditableEntry, String> updatedCol = new TableColumn<>("Letzte Aenderung");
updatedCol.setCellValueFactory(c -> c.getValue().updatedAtTextProperty);
updatedCol.setPrefWidth(180);
TableColumn<EditableEntry, Void> deleteCol = new TableColumn<>("Aktion");
deleteCol.setCellFactory(col -> new DeleteButtonCell());
deleteCol.setPrefWidth(80);
tableView.getColumns().setAll(List.of(providerCol, modelCol, inCol, outCol,
currencyCol, updatedCol, deleteCol));
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_LAST_COLUMN);
addButton.setOnAction(e -> openAddDialog());
saveButton.setOnAction(e -> handleSave());
reloadButton.setOnAction(e -> reloadFromCurrentConfig());
HBox buttonBar = new HBox(8, addButton, saveButton, reloadButton);
buttonBar.setAlignment(Pos.CENTER_LEFT);
VBox.setVgrow(tableView, Priority.ALWAYS);
VBox content = new VBox(8, tableView, buttonBar, statusLabel);
content.setPadding(new Insets(12));
tab.setContent(content);
statusLabel.setText("Klicken Sie auf \"Neu laden\", um die aktuellen Modell-Preise anzuzeigen.");
}
private void updateButtonStates() {
Optional<Path> path = configPathSupplier.get();
boolean active = port != null && path.isPresent();
addButton.setDisable(!active);
saveButton.setDisable(!active);
reloadButton.setDisable(!active);
}
private void openAddDialog() {
Dialog<EditableEntry> dialog = new Dialog<>();
dialog.setTitle("Modell hinzufuegen");
dialog.setHeaderText("Neuen Modell-Preis erfassen");
ChoiceBox<String> providerBox = new ChoiceBox<>(FXCollections.observableArrayList(SUPPORTED_PROVIDERS));
providerBox.getSelectionModel().selectFirst();
TextField modelField = new TextField();
TextField inputField = new TextField();
TextField outputField = new TextField();
GridPane grid = new GridPane();
grid.setHgap(8);
grid.setVgap(8);
grid.add(new Label("Provider"), 0, 0);
grid.add(providerBox, 1, 0);
grid.add(new Label("Modellname"), 0, 1);
grid.add(modelField, 1, 1);
grid.add(new Label("In/1M USD"), 0, 2);
grid.add(inputField, 1, 2);
grid.add(new Label("Out/1M USD"), 0, 3);
grid.add(outputField, 1, 3);
dialog.getDialogPane().setContent(grid);
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
dialog.setResultConverter(button -> {
if (button != ButtonType.OK) {
return null;
}
String provider = providerBox.getValue();
String modelName = modelField.getText() != null ? modelField.getText().trim() : "";
if (provider == null || modelName.isEmpty()) {
showError("Provider und Modellname sind Pflichtfelder.");
return null;
}
try {
long inputNano = parseUsdPerMillionToNano(inputField.getText());
long outputNano = parseUsdPerMillionToNano(outputField.getText());
EditableEntry entry = new EditableEntry(provider, modelName,
inputNano, outputNano, "USD", null, false, true);
return entry;
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
return null;
}
});
Optional<EditableEntry> result = dialog.showAndWait();
result.ifPresent(entry -> {
for (EditableEntry existing : rows) {
if (existing.providerProperty.get().equals(entry.providerProperty.get())
&& existing.modelNameProperty.get().equals(entry.modelNameProperty.get())) {
showError("Eintrag fuer Provider \"" + entry.providerProperty.get()
+ "\" und Modell \"" + entry.modelNameProperty.get()
+ "\" existiert bereits.");
return;
}
}
rows.add(entry);
statusLabel.setText("Neuer Eintrag vorgemerkt; bitte speichern.");
});
}
private void handleSave() {
Optional<Path> currentPath = configPathSupplier.get();
if (port == null || currentPath.isEmpty()) {
return;
}
ModelPriceChangeSet changeSet;
try {
changeSet = buildChangeSet();
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
return;
}
if (changeSet.isEmpty()) {
statusLabel.setText("Keine Aenderungen zu speichern.");
return;
}
Path configPath = currentPath.get();
statusLabel.setText("Speichere ...");
saveButton.setDisable(true);
workerExecutor.submit(() -> {
try {
port.saveAllChanges(configPath, changeSet);
Platform.runLater(() -> {
statusLabel.setText("Aenderungen gespeichert.");
reloadFromCurrentConfig();
});
} catch (RuntimeException ex) {
LOG.error("Modell-Preis-Speichern fehlgeschlagen: {}", ex.getMessage(), ex);
Platform.runLater(() -> {
statusLabel.setText("Fehler beim Speichern: " + ex.getMessage());
saveButton.setDisable(false);
});
}
});
}
private ModelPriceChangeSet buildChangeSet() {
List<ModelPriceEntry> upserts = new ArrayList<>();
Instant placeholder = Instant.now();
for (EditableEntry row : rows) {
if (!row.editable) {
continue;
}
if (!row.dirty && originalKeys.contains(
new ModelPriceKey(row.providerProperty.get(), row.modelNameProperty.get()))) {
continue;
}
try {
upserts.add(new ModelPriceEntry(
row.providerProperty.get(),
row.modelNameProperty.get(),
row.inputPriceNanoUsd,
row.outputPriceNanoUsd,
"USD",
placeholder));
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Eintrag (" + row.providerProperty.get()
+ ", " + row.modelNameProperty.get() + ") ungueltig: " + ex.getMessage());
}
}
return new ModelPriceChangeSet(upserts, List.copyOf(pendingDeletions));
}
private void showError(String message) {
Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK);
alert.setHeaderText("Eingabefehler");
alert.showAndWait();
}
/**
* Konvertiert eine $/1M-Tokens-Eingabe in Nano-USD/Token.
*
* <p>Akzeptiert Komma oder Punkt als Dezimaltrenner. Maximal sechs
* Nachkommastellen sind erlaubt; mehr fuehrt zur
* {@link IllegalArgumentException}. Negative Werte und Nicht-Numerisches
* werden ebenfalls abgewiesen.
*
* @param raw Eingabetext
* @return umgerechneter Nano-USD-Wert
* @throws IllegalArgumentException bei ungueltiger Eingabe
*/
static long parseUsdPerMillionToNano(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("Preis darf nicht leer sein.");
}
String normalized = raw.trim().replace(',', '.');
BigDecimal value;
try {
value = new BigDecimal(normalized);
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException("Preis ist nicht numerisch: " + raw);
}
if (value.signum() < 0) {
throw new IllegalArgumentException("Preis darf nicht negativ sein.");
}
if (value.scale() > 6) {
throw new IllegalArgumentException("Maximal 6 Nachkommastellen erlaubt.");
}
BigDecimal nanoPerToken = value.multiply(BigDecimal.valueOf(1000L))
.setScale(0, RoundingMode.HALF_UP);
long nanoLong = nanoPerToken.longValueExact();
if (nanoLong > ModelPriceEntry.MAX_PRICE_PER_TOKEN_NANO_USD) {
throw new IllegalArgumentException("Preis ueberschreitet Maximum.");
}
return nanoLong;
}
/**
* Formatiert einen Nano-USD-Wert als $/1M-Tokens-Text.
*
* @param nanoPerToken Nano-USD pro Token
* @return Formatierter Text mit bis zu sechs Nachkommastellen
*/
static String formatNanoAsUsdPerMillion(long nanoPerToken) {
BigDecimal usdPerMillion = BigDecimal.valueOf(nanoPerToken)
.multiply(NANO_TO_USD_PER_MILLION)
.divide(new BigDecimal("1000000000"), 6, RoundingMode.HALF_UP)
.stripTrailingZeros();
return usdPerMillion.toPlainString();
}
/**
* Mutable Tabellenzeile. Kapselt View-Felder als Properties und einen
* Dirty-Flag fuer den ChangeSet-Bau.
*/
private static final class EditableEntry {
final SimpleStringProperty providerProperty;
final SimpleStringProperty modelNameProperty;
final SimpleStringProperty inputPriceTextProperty;
final SimpleStringProperty outputPriceTextProperty;
final SimpleStringProperty currencyProperty;
final SimpleStringProperty updatedAtTextProperty;
final SimpleObjectProperty<Boolean> invalidUpdatedAtProperty;
long inputPriceNanoUsd;
long outputPriceNanoUsd;
boolean editable;
boolean dirty;
EditableEntry(String provider, String modelName,
long inputNano, long outputNano, String currency,
Instant updatedAt, boolean invalidUpdatedAt, boolean editable) {
this.providerProperty = new SimpleStringProperty(provider);
this.modelNameProperty = new SimpleStringProperty(modelName);
this.inputPriceNanoUsd = inputNano;
this.outputPriceNanoUsd = outputNano;
this.inputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(inputNano));
this.outputPriceTextProperty = new SimpleStringProperty(formatNanoAsUsdPerMillion(outputNano));
this.currencyProperty = new SimpleStringProperty(currency);
this.updatedAtTextProperty = new SimpleStringProperty(
invalidUpdatedAt ? "ungueltig"
: updatedAt == null ? "" : DateTimeFormatter.ISO_INSTANT.format(updatedAt));
this.invalidUpdatedAtProperty = new SimpleObjectProperty<>(invalidUpdatedAt);
this.editable = editable;
this.dirty = false;
}
static EditableEntry fromView(ModelPriceView view) {
boolean editable = SUPPORTED_PROVIDERS.contains(view.provider());
EditableEntry entry = new EditableEntry(
view.provider(), view.modelName(),
view.priceInputPerTokenNanoUsd(), view.priceOutputPerTokenNanoUsd(),
view.currency(), view.updatedAt(), view.invalidUpdatedAt(), editable);
return entry;
}
}
/**
* Editierbare Zelle fuer Input-/Output-Preisfelder.
*/
private final class PriceEditCell extends TableCell<EditableEntry, String> {
private final TextField textField = new TextField();
private final boolean isInputColumn;
PriceEditCell(boolean isInputColumn) {
this.isInputColumn = isInputColumn;
textField.focusedProperty().addListener((obs, was, focused) -> {
if (!focused) {
commit();
}
});
textField.setOnAction(e -> commit());
}
private void commit() {
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row == null || !row.editable) {
return;
}
String text = textField.getText();
try {
long nano = parseUsdPerMillionToNano(text);
if (isInputColumn) {
if (row.inputPriceNanoUsd != nano) {
row.inputPriceNanoUsd = nano;
row.dirty = true;
}
row.inputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
} else {
if (row.outputPriceNanoUsd != nano) {
row.outputPriceNanoUsd = nano;
row.dirty = true;
}
row.outputPriceTextProperty.set(formatNanoAsUsdPerMillion(nano));
}
} catch (IllegalArgumentException ex) {
showError(ex.getMessage());
String revert = isInputColumn
? formatNanoAsUsdPerMillion(row.inputPriceNanoUsd)
: formatNanoAsUsdPerMillion(row.outputPriceNanoUsd);
textField.setText(revert);
}
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setText(null);
setGraphic(null);
return;
}
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row != null && !row.editable) {
setText(item);
setGraphic(null);
setTooltip(new Tooltip("Unbekannter Provider Bearbeitung in V3.3 nicht unterstuetzt."));
return;
}
textField.setText(item);
setText(null);
setGraphic(textField);
}
}
/**
* Loesch-Button-Spalte mit Bestaetigungsdialog.
*/
private final class DeleteButtonCell extends TableCell<EditableEntry, Void> {
private final Button button = new Button("Loeschen");
DeleteButtonCell() {
button.setOnAction(e -> {
EditableEntry row = getTableRow() != null ? getTableRow().getItem() : null;
if (row == null) {
return;
}
String message = String.format(Locale.GERMAN,
"Eintrag fuer Provider \"%s\" und Modell \"%s\" wirklich loeschen?",
row.providerProperty.get(), row.modelNameProperty.get());
Alert alert = new Alert(Alert.AlertType.CONFIRMATION, message, ButtonType.OK, ButtonType.CANCEL);
alert.setHeaderText("Loeschen bestaetigen");
alert.showAndWait().ifPresent(button -> {
if (button == ButtonType.OK) {
ModelPriceKey key = new ModelPriceKey(
row.providerProperty.get(), row.modelNameProperty.get());
if (originalKeys.contains(key)) {
pendingDeletions.add(key);
}
rows.remove(row);
}
});
});
}
@Override
protected void updateItem(Void item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setGraphic(null);
} else {
setGraphic(button);
}
}
}
}
@@ -0,0 +1,9 @@
/**
* GUI-Bestandteile fuer die Verwaltung der persistierten Modell-Preise.
*
* <p>Enthaelt den Bridge-Port {@link de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices
* .GuiModelPriceManagementPort} und den zugehoerigen Tab. Der Port wird von
* Bootstrap mit einer Lambda-Implementierung gefuellt, die anhand der aktuell
* geladenen Konfigurationsdatei eine SQLite-Repository-Instanz aufbaut.
*/
package de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices;
@@ -244,8 +244,9 @@ class GuiAdapterSmokeTest {
"The 'Speichern' button must be visible");
assertEquals("Speichern unter", workspace.saveAsButton().getText(),
"The 'Speichern unter' button must be visible");
assertEquals(5, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab, scheduler tab, history tab and prompt editor tab must all be present");
assertEquals(6, workspace.tabPane().getTabs().size(),
"Configuration tab, processing-run tab, scheduler tab, history tab, "
+ "prompt editor tab and model-prices tab must all be present");
assertEquals("Konfiguration", workspace.tabPane().getTabs().get(0).getText(),
"The first tab must use the configuration label");
assertEquals("Verarbeitungslauf", workspace.tabPane().getTabs().get(1).getText(),
@@ -256,6 +257,8 @@ class GuiAdapterSmokeTest {
"The fourth tab must host the history view");
assertEquals("Prompt", workspace.tabPane().getTabs().get(4).getText(),
"The fifth tab must host the prompt editor");
assertEquals("Modell-Preise", workspace.tabPane().getTabs().get(5).getText(),
"The sixth tab must host the model prices view");
assertEquals(
"Pfade,Provider,Verarbeitungslimits,Tests,Meldungen",
String.join(",", workspace.sectionTitles()),