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:
+106
-1
@@ -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ß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<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
|
||||
|
||||
+6
-3
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+141
-4
@@ -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.
|
||||
*
|
||||
|
||||
+78
-1
@@ -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 "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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
+53
@@ -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);
|
||||
}
|
||||
+562
@@ -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ß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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
+5
-2
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user