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()),
|
||||
|
||||
+61
-1
@@ -14,6 +14,7 @@ import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
|
||||
@@ -356,7 +357,8 @@ public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
|
||||
"Anthropic response contained no text-type content blocks");
|
||||
}
|
||||
|
||||
return new AiInvocationSuccess(request, new AiRawResponse(extractedText));
|
||||
return new AiInvocationSuccess(request, new AiRawResponse(extractedText),
|
||||
extractTokenUsageFromResponse(json));
|
||||
} catch (JSONException e) {
|
||||
LOG.warn("Claude AI response could not be parsed as JSON: {}", e.getMessage());
|
||||
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
|
||||
@@ -364,6 +366,64 @@ public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Token-Verbrauchsmetadaten aus der Anthropic-Response.
|
||||
*
|
||||
* <p>Anthropic Messages API liefert im Top-Level-Feld {@code usage}:
|
||||
* {@code input_tokens}, {@code output_tokens},
|
||||
* {@code cache_creation_input_tokens}, {@code cache_read_input_tokens}.
|
||||
*
|
||||
* <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
|
||||
* Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
|
||||
*
|
||||
* @param root die geparste Anthropic-Response
|
||||
* @return befuelltes {@link AiUsageMetadata}; nie {@code null}
|
||||
*/
|
||||
private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
|
||||
JSONObject usage = root.optJSONObject("usage");
|
||||
if (usage == null) {
|
||||
LOG.warn("Anthropic-Response enthielt kein usage-Feld – Token-Daten werden nicht erfasst");
|
||||
return AiUsageMetadata.empty();
|
||||
}
|
||||
Long inputTokens = readTokenField(usage, "input_tokens");
|
||||
Long outputTokens = readTokenField(usage, "output_tokens");
|
||||
Long cacheCreation = readTokenField(usage, "cache_creation_input_tokens");
|
||||
Long cacheRead = readTokenField(usage, "cache_read_input_tokens");
|
||||
return new AiUsageMetadata(inputTokens, outputTokens, cacheCreation, cacheRead);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
|
||||
*
|
||||
* <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
|
||||
* Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
|
||||
* mit WARN-Log und gibt {@code null} zurueck.
|
||||
*
|
||||
* @param usage das JSON-Objekt mit den Token-Feldern
|
||||
* @param fieldName Name des Feldes
|
||||
* @return validierter Token-Wert oder {@code null}
|
||||
*/
|
||||
private Long readTokenField(JSONObject usage, String fieldName) {
|
||||
if (!usage.has(fieldName) || usage.isNull(fieldName)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
long value = usage.getLong(fieldName);
|
||||
if (value < 0L) {
|
||||
LOG.warn("Anthropic-Token-Feld {} ist negativ ({}) – Wert verworfen", fieldName, value);
|
||||
return null;
|
||||
}
|
||||
if (value > 10_000_000L) {
|
||||
LOG.warn("Anthropic-Token-Feld {} uebersteigt Maximum (10 Mio.): {} – Wert verworfen", fieldName, value);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
} catch (JSONException e) {
|
||||
LOG.warn("Anthropic-Token-Feld {} ist nicht numerisch – Wert verworfen: {}", fieldName, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-private accessor for the last constructed JSON body.
|
||||
* <p>
|
||||
|
||||
+60
-1
@@ -14,6 +14,7 @@ import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
|
||||
@@ -268,7 +269,8 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
|
||||
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
|
||||
"OpenAI response message.content is absent or blank");
|
||||
}
|
||||
return new AiInvocationSuccess(request, new AiRawResponse(content));
|
||||
return new AiInvocationSuccess(request, new AiRawResponse(content),
|
||||
extractTokenUsageFromResponse(json));
|
||||
} catch (JSONException e) {
|
||||
LOG.warn("OpenAI response could not be parsed as JSON: {}", e.getMessage());
|
||||
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
|
||||
@@ -276,6 +278,63 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert Token-Verbrauchsmetadaten aus der OpenAI-Response.
|
||||
*
|
||||
* <p>Mapping: {@code usage.prompt_tokens -> inputTokens},
|
||||
* {@code usage.completion_tokens -> outputTokens}. Cache-Felder sind in der
|
||||
* OpenAI-kompatiblen Schnittstelle nicht standardisiert und bleiben immer
|
||||
* {@code null}.
|
||||
*
|
||||
* <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
|
||||
* Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
|
||||
*
|
||||
* @param root die geparste OpenAI-Response (Envelope)
|
||||
* @return befuelltes {@link AiUsageMetadata}; nie {@code null}
|
||||
*/
|
||||
private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
|
||||
JSONObject usage = root.optJSONObject("usage");
|
||||
if (usage == null) {
|
||||
LOG.warn("OpenAI-Response enthielt kein usage-Feld – Token-Daten werden nicht erfasst");
|
||||
return AiUsageMetadata.empty();
|
||||
}
|
||||
Long inputTokens = readTokenField(usage, "prompt_tokens");
|
||||
Long outputTokens = readTokenField(usage, "completion_tokens");
|
||||
return new AiUsageMetadata(inputTokens, outputTokens, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
|
||||
*
|
||||
* <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
|
||||
* Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
|
||||
* mit WARN-Log und gibt {@code null} zurueck.
|
||||
*
|
||||
* @param usage das JSON-Objekt mit den Token-Feldern
|
||||
* @param fieldName Name des Feldes
|
||||
* @return validierter Token-Wert oder {@code null}
|
||||
*/
|
||||
private Long readTokenField(JSONObject usage, String fieldName) {
|
||||
if (!usage.has(fieldName) || usage.isNull(fieldName)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
long value = usage.getLong(fieldName);
|
||||
if (value < 0L) {
|
||||
LOG.warn("OpenAI-Token-Feld {} ist negativ ({}) – Wert verworfen", fieldName, value);
|
||||
return null;
|
||||
}
|
||||
if (value > 10_000_000L) {
|
||||
LOG.warn("OpenAI-Token-Feld {} uebersteigt Maximum (10 Mio.): {} – Wert verworfen", fieldName, value);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
} catch (JSONException e) {
|
||||
LOG.warn("OpenAI-Token-Feld {} ist nicht numerisch – Wert verworfen: {}", fieldName, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an OpenAI Chat Completions API request from the request representation.
|
||||
* <p>
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
/**
|
||||
* Technischer Fehler im SQLite-Adapter fuer Modell-Preise.
|
||||
*
|
||||
* <p>Wird vom {@link SqliteModelPriceRepositoryAdapter} geworfen, wenn ein
|
||||
* JDBC-Fehler beim Lesen, Schreiben oder Loeschen aufgetreten ist. Die
|
||||
* Application-Schicht und die GUI behandeln diese Exception als
|
||||
* technischen Fehler mit deutscher Meldung.
|
||||
*/
|
||||
public class ModelPriceRepositoryException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* Erzeugt eine neue Ausnahme mit Meldung und Ursache.
|
||||
*
|
||||
* @param message deutsche Meldung
|
||||
* @param cause urspruenglicher Fehler
|
||||
*/
|
||||
public ModelPriceRepositoryException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
/**
|
||||
* Zentrale Factory fuer SQLite-Connections.
|
||||
*
|
||||
* <p>Wird von allen Repository-Adaptern und vom UnitOfWork-Adapter genutzt,
|
||||
* um Connections mit einheitlichen PRAGMA-Einstellungen zu oeffnen. Damit
|
||||
* sind WAL-Modus, {@code busy_timeout} und {@code foreign_keys} fuer alle
|
||||
* Schreib- und Lesepfade wirksam.
|
||||
*
|
||||
* <p>Folgende PRAGMAs werden auf jeder Connection gesetzt:
|
||||
* <ul>
|
||||
* <li>{@code PRAGMA journal_mode=WAL} – Reader werden nicht durch Writer blockiert.</li>
|
||||
* <li>{@code PRAGMA busy_timeout=5000} – wartet bis zu 5 Sekunden, bevor
|
||||
* {@code SQLITE_BUSY} geworfen wird.</li>
|
||||
* <li>{@code PRAGMA foreign_keys=ON} – aktiviert die Pruefung von Fremdschluesseln
|
||||
* (entspricht dem bestehenden Verhalten der bisherigen Adapter).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Die Factory ist stateless; die Methode {@link #open(String)} liefert
|
||||
* jeweils eine frische {@link Connection}, die der Aufrufer (typisch via
|
||||
* try-with-resources) wieder schließt.
|
||||
*/
|
||||
public final class SqliteConnectionFactory {
|
||||
|
||||
private SqliteConnectionFactory() {
|
||||
// Utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnet eine neue SQLite-Connection und setzt die Standard-PRAGMAs.
|
||||
*
|
||||
* @param jdbcUrl JDBC-URL zur SQLite-Datenbank, z.B. {@code jdbc:sqlite:/pfad/db.sqlite}
|
||||
* @return eine neue, eingerichtete {@link Connection}
|
||||
* @throws SQLException wenn die Verbindung nicht hergestellt oder die
|
||||
* PRAGMAs nicht gesetzt werden koennen
|
||||
*/
|
||||
public static Connection open(String jdbcUrl) throws SQLException {
|
||||
Connection connection = DriverManager.getConnection(jdbcUrl);
|
||||
try {
|
||||
applyDefaultPragmas(connection);
|
||||
} catch (SQLException ex) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException ignored) {
|
||||
// Schliessfehler maskieren den eigentlichen Setup-Fehler nicht
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Standard-PRAGMAs auf einer bereits geoeffneten Connection.
|
||||
*
|
||||
* <p>Die Foreign-Key-Pruefung wird hier <strong>nicht</strong> aktiviert,
|
||||
* um das bisher faktisch praktizierte Verhalten von Repository-Connections
|
||||
* zu erhalten. Die Foreign-Key-Pruefung wird durch
|
||||
* {@code SqliteSchemaInitializationAdapter} auf der zentralen DataSource
|
||||
* fuer Schema-Operationen explizit aktiviert; einzelne Repository-
|
||||
* Connections, die ueber diese Factory geoeffnet werden, behalten das
|
||||
* bisherige Verhalten der direkten {@code DriverManager.getConnection}-
|
||||
* Aufrufe und setzen Foreign-Keys nicht implizit.
|
||||
*
|
||||
* @param connection bestehende Connection; nicht {@code null}
|
||||
* @throws SQLException wenn ein PRAGMA-Statement fehlschlaegt
|
||||
*/
|
||||
private static void applyDefaultPragmas(Connection connection) throws SQLException {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
// WAL: Reader werden nicht durch Writer blockiert.
|
||||
statement.execute("PRAGMA journal_mode=WAL");
|
||||
// 5 Sekunden Wartezeit pro Connection bei SQLITE_BUSY.
|
||||
statement.execute("PRAGMA busy_timeout=5000");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;
|
||||
|
||||
/**
|
||||
* SQLite-Implementierung des {@link ModelPriceRepository}.
|
||||
*
|
||||
* <p>Persistiert Modell-Preise in der Tabelle {@code model_price}. Inserts und
|
||||
* Updates erfolgen via {@code INSERT ... ON CONFLICT(provider, model_name)
|
||||
* DO UPDATE SET ...}; Loeschungen via direktes {@code DELETE}. Der
|
||||
* {@link #saveAllChanges(ModelPriceChangeSet) Batch-Pfad} faehrt eine
|
||||
* JDBC-Transaktion mit {@code autoCommit=false}, ROLLBACKt bei Fehlern und
|
||||
* COMMITet bei Erfolg.
|
||||
*
|
||||
* <p>Beim Lesen wird der DB-String {@code updated_at} als {@link Instant}
|
||||
* geparst. Schlaegt das Parsing fehl, liefert die Adapter-Methode einen
|
||||
* {@link ModelPriceView} mit {@code updatedAt=null} und
|
||||
* {@code invalidUpdatedAt=true}; der Originalstring landet in
|
||||
* {@code invalidUpdatedAtRaw}, damit GUI/CLI "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<ModelPriceView> findAll() {
|
||||
List<ModelPriceView> result = new ArrayList<>();
|
||||
try (Connection connection = getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(SQL_FIND_ALL);
|
||||
ResultSet rs = statement.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(mapRow(rs));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new ModelPriceRepositoryException(
|
||||
"Modell-Preise konnten nicht gelesen werden: " + e.getMessage(), e);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName) {
|
||||
Objects.requireNonNull(provider, "provider");
|
||||
Objects.requireNonNull(modelName, "modelName");
|
||||
try (Connection connection = getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(SQL_FIND_BY_KEY)) {
|
||||
statement.setString(1, provider);
|
||||
statement.setString(2, modelName);
|
||||
try (ResultSet rs = statement.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return Optional.of(mapRow(rs));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new ModelPriceRepositoryException(
|
||||
"Modell-Preis-Lookup fehlgeschlagen fuer (" + provider + ", " + modelName
|
||||
+ "): " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void upsert(ModelPriceEntry entry) {
|
||||
Objects.requireNonNull(entry, "entry");
|
||||
try (Connection connection = getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(SQL_UPSERT)) {
|
||||
bindUpsert(statement, entry);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new ModelPriceRepositoryException(
|
||||
"Modell-Preis-Upsert fehlgeschlagen: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String provider, String modelName) {
|
||||
Objects.requireNonNull(provider, "provider");
|
||||
Objects.requireNonNull(modelName, "modelName");
|
||||
try (Connection connection = getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(SQL_DELETE)) {
|
||||
statement.setString(1, provider);
|
||||
statement.setString(2, modelName);
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new ModelPriceRepositoryException(
|
||||
"Modell-Preis-Delete fehlgeschlagen: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAllChanges(ModelPriceChangeSet changeSet) {
|
||||
Objects.requireNonNull(changeSet, "changeSet");
|
||||
if (changeSet.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try (Connection connection = getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
if (!changeSet.upserts().isEmpty()) {
|
||||
try (PreparedStatement upsertStmt = connection.prepareStatement(SQL_UPSERT)) {
|
||||
for (ModelPriceEntry entry : changeSet.upserts()) {
|
||||
bindUpsert(upsertStmt, entry);
|
||||
upsertStmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changeSet.deletions().isEmpty()) {
|
||||
try (PreparedStatement deleteStmt = connection.prepareStatement(SQL_DELETE)) {
|
||||
for (ModelPriceKey key : changeSet.deletions()) {
|
||||
deleteStmt.setString(1, key.provider());
|
||||
deleteStmt.setString(2, key.modelName());
|
||||
deleteStmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.commit();
|
||||
} catch (SQLException txError) {
|
||||
try {
|
||||
connection.rollback();
|
||||
} catch (SQLException rollbackError) {
|
||||
LOG.error("Rollback nach Modell-Preis-Batch-Fehler ebenfalls fehlgeschlagen: {}",
|
||||
rollbackError.getMessage(), rollbackError);
|
||||
}
|
||||
throw new ModelPriceRepositoryException(
|
||||
"Modell-Preis-Batch konnte nicht persistiert werden: " + txError.getMessage(),
|
||||
txError);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new ModelPriceRepositoryException(
|
||||
"Datenbankverbindung fuer Modell-Preis-Batch fehlgeschlagen: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void bindUpsert(PreparedStatement statement, ModelPriceEntry entry) throws SQLException {
|
||||
statement.setString(1, entry.provider());
|
||||
statement.setString(2, entry.modelName());
|
||||
statement.setLong(3, entry.priceInputPerTokenNanoUsd());
|
||||
statement.setLong(4, entry.priceOutputPerTokenNanoUsd());
|
||||
statement.setString(5, entry.currency());
|
||||
statement.setString(6, entry.updatedAt().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine Zeile in einen {@link ModelPriceView}.
|
||||
*
|
||||
* <p>Bei nicht parsebarem {@code updated_at} wird ein WARN-Log erzeugt
|
||||
* und der View mit {@code updatedAt=null}, {@code invalidUpdatedAt=true}
|
||||
* sowie dem Originalstring zurueckgegeben.
|
||||
*
|
||||
* @param rs aktueller ResultSet, dessen Cursor auf der Zielzeile steht
|
||||
* @return Lesen-DTO
|
||||
* @throws SQLException bei JDBC-Fehlern
|
||||
*/
|
||||
private static ModelPriceView mapRow(ResultSet rs) throws SQLException {
|
||||
String provider = rs.getString("provider");
|
||||
String modelName = rs.getString("model_name");
|
||||
long priceIn = rs.getLong("price_input_per_token_nano_usd");
|
||||
long priceOut = rs.getLong("price_output_per_token_nano_usd");
|
||||
String currency = rs.getString("currency");
|
||||
String updatedAtRaw = rs.getString("updated_at");
|
||||
|
||||
Instant updatedAt = null;
|
||||
boolean invalid = false;
|
||||
String invalidRaw = null;
|
||||
try {
|
||||
updatedAt = Instant.parse(updatedAtRaw);
|
||||
} catch (DateTimeParseException ex) {
|
||||
LOG.warn("updated_at konnte fuer (Provider={}, Modell={}) nicht geparst werden: \"{}\"",
|
||||
provider, modelName, updatedAtRaw);
|
||||
invalid = true;
|
||||
invalidRaw = updatedAtRaw;
|
||||
} catch (NullPointerException ex) {
|
||||
LOG.warn("updated_at war fuer (Provider={}, Modell={}) NULL – als ungueltig markiert",
|
||||
provider, modelName);
|
||||
invalid = true;
|
||||
invalidRaw = null;
|
||||
}
|
||||
|
||||
return new ModelPriceView(provider, modelName, priceIn, priceOut, currency, updatedAt,
|
||||
invalidRaw, invalid);
|
||||
}
|
||||
}
|
||||
+33
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -82,7 +82,18 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
||||
"last_target_path", "last_target_file_name"
|
||||
);
|
||||
|
||||
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
|
||||
/**
|
||||
* Alle erwarteten Spalten der Tabelle {@code processing_attempt} im
|
||||
* V1-Zielschema.
|
||||
*
|
||||
* <p>Dies ist der minimale Zielzustand nach {@code V1__initial_schema}.
|
||||
* Spaetere Migrationen (z.B. {@code V2__token_tracking}) ergaenzen
|
||||
* additiv weitere Spalten; das Vorhandensein dieser zusaetzlichen Spalten
|
||||
* vor dem Baseline-Eintrag ist <strong>kein</strong> Konformitaetskriterium,
|
||||
* weil die Schema-Pruefung in Fall 2 ausschließlich gegen das
|
||||
* V1-Schema arbeitet. Die V2-Spalten werden nach der Baseline-Eintragung
|
||||
* durch Flyway ergaenzt.
|
||||
*/
|
||||
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
|
||||
"id", COL_FINGERPRINT, "run_id", "attempt_number", "started_at", "ended_at",
|
||||
"status", "failure_class", "failure_message", "retryable",
|
||||
@@ -91,7 +102,12 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
|
||||
"validated_title", "final_target_file_name", "ai_provider"
|
||||
);
|
||||
|
||||
/** Erwartete Indizes. */
|
||||
/**
|
||||
* Erwartete Indizes nach {@code V1__initial_schema}.
|
||||
*
|
||||
* <p>Spaetere Migrationen koennen additiv weitere Indizes anlegen; sie
|
||||
* sind kein Konformitaetskriterium fuer Fall 2.
|
||||
*/
|
||||
private static final Set<String> EXPECTED_INDEXES = Set.of(
|
||||
"idx_processing_attempt_fingerprint",
|
||||
"idx_processing_attempt_run_id",
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
@@ -43,7 +42,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
|
||||
public void executeInTransaction(Consumer<TransactionOperations> operations) {
|
||||
Objects.requireNonNull(operations, "operations must not be null");
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
|
||||
try (Connection connection = SqliteConnectionFactory.open(jdbcUrl)) {
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- V2: Token-Erfassung mit Preis-Snapshot in processing_attempt;
|
||||
-- neue model_price-Tabelle mit Composite Primary Key.
|
||||
-- Verifizierter Stand: V1__initial_schema.sql ist die einzige bisherige
|
||||
-- Migration im Projekt.
|
||||
|
||||
ALTER TABLE processing_attempt
|
||||
ADD COLUMN input_tokens INTEGER
|
||||
CHECK (input_tokens IS NULL OR (input_tokens >= 0 AND input_tokens <= 10000000));
|
||||
|
||||
ALTER TABLE processing_attempt
|
||||
ADD COLUMN output_tokens INTEGER
|
||||
CHECK (output_tokens IS NULL OR (output_tokens >= 0 AND output_tokens <= 10000000));
|
||||
|
||||
ALTER TABLE processing_attempt
|
||||
ADD COLUMN cache_creation_input_tokens INTEGER
|
||||
CHECK (cache_creation_input_tokens IS NULL OR (cache_creation_input_tokens >= 0 AND cache_creation_input_tokens <= 10000000));
|
||||
|
||||
ALTER TABLE processing_attempt
|
||||
ADD COLUMN cache_read_input_tokens INTEGER
|
||||
CHECK (cache_read_input_tokens IS NULL OR (cache_read_input_tokens >= 0 AND cache_read_input_tokens <= 10000000));
|
||||
|
||||
ALTER TABLE processing_attempt
|
||||
ADD COLUMN price_input_per_token_nano_usd INTEGER
|
||||
CHECK (price_input_per_token_nano_usd IS NULL OR (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000));
|
||||
|
||||
ALTER TABLE processing_attempt
|
||||
ADD COLUMN price_output_per_token_nano_usd INTEGER
|
||||
CHECK (price_output_per_token_nano_usd IS NULL OR (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000));
|
||||
|
||||
CREATE TABLE model_price (
|
||||
provider TEXT NOT NULL,
|
||||
model_name TEXT NOT NULL,
|
||||
price_input_per_token_nano_usd INTEGER NOT NULL CHECK (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000),
|
||||
price_output_per_token_nano_usd INTEGER NOT NULL CHECK (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000),
|
||||
currency TEXT NOT NULL DEFAULT 'USD' CHECK (currency = 'USD'),
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, model_name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_processing_attempt_started_at_provider_fp_model
|
||||
ON processing_attempt (started_at, ai_provider, fingerprint, model_name);
|
||||
|
||||
CREATE INDEX idx_processing_attempt_run_id_provider_model
|
||||
ON processing_attempt (run_id, ai_provider, model_name);
|
||||
|
||||
-- Default-Preise (Stand 2026-05-08, in Nano-USD pro Token)
|
||||
-- Quellen (abgerufen 2026-05-08):
|
||||
-- OpenAI: https://openai.com/api/pricing/
|
||||
-- Anthropic: https://www.anthropic.com/pricing
|
||||
-- ON CONFLICT DO NOTHING: schuetzt vor manuell vorhandenen Default-Zeilen.
|
||||
INSERT INTO model_price
|
||||
(provider, model_name, price_input_per_token_nano_usd, price_output_per_token_nano_usd, currency, updated_at)
|
||||
VALUES
|
||||
('openai-compatible', 'gpt-4o-mini', 150, 600, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('openai-compatible', 'gpt-4o', 2500, 10000, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('openai-compatible', 'gpt-4.1', 2000, 8000, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('openai-compatible', 'gpt-4.1-mini', 400, 1600, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('openai-compatible', 'gpt-4.1-nano', 100, 400, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('openai-compatible', 'gpt-5', 1250, 10000, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('openai-compatible', 'gpt-5-mini', 250, 2000, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('claude', 'claude-haiku-4-5-20251001', 1000, 5000, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('claude', 'claude-sonnet-4-6', 3000, 15000, 'USD', '2026-05-08T00:00:00Z'),
|
||||
('claude', 'claude-opus-4-7', 5000, 25000, 'USD', '2026-05-08T00:00:00Z')
|
||||
ON CONFLICT (provider, model_name) DO NOTHING;
|
||||
+5
-1
@@ -88,7 +88,11 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
"status", "failure_class", "failure_message", "retryable",
|
||||
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
|
||||
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
|
||||
"validated_title", "final_target_file_name", "ai_provider"
|
||||
"validated_title", "final_target_file_name", "ai_provider",
|
||||
// Token- und Preis-Spalten ergaenzt durch V2__token_tracking
|
||||
"input_tokens", "output_tokens",
|
||||
"cache_creation_input_tokens", "cache_read_input_tokens",
|
||||
"price_input_per_token_nano_usd", "price_output_per_token_nano_usd"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.cost;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Interpretiert aggregierte Token-Rohkosten in {@link BigDecimal}-USD und
|
||||
* bestimmt die Status-Flags des {@link CostResult}.
|
||||
*
|
||||
* <p>Der CostCalculator fuehrt selbst <strong>keine</strong> Multiplikation
|
||||
* Tokens×Preis durch. Multiplikation findet im SQL-Adapter statt
|
||||
* (Pro-Attempt-Snapshot); der CostCalculator interpretiert nur die bereits
|
||||
* aufsummierten Roh-Werte und uebersetzt sie in einen anzeigetauglichen
|
||||
* Betrag.
|
||||
*
|
||||
* <p>Es findet <strong>keine</strong> interne Rundung statt; Rundung
|
||||
* auf vier Nachkommastellen erfolgt ausschließ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.
|
||||
*
|
||||
* <p>Verwendet {@code long}-Pro-Group-Aggregation aus SQL.
|
||||
*
|
||||
* @param sumInputCostNanoUsd Summe der Input-Kosten in Nano-USD; {@code null} wenn keiner
|
||||
* @param sumOutputCostNanoUsd Summe der Output-Kosten in Nano-USD; {@code null} wenn keiner
|
||||
* @param hasPartialTokenData mindestens ein Token-Feld fehlte
|
||||
* @param hasMissingPriceSnapshot mindestens ein Preis-Snapshot fehlte
|
||||
* @param hasCacheTokensIgnored Cache-Tokens kamen vor
|
||||
* @param hasAnyTokenData es lagen ueberhaupt Token-Daten vor
|
||||
* @return interpretiertes Kosten-Ergebnis
|
||||
*/
|
||||
public CostResult formatRow(
|
||||
Long sumInputCostNanoUsd,
|
||||
Long sumOutputCostNanoUsd,
|
||||
boolean hasPartialTokenData,
|
||||
boolean hasMissingPriceSnapshot,
|
||||
boolean hasCacheTokensIgnored,
|
||||
boolean hasAnyTokenData) {
|
||||
BigInteger sumInput = sumInputCostNanoUsd == null ? null : BigInteger.valueOf(sumInputCostNanoUsd);
|
||||
BigInteger sumOutput = sumOutputCostNanoUsd == null ? null : BigInteger.valueOf(sumOutputCostNanoUsd);
|
||||
boolean hasAnyInputCost = sumInput != null;
|
||||
boolean hasAnyOutputCost = sumOutput != null;
|
||||
return interpret(sumInput, sumOutput, hasAnyInputCost, hasAnyOutputCost,
|
||||
hasAnyTokenData, hasPartialTokenData, hasMissingPriceSnapshot, hasCacheTokensIgnored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpretation aufsummierter BigInteger-Aggregate fuer Kopfzeilen und
|
||||
* Run-Summary-Banner.
|
||||
*
|
||||
* <p>Wird in V3.3 in einem spaeteren Arbeitspaket vollstaendig implementiert
|
||||
* (AP-B). Fuer AP-A genuegt diese Stub-Methode, die mit
|
||||
* {@link UnsupportedOperationException} fehlt, sobald sie aufgerufen wird.
|
||||
*
|
||||
* @param totalInputCostNanoUsd aufaddierte Input-Kosten in Nano-USD oder {@code null}
|
||||
* @param totalOutputCostNanoUsd aufaddierte Output-Kosten in Nano-USD oder {@code null}
|
||||
* @param hasAnyInputCost Flag, ob ueberhaupt ein berechenbarer Input-Kostenanteil vorlag
|
||||
* @param hasAnyOutputCost Flag, ob ueberhaupt ein berechenbarer Output-Kostenanteil vorlag
|
||||
* @param hasAnyTokenData Flag, ob ueberhaupt Token-Daten vorlagen
|
||||
* @param hasPartialTokenData Flag, ob mindestens ein Token-Feld fehlte
|
||||
* @param hasMissingPriceSnapshot Flag, ob mindestens ein Preis-Snapshot fehlte
|
||||
* @param hasCacheTokensIgnored Flag, ob Cache-Tokens vorkamen
|
||||
* @return interpretiertes Kosten-Ergebnis
|
||||
*/
|
||||
public CostResult formatTotal(
|
||||
BigInteger totalInputCostNanoUsd,
|
||||
BigInteger totalOutputCostNanoUsd,
|
||||
boolean hasAnyInputCost,
|
||||
boolean hasAnyOutputCost,
|
||||
boolean hasAnyTokenData,
|
||||
boolean hasPartialTokenData,
|
||||
boolean hasMissingPriceSnapshot,
|
||||
boolean hasCacheTokensIgnored) {
|
||||
throw new UnsupportedOperationException("formatTotal wird in einem spaeteren Arbeitspaket implementiert");
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpretation eines einzelnen Versuchs (z.B. fuer den History-Tab).
|
||||
*
|
||||
* <p>Multipliziert Tokens×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);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.cost;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* Interpretiertes Kosten-Ergebnis zu einer Tabellenzeile, einer Kopfzeile oder
|
||||
* einem einzelnen Versuch.
|
||||
*
|
||||
* <p>Der {@code CostCalculator} fuellt diese Struktur. Die GUI nutzt die
|
||||
* Boolean-Flags fuer das Anzeige-Mapping (Tabellenwert, Tooltip,
|
||||
* Banner-Beitrag) und formatiert {@link #amountUsd()} schließlich auf vier
|
||||
* Nachkommastellen.
|
||||
*
|
||||
* <p>Flag-Semantik:
|
||||
* <ul>
|
||||
* <li>{@link #exact()} – alle Token-Werte und alle Preise vorhanden,
|
||||
* Cache-Tokens spielten keine Rolle.</li>
|
||||
* <li>{@link #partialTokens()} – mindestens ein Token-Wert fehlte
|
||||
* (z.B. Input-Tokens null, Output-Tokens vorhanden); berechneter
|
||||
* Wert ist eine Untergrenze.</li>
|
||||
* <li>{@link #missingPriceSnapshot()} – mindestens ein Versuch ohne
|
||||
* Preis-Snapshot floss ein.</li>
|
||||
* <li>{@link #noTokens()} – keinerlei Token-Daten erfasst.</li>
|
||||
* <li>{@link #cacheTokensIgnored()} – Cache-Tokens lagen vor, sind in
|
||||
* V3.3 jedoch nicht in {@link #amountUsd()} enthalten.</li>
|
||||
* <li>{@link #hasAnyCalculatedCost()} – mindestens ein berechneter
|
||||
* Anteil floss in {@link #amountUsd()} ein. Wichtig zur Unter­
|
||||
* scheidung von "keine Werte" und "Mikrobetrag".</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param amountUsd Gesamtbetrag in USD; {@code null} wenn nichts berechenbar war
|
||||
* @param exact {@code true}, wenn alle Daten vollstaendig waren
|
||||
* @param partialTokens {@code true}, wenn mindestens ein Token-Wert fehlte
|
||||
* @param missingPriceSnapshot {@code true}, wenn mindestens ein Preis-Snapshot fehlte
|
||||
* @param noTokens {@code true}, wenn keinerlei Token-Daten vorlagen
|
||||
* @param cacheTokensIgnored {@code true}, wenn Cache-Tokens vorkamen (in V3.3 nicht eingerechnet)
|
||||
* @param hasAnyCalculatedCost {@code true}, wenn ein berechneter Anteil enthalten ist
|
||||
*/
|
||||
public record CostResult(
|
||||
BigDecimal amountUsd,
|
||||
boolean exact,
|
||||
boolean partialTokens,
|
||||
boolean missingPriceSnapshot,
|
||||
boolean noTokens,
|
||||
boolean cacheTokensIgnored,
|
||||
boolean hasAnyCalculatedCost) {
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Application-Komponenten fuer die Interpretation aggregierter Token-Kosten.
|
||||
*
|
||||
* <p>Enthaelt den {@code CostCalculator}, der Long-/BigInteger-Aggregate aus
|
||||
* der Persistenzschicht in {@link java.math.BigDecimal}-USD ueberfuehrt und die
|
||||
* Statusflags des {@code CostResult} bestimmt. Die GUI ist allein fuer die
|
||||
* Endformatierung (Locale, Tilde, "< $0.0001") zustaendig.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.application.cost;
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.dto;
|
||||
|
||||
/**
|
||||
* Token-Verbrauchsmetadaten eines erfolgreichen KI-Aufrufs.
|
||||
*
|
||||
* <p>Ein KI-Adapter befuellt dieses DTO mit den vom Provider zurueckgelieferten
|
||||
* Token-Zaehlungen. Alle Felder sind nullable: nicht alle Provider liefern
|
||||
* Cache-Tokens, und einzelne Felder koennen vom Adapter wegen ungueltiger
|
||||
* Werte (negativ, > 10 Mio., nicht-numerisch) auf {@code null} gesetzt werden.
|
||||
*
|
||||
* <p>Die Application-Schicht verwendet dieses DTO ohne JDBC- oder Domain-Bezug;
|
||||
* fuer die Persistenz werden die Werte vom {@code BatchRunProcessingUseCase}
|
||||
* direkt an den {@code ProcessingAttempt}-Schreibpfad weitergereicht.
|
||||
*
|
||||
* @param inputTokens Anzahl Standard-Input-Tokens; {@code null} wenn nicht ermittelbar
|
||||
* @param outputTokens Anzahl Standard-Output-Tokens; {@code null} wenn nicht ermittelbar
|
||||
* @param cacheCreationInputTokens Anzahl Cache-Schreib-Tokens (nur Anthropic); {@code null} bei OpenAI-Adapter oder wenn nicht ermittelbar
|
||||
* @param cacheReadInputTokens Anzahl Cache-Lese-Tokens (nur Anthropic); {@code null} bei OpenAI-Adapter oder wenn nicht ermittelbar
|
||||
*/
|
||||
public record AiUsageMetadata(
|
||||
Long inputTokens,
|
||||
Long outputTokens,
|
||||
Long cacheCreationInputTokens,
|
||||
Long cacheReadInputTokens) {
|
||||
|
||||
/**
|
||||
* Liefert eine leere Instanz ohne jegliche Token-Daten.
|
||||
*
|
||||
* @return AiUsageMetadata mit allen Feldern auf {@code null}
|
||||
*/
|
||||
public static AiUsageMetadata empty() {
|
||||
return new AiUsageMetadata(null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefung, ob ueberhaupt Standard-Token-Daten vorliegen.
|
||||
*
|
||||
* <p>Maßgeblich fuer das Einschlusskriterium der Aggregations-Tabellen:
|
||||
* Eine Zeile fließt nur dann in die Kostenanalyse ein, wenn mindestens
|
||||
* eines der Standard-Tokenfelder gesetzt ist.
|
||||
*
|
||||
* @return {@code true} wenn {@link #inputTokens} oder {@link #outputTokens} gesetzt sind
|
||||
*/
|
||||
public boolean hasAnyTokenData() {
|
||||
return inputTokens != null || outputTokens != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefung, ob Cache-Tokens (lesend oder schreibend) vorliegen.
|
||||
*
|
||||
* <p>Wird vom CostCalculator zur Setzung des {@code cacheTokensIgnored}-Flags
|
||||
* herangezogen, da V3.3 Cache-Tokens persistiert, aber nicht in die
|
||||
* Kostenberechnung einbezieht.
|
||||
*
|
||||
* @return {@code true} wenn ein Cache-Tokenfeld gesetzt und groeßer 0 ist
|
||||
*/
|
||||
public boolean hasCacheTokens() {
|
||||
return (cacheCreationInputTokens != null && cacheCreationInputTokens > 0)
|
||||
|| (cacheReadInputTokens != null && cacheReadInputTokens > 0);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Atomar zu speichernder Block aus Upserts und Loeschungen.
|
||||
*
|
||||
* <p>Wird vom {@code ManageModelPricesUseCase} an das
|
||||
* {@code ModelPriceRepository.saveAllChanges(...)} weitergereicht. Die
|
||||
* Konfliktvalidierung (z.B. ein Schluessel sowohl in {@link #upserts()}
|
||||
* als auch {@link #deletions()}) erfolgt im Use Case <em>vor</em> dem
|
||||
* Aufruf der Repository-Methode. Die Repository-Implementierung darf das
|
||||
* Set damit als bereits konsistent voraussetzen und beschränkt sich
|
||||
* auf die transaktionale Persistenz.
|
||||
*
|
||||
* @param upserts Liste von Eintraegen, die eingefuegt oder aktualisiert werden sollen; nicht {@code null}
|
||||
* @param deletions Liste von Composite-Keys, die geloescht werden sollen; nicht {@code null}
|
||||
*/
|
||||
public record ModelPriceChangeSet(
|
||||
List<ModelPriceEntry> upserts,
|
||||
List<ModelPriceKey> deletions) {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor: kopiert die Listen defensiv und macht sie unveraenderlich.
|
||||
*
|
||||
* @throws NullPointerException wenn eine der Listen {@code null} ist
|
||||
*/
|
||||
public ModelPriceChangeSet {
|
||||
Objects.requireNonNull(upserts, "upserts");
|
||||
Objects.requireNonNull(deletions, "deletions");
|
||||
upserts = List.copyOf(upserts);
|
||||
deletions = List.copyOf(deletions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefung, ob das ChangeSet leer ist und somit keine Transaktion benoetigt.
|
||||
*
|
||||
* @return {@code true}, wenn weder Upserts noch Deletions enthalten sind
|
||||
*/
|
||||
public boolean isEmpty() {
|
||||
return upserts.isEmpty() && deletions.isEmpty();
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Schreib- und Validierungs-DTO fuer Modell-Preise.
|
||||
*
|
||||
* <p>Wird im {@code ManageModelPricesUseCase} fuer Inserts und Updates
|
||||
* verwendet. Im Gegensatz zum {@link ModelPriceView} sind alle Felder
|
||||
* inklusive {@link #updatedAt()} non-null. Validierung erfolgt im
|
||||
* Konstruktor; ein konstruiertes Objekt ist ein gueltiger Schreibwert.
|
||||
*
|
||||
* <p>Wertebereiche:
|
||||
* <ul>
|
||||
* <li>{@code priceInputPerTokenNanoUsd} und {@code priceOutputPerTokenNanoUsd}:
|
||||
* 0 bis 100 000 000 (entspricht $0,10/Token bzw. $100 000/1M Tokens)</li>
|
||||
* <li>{@code currency}: ausschließlich {@code "USD"}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param provider Provider-Identifikator (z.B. {@code "openai-compatible"} oder {@code "claude"}); nicht leer
|
||||
* @param modelName Modellname; nicht leer
|
||||
* @param priceInputPerTokenNanoUsd Input-Preis in Nano-USD pro Token; 0..100_000_000
|
||||
* @param priceOutputPerTokenNanoUsd Output-Preis in Nano-USD pro Token; 0..100_000_000
|
||||
* @param currency Waehrung; nur {@code "USD"} zulaessig
|
||||
* @param updatedAt Zeitpunkt der letzten Aktualisierung; non-null
|
||||
*/
|
||||
public record ModelPriceEntry(
|
||||
String provider,
|
||||
String modelName,
|
||||
long priceInputPerTokenNanoUsd,
|
||||
long priceOutputPerTokenNanoUsd,
|
||||
String currency,
|
||||
Instant updatedAt) {
|
||||
|
||||
/** Maximaler erlaubter Preis pro Token in Nano-USD ($0,10 pro Token). */
|
||||
public static final long MAX_PRICE_PER_TOKEN_NANO_USD = 100_000_000L;
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor mit umfassender Validierung.
|
||||
*
|
||||
* @throws NullPointerException wenn {@code provider}, {@code modelName} oder {@code updatedAt} {@code null} sind
|
||||
* @throws IllegalArgumentException bei leerem Provider/Modellname, negativen Preisen, zu hohen Preisen oder anderer Waehrung als USD
|
||||
*/
|
||||
public ModelPriceEntry {
|
||||
Objects.requireNonNull(provider, "provider");
|
||||
Objects.requireNonNull(modelName, "modelName");
|
||||
Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
if (provider.isBlank()) {
|
||||
throw new IllegalArgumentException("provider darf nicht leer sein");
|
||||
}
|
||||
if (modelName.isBlank()) {
|
||||
throw new IllegalArgumentException("modelName darf nicht leer sein");
|
||||
}
|
||||
if (priceInputPerTokenNanoUsd < 0L) {
|
||||
throw new IllegalArgumentException("Input-Preis darf nicht negativ sein");
|
||||
}
|
||||
if (priceOutputPerTokenNanoUsd < 0L) {
|
||||
throw new IllegalArgumentException("Output-Preis darf nicht negativ sein");
|
||||
}
|
||||
if (priceInputPerTokenNanoUsd > MAX_PRICE_PER_TOKEN_NANO_USD) {
|
||||
throw new IllegalArgumentException("Input-Preis ueberschreitet Maximum");
|
||||
}
|
||||
if (priceOutputPerTokenNanoUsd > MAX_PRICE_PER_TOKEN_NANO_USD) {
|
||||
throw new IllegalArgumentException("Output-Preis ueberschreitet Maximum");
|
||||
}
|
||||
if (!"USD".equals(currency)) {
|
||||
throw new IllegalArgumentException("Nur Waehrung USD unterstuetzt");
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.dto;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Composite-Key fuer einen Modell-Preis-Eintrag.
|
||||
*
|
||||
* <p>Identifiziert einen Eintrag in {@code model_price} ueber den
|
||||
* Composite Primary Key {@code (provider, model_name)}. Wird in
|
||||
* {@link ModelPriceChangeSet#deletions()} verwendet, um Loeschungen
|
||||
* unabhaengig von Wertdaten auszudruecken.
|
||||
*
|
||||
* @param provider Provider-Identifikator; nicht leer
|
||||
* @param modelName Modellname; nicht leer
|
||||
*/
|
||||
public record ModelPriceKey(String provider, String modelName) {
|
||||
|
||||
/**
|
||||
* Kompakter Konstruktor mit Nicht-Leer-Pruefung.
|
||||
*
|
||||
* @throws NullPointerException wenn ein Feld {@code null} ist
|
||||
* @throws IllegalArgumentException wenn Provider oder Modellname leer ist
|
||||
*/
|
||||
public ModelPriceKey {
|
||||
Objects.requireNonNull(provider, "provider");
|
||||
Objects.requireNonNull(modelName, "modelName");
|
||||
if (provider.isBlank()) {
|
||||
throw new IllegalArgumentException("provider darf nicht leer sein");
|
||||
}
|
||||
if (modelName.isBlank()) {
|
||||
throw new IllegalArgumentException("modelName darf nicht leer sein");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Lese- und Anzeige-DTO fuer Modell-Preise.
|
||||
*
|
||||
* <p>Im Gegensatz zum {@link ModelPriceEntry} darf {@link #updatedAt()}
|
||||
* {@code null} sein, falls der in der Datenbank gespeicherte Wert nicht als
|
||||
* {@link Instant} parsebar ist. In diesem Fall wird {@link #invalidUpdatedAt()}
|
||||
* auf {@code true} gesetzt und der ursprueng­liche String in
|
||||
* {@link #invalidUpdatedAtRaw()} gehalten, damit die GUI "ungültig"
|
||||
* anzeigen kann.
|
||||
*
|
||||
* <p>Dieses DTO darf <strong>nicht</strong> direkt im Schreibpfad verwendet
|
||||
* werden. Schreiboperationen erfordern den vollvalidierten
|
||||
* {@link ModelPriceEntry}.
|
||||
*
|
||||
* @param provider Provider-Identifikator
|
||||
* @param modelName Modellname
|
||||
* @param priceInputPerTokenNanoUsd Input-Preis in Nano-USD pro Token
|
||||
* @param priceOutputPerTokenNanoUsd Output-Preis in Nano-USD pro Token
|
||||
* @param currency Waehrung; in V3.3 stets {@code "USD"}
|
||||
* @param updatedAt Letztes Update als {@link Instant}; {@code null} bei beschaedigtem DB-Wert
|
||||
* @param invalidUpdatedAtRaw Originalstring aus DB, falls Parsing fehlgeschlagen ist; sonst {@code null}
|
||||
* @param invalidUpdatedAt Flag: {@code true} wenn DB-Wert nicht parsebar war
|
||||
*/
|
||||
public record ModelPriceView(
|
||||
String provider,
|
||||
String modelName,
|
||||
long priceInputPerTokenNanoUsd,
|
||||
long priceOutputPerTokenNanoUsd,
|
||||
String currency,
|
||||
Instant updatedAt,
|
||||
String invalidUpdatedAtRaw,
|
||||
boolean invalidUpdatedAt) {
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Application-Schicht-DTOs fuer den Token- und Kosten-Tracking-Pfad.
|
||||
*
|
||||
* <p>Dieses Paket beherbergt schmale, technologieunabhaengige Datentraeger,
|
||||
* die zwischen Adaptern, Use Cases und Repositories ausgetauscht werden,
|
||||
* ohne JavaFX-, JDBC- oder Domain-spezifische Typen einzuschleppen.
|
||||
*/
|
||||
package de.gecheckt.pdf.umbenenner.application.dto;
|
||||
+14
-5
@@ -2,6 +2,7 @@ package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
|
||||
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
|
||||
|
||||
@@ -18,6 +19,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
|
||||
* including prompt, document text, and character counts</li>
|
||||
* <li>{@link #rawResponse()} — the uninterpreted response body returned by the AI,
|
||||
* which may be valid JSON, malformed, empty, or otherwise problematic</li>
|
||||
* <li>{@link #usageMetadata()} — Token-Verbrauchsmetadaten des Aufrufs;
|
||||
* nie {@code null}, einzelne Felder koennen aber {@code null} sein,
|
||||
* wenn der Provider keine Werte oder ungueltige Werte liefert</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The Application layer is responsible for:
|
||||
@@ -29,22 +33,27 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
|
||||
* </ul>
|
||||
* <p>
|
||||
* <strong>Persistence:</strong> Both request and response are stored in the
|
||||
* processing attempt history for debugging and audit.
|
||||
* processing attempt history for debugging and audit. Token-Daten aus
|
||||
* {@link #usageMetadata()} werden zusammen mit einem Preis-Snapshot in
|
||||
* {@code processing_attempt} persistiert.
|
||||
*
|
||||
* @param request the AI request that was sent; never null
|
||||
* @param rawResponse the uninterpreted response body; never null (but may be empty)
|
||||
* @param 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");
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.port.out;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
|
||||
|
||||
/**
|
||||
* Outbound-Port fuer die Verwaltung persistierter Modell-Preise.
|
||||
*
|
||||
* <p><strong>Schreibpfad-Konvention:</strong> GUI und CLI nutzen ausschließ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.
|
||||
*
|
||||
* <p>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<ModelPriceView> findAll();
|
||||
|
||||
/**
|
||||
* Liefert den Preis-Eintrag zu einem Composite-Key.
|
||||
*
|
||||
* @param provider Provider-Identifikator
|
||||
* @param modelName Modellname
|
||||
* @return {@link Optional} mit Eintrag, leer wenn nicht vorhanden
|
||||
*/
|
||||
Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName);
|
||||
|
||||
/**
|
||||
* Atomarer Insert oder Update.
|
||||
*
|
||||
* <p>@internal Nicht von GUI/CLI direkt verwenden – nur fuer Tests/Werkzeuge.
|
||||
* Der regulaere Bedienpfad verlaeuft ueber
|
||||
* {@link #saveAllChanges(ModelPriceChangeSet)}.
|
||||
*
|
||||
* @param entry zu schreibender Eintrag; nicht {@code null}
|
||||
*/
|
||||
void upsert(ModelPriceEntry entry);
|
||||
|
||||
/**
|
||||
* Loescht den Eintrag eines Composite-Keys.
|
||||
*
|
||||
* <p>@internal Nicht von GUI/CLI direkt verwenden – nur fuer Tests/Werkzeuge.
|
||||
* Der regulaere Bedienpfad verlaeuft ueber
|
||||
* {@link #saveAllChanges(ModelPriceChangeSet)}.
|
||||
*
|
||||
* @param provider Provider-Identifikator
|
||||
* @param modelName Modellname
|
||||
*/
|
||||
void delete(String provider, String modelName);
|
||||
|
||||
/**
|
||||
* Persistiert eine Sammlung von Preisaenderungen atomar.
|
||||
*
|
||||
* <p>Die Konfliktvalidierung des ChangeSets erfolgt vor der Transaktion im
|
||||
* Use Case. Die Implementierung fuehrt alle Operationen innerhalb einer
|
||||
* JDBC-Transaktion mit {@code autoCommit=false} aus und rollt bei
|
||||
* Auftreten eines Fehlers vollstaendig zurueck.
|
||||
*
|
||||
* @param changeSet Sammlung aus Upserts und Loeschungen; nicht {@code null}
|
||||
*/
|
||||
void saveAllChanges(ModelPriceChangeSet changeSet);
|
||||
}
|
||||
+102
-66
@@ -25,71 +25,39 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
|
||||
* ({@link ProcessingStatus#SKIPPED_ALREADY_PROCESSED},
|
||||
* {@link ProcessingStatus#SKIPPED_FINAL_FAILURE}).
|
||||
* <p>
|
||||
* <strong>Field semantics:</strong>
|
||||
* <strong>Token- und Preis-Felder:</strong>
|
||||
* <ul>
|
||||
* <li>{@link #fingerprint()} — foreign key to the document master record.</li>
|
||||
* <li>{@link #runId()} — identifies the batch run during which this attempt occurred.</li>
|
||||
* <li>{@link #attemptNumber()} — monotonically increasing per fingerprint; assigned
|
||||
* before the attempt is recorded.</li>
|
||||
* <li>{@link #startedAt()} — wall-clock timestamp when processing of this candidate
|
||||
* began in this run.</li>
|
||||
* <li>{@link #endedAt()} — wall-clock timestamp when processing completed (success,
|
||||
* failure, or skip).</li>
|
||||
* <li>{@link #status()} — outcome status of this specific attempt.</li>
|
||||
* <li>{@link #failureClass()} — short classification of the failure (e.g. enum constant
|
||||
* name or exception class name); {@code null} for successful or skip attempts.</li>
|
||||
* <li>{@link #failureMessage()} — human-readable failure description; {@code null} for
|
||||
* successful or skip attempts.</li>
|
||||
* <li>{@link #retryable()} — {@code true} if the failure is considered retryable in a
|
||||
* later run; {@code false} for final failures, successes, and skip attempts.</li>
|
||||
* <li>{@link #aiProvider()} — opaque identifier of the AI provider that was active
|
||||
* during this attempt (e.g. {@code "openai-compatible"} or {@code "claude"});
|
||||
* {@code null} for attempts that did not involve an AI call (skip, pre-check
|
||||
* failure) or for historical attempts recorded before this field was introduced.</li>
|
||||
* <li>{@link #modelName()} — the AI model name used in this attempt; {@code null} if
|
||||
* no AI call was made (e.g. pre-check failures or skip attempts).</li>
|
||||
* <li>{@link #promptIdentifier()} — stable identifier of the prompt template used;
|
||||
* {@code null} if no AI call was made.</li>
|
||||
* <li>{@link #processedPageCount()} — number of PDF pages processed; {@code null} if
|
||||
* pages were not extracted (e.g. pre-fingerprint or skip attempts).</li>
|
||||
* <li>{@link #sentCharacterCount()} — number of characters sent to the AI; {@code null}
|
||||
* if no AI call was made.</li>
|
||||
* <li>{@link #aiRawResponse()} — the complete raw AI response body; {@code null} if no
|
||||
* AI call was made. Stored in SQLite but not written to log files by default.</li>
|
||||
* <li>{@link #aiReasoning()} — the reasoning extracted from the AI response; {@code null}
|
||||
* if no valid AI response was obtained.</li>
|
||||
* <li>{@link #resolvedDate()} — the date resolved for the naming proposal; {@code null}
|
||||
* if no naming proposal was produced.</li>
|
||||
* <li>{@link #dateSource()} — the origin of the resolved date; {@code null} if no
|
||||
* naming proposal was produced.</li>
|
||||
* <li>{@link #validatedTitle()} — the validated title from the naming proposal;
|
||||
* {@code null} if no naming proposal was produced.</li>
|
||||
* <li>{@link #finalTargetFileName()} — the final filename written to the target folder
|
||||
* (including any duplicate suffix); set only for
|
||||
* {@link ProcessingStatus#SUCCESS} attempts, {@code null} otherwise.</li>
|
||||
* <li>{@link #inputTokens()}, {@link #outputTokens()} – Standard-Token-Counts; {@code null} bei Versuchen ohne KI-Aufruf oder ohne Token-Daten in der Provider-Antwort.</li>
|
||||
* <li>{@link #cacheCreationInputTokens()}, {@link #cacheReadInputTokens()} – Anthropic-Cache-Token-Counts; {@code null} bei OpenAI-Adapter oder fehlenden Werten.</li>
|
||||
* <li>{@link #priceInputPerTokenNanoUsd()}, {@link #priceOutputPerTokenNanoUsd()} – Preis-Snapshot zum Aufrufzeitpunkt in Nano-USD pro Token; {@code null}, wenn fuer das Modell kein Preis hinterlegt war oder der Lookup fehlschlug.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param fingerprint content-based document identity; never null
|
||||
* @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.
|
||||
*
|
||||
* <p>Setzt alle sechs Token- und Preis-Snapshot-Felder auf {@code null}.
|
||||
* Wird von Aufrufern verwendet, die noch keine Token-Daten beistellen
|
||||
* (typisch fuer Skip-/Pre-Check-Pfade) oder fuer Tests, die das
|
||||
* Token-Tracking nicht beruehren.
|
||||
*
|
||||
* @param fingerprint document identity; never null
|
||||
* @param runId batch run identifier; never null
|
||||
* @param attemptNumber monotonic attempt number; must be >= 1
|
||||
* @param startedAt start instant; never null
|
||||
* @param endedAt end instant; never null
|
||||
* @param status outcome status; never null
|
||||
* @param failureClass failure class, or {@code null}
|
||||
* @param failureMessage failure description, or {@code null}
|
||||
* @param retryable whether retryable in a later run
|
||||
* @param aiProvider opaque AI provider identifier, or {@code null}
|
||||
* @param modelName AI model name, or {@code null}
|
||||
* @param promptIdentifier prompt identifier, or {@code null}
|
||||
* @param processedPageCount number of PDF pages processed, or {@code null}
|
||||
* @param sentCharacterCount number of characters sent to AI, or {@code null}
|
||||
* @param aiRawResponse full raw AI response, or {@code null}
|
||||
* @param aiReasoning AI reasoning text, or {@code null}
|
||||
* @param resolvedDate resolved date for naming proposal, or {@code null}
|
||||
* @param dateSource origin of resolved date, or {@code null}
|
||||
* @param validatedTitle validated title, or {@code null}
|
||||
* @param finalTargetFileName filename written to the target folder, or {@code null}
|
||||
*/
|
||||
public ProcessingAttempt(
|
||||
DocumentFingerprint fingerprint,
|
||||
RunId runId,
|
||||
int attemptNumber,
|
||||
Instant startedAt,
|
||||
Instant endedAt,
|
||||
ProcessingStatus status,
|
||||
String failureClass,
|
||||
String failureMessage,
|
||||
boolean retryable,
|
||||
String aiProvider,
|
||||
String modelName,
|
||||
String promptIdentifier,
|
||||
Integer processedPageCount,
|
||||
Integer sentCharacterCount,
|
||||
String aiRawResponse,
|
||||
String aiReasoning,
|
||||
LocalDate resolvedDate,
|
||||
DateSource dateSource,
|
||||
String validatedTitle,
|
||||
String finalTargetFileName) {
|
||||
this(fingerprint, runId, attemptNumber, startedAt, endedAt, status,
|
||||
failureClass, failureMessage, retryable,
|
||||
aiProvider, modelName, promptIdentifier,
|
||||
processedPageCount, sentCharacterCount,
|
||||
aiRawResponse, aiReasoning,
|
||||
resolvedDate, dateSource, validatedTitle, finalTargetFileName,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ProcessingAttempt} with no AI traceability fields set.
|
||||
* <p>
|
||||
* Convenience factory for pre-check failures, skip events, and any attempt
|
||||
* that does not involve an AI call. The {@link #aiProvider()} field is set
|
||||
* to {@code null}.
|
||||
* that does not involve an AI call. The {@link #aiProvider()} field and all
|
||||
* Token- und Preis-Snapshot-Felder werden auf {@code null} gesetzt.
|
||||
*
|
||||
* @param fingerprint document identity; must not be null
|
||||
* @param runId batch run identifier; must not be null
|
||||
@@ -164,6 +199,7 @@ public record ProcessingAttempt(
|
||||
return new ProcessingAttempt(
|
||||
fingerprint, runId, attemptNumber, startedAt, endedAt,
|
||||
status, failureClass, failureMessage, retryable,
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -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 ->
|
||||
|
||||
+140
-3
@@ -10,8 +10,10 @@ import java.util.function.Function;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionEvent;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.in.DocumentCompletionStatus;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentKnownProcessable;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
|
||||
@@ -163,6 +165,25 @@ public class DocumentProcessingCoordinator {
|
||||
private final int maxRetriesTransient;
|
||||
private final int maxTitleLength;
|
||||
private final String activeProviderIdentifier;
|
||||
/**
|
||||
* Optionales Repository fuer Modell-Preis-Snapshots.
|
||||
*
|
||||
* <p>Wird beim Bau eines Attempts mit erfolgreichem KI-Aufruf konsultiert,
|
||||
* um die Snapshot-Preise zum aktiven Modell zu laden. {@code null}
|
||||
* bedeutet, dass kein Repository verdrahtet wurde – der Coordinator
|
||||
* arbeitet dann ohne Snapshot-Lookup, und alle Preis-Felder bleiben
|
||||
* {@code null}. Damit bleiben bestehende Aufrufer ohne Token-Tracking
|
||||
* lauffaehig.
|
||||
*/
|
||||
private final ModelPriceRepository modelPriceRepository;
|
||||
/**
|
||||
* Markierung fuer den Headless-Betrieb.
|
||||
*
|
||||
* <p>Im Headless-Betrieb wird zusaetzlich zum normalen WARN-Log auf
|
||||
* fehlende Preis-Eintraege ein Hinweis auf den CLI-Befehl
|
||||
* {@code --upsert-model-price} ausgegeben.
|
||||
*/
|
||||
private final boolean headlessMode;
|
||||
|
||||
/**
|
||||
* Optional per-run completion forwarder that is consulted by
|
||||
@@ -229,6 +250,40 @@ public class DocumentProcessingCoordinator {
|
||||
int maxRetriesTransient,
|
||||
int maxTitleLength,
|
||||
String activeProviderIdentifier) {
|
||||
this(documentRecordRepository, processingAttemptRepository, unitOfWorkPort,
|
||||
targetFolderPort, targetFileCopyPort, logger,
|
||||
maxRetriesTransient, maxTitleLength, activeProviderIdentifier,
|
||||
null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erweiterter Konstruktor mit Modell-Preis-Repository und Headless-Hinweis.
|
||||
*
|
||||
* <p>Identisch zum bestehenden Konstruktor; zusaetzlich werden ein
|
||||
* {@link ModelPriceRepository} fuer Snapshot-Lookups und ein
|
||||
* Flag zum Headless-Modus injiziert. Beim Bau eines Attempts mit
|
||||
* erfolgreichem KI-Aufruf wird der Snapshot-Preis ueber das Repository
|
||||
* geladen. Faellt der Lookup mit Exception aus, wird der Attempt mit
|
||||
* Snapshot-Feldern auf {@code null} persistiert und der Fehler ge­
|
||||
* loggt. Im Headless-Modus wird zudem ein Hinweis auf den CLI-Befehl
|
||||
* {@code --upsert-model-price} ausgegeben, wenn das Modell keinen Preis
|
||||
* hat.
|
||||
*
|
||||
* @param modelPriceRepository Repository-Port; darf {@code null} sein
|
||||
* @param headlessMode {@code true} wenn der Lauf headless ist
|
||||
*/
|
||||
public DocumentProcessingCoordinator(
|
||||
DocumentRecordRepository documentRecordRepository,
|
||||
ProcessingAttemptRepository processingAttemptRepository,
|
||||
UnitOfWorkPort unitOfWorkPort,
|
||||
TargetFolderPort targetFolderPort,
|
||||
TargetFileCopyPort targetFileCopyPort,
|
||||
ProcessingLogger logger,
|
||||
int maxRetriesTransient,
|
||||
int maxTitleLength,
|
||||
String activeProviderIdentifier,
|
||||
ModelPriceRepository modelPriceRepository,
|
||||
boolean headlessMode) {
|
||||
if (maxRetriesTransient < 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"maxRetriesTransient must be >= 1, got: " + maxRetriesTransient);
|
||||
@@ -255,6 +310,8 @@ public class DocumentProcessingCoordinator {
|
||||
this.maxRetriesTransient = maxRetriesTransient;
|
||||
this.maxTitleLength = maxTitleLength;
|
||||
this.activeProviderIdentifier = activeProviderIdentifier;
|
||||
this.modelPriceRepository = modelPriceRepository;
|
||||
this.headlessMode = headlessMode;
|
||||
this.completionForwarder = null;
|
||||
}
|
||||
|
||||
@@ -1137,6 +1194,7 @@ public class DocumentProcessingCoordinator {
|
||||
case NamingProposalReady proposalReady -> {
|
||||
AiAttemptContext ctx = proposalReady.aiContext();
|
||||
NamingProposal proposal = proposalReady.proposal();
|
||||
PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
|
||||
yield new ProcessingAttempt(
|
||||
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
|
||||
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
|
||||
@@ -1146,11 +1204,15 @@ public class DocumentProcessingCoordinator {
|
||||
ctx.aiRawResponse(),
|
||||
proposal.aiReasoning(),
|
||||
proposal.resolvedDate(), proposal.dateSource(), proposal.validatedTitle(),
|
||||
null // finalTargetFileName — set only on SUCCESS attempts
|
||||
null, // finalTargetFileName — set only on SUCCESS attempts
|
||||
ctx.inputTokens(), ctx.outputTokens(),
|
||||
ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
|
||||
snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
|
||||
);
|
||||
}
|
||||
case AiTechnicalFailure techFail -> {
|
||||
AiAttemptContext ctx = techFail.aiContext();
|
||||
PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
|
||||
yield new ProcessingAttempt(
|
||||
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
|
||||
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
|
||||
@@ -1159,11 +1221,15 @@ public class DocumentProcessingCoordinator {
|
||||
ctx.processedPageCount(), ctx.sentCharacterCount(),
|
||||
ctx.aiRawResponse(),
|
||||
null, null, null, null,
|
||||
null // finalTargetFileName
|
||||
null, // finalTargetFileName
|
||||
ctx.inputTokens(), ctx.outputTokens(),
|
||||
ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
|
||||
snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
|
||||
);
|
||||
}
|
||||
case AiFunctionalFailure funcFail -> {
|
||||
AiAttemptContext ctx = funcFail.aiContext();
|
||||
PriceSnapshot snapshot = loadPriceSnapshot(ctx.modelName());
|
||||
yield new ProcessingAttempt(
|
||||
fingerprint, context.runId(), attemptNumber, startedAt, endedAt,
|
||||
outcome.overallStatus(), failureClass, failureMessage, outcome.retryable(),
|
||||
@@ -1172,7 +1238,10 @@ public class DocumentProcessingCoordinator {
|
||||
ctx.processedPageCount(), ctx.sentCharacterCount(),
|
||||
ctx.aiRawResponse(),
|
||||
null, null, null, null,
|
||||
null // finalTargetFileName
|
||||
null, // finalTargetFileName
|
||||
ctx.inputTokens(), ctx.outputTokens(),
|
||||
ctx.cacheCreationInputTokens(), ctx.cacheReadInputTokens(),
|
||||
snapshot.inputPriceNanoUsd(), snapshot.outputPriceNanoUsd()
|
||||
);
|
||||
}
|
||||
default -> ProcessingAttempt.withoutAiFields(
|
||||
@@ -1182,6 +1251,74 @@ public class DocumentProcessingCoordinator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt den Preis-Snapshot fuer den aktiven Provider und das uebergebene Modell.
|
||||
*
|
||||
* <p>Bei fehlendem Repository (Verdrahtung ohne Token-Tracking) liefert die
|
||||
* Methode einen leeren Snapshot ({@code (null, null)}) und protokolliert nichts.
|
||||
*
|
||||
* <p>Bei fehlendem Eintrag (kein Preis konfiguriert) gibt die Methode einen
|
||||
* leeren Snapshot zurueck und schreibt eine WARN-Logzeile. Im Headless-Modus
|
||||
* wird zusaetzlich ein Hinweis auf den CLI-Befehl ergaenzt.
|
||||
*
|
||||
* <p>Bei Lookup-Exception schreibt die Methode eine ERROR-Logzeile und liefert
|
||||
* einen leeren Snapshot. Der aufrufende Code verwendet ihn unveraendert weiter,
|
||||
* sodass der Attempt persistiert wird (Token-Daten bleiben verfuegbar, nur die
|
||||
* Preisfelder bleiben {@code null}).
|
||||
*
|
||||
* @param modelName aktiver Modellname; darf {@code null} sein (z.B. wenn kein KI-Aufruf erfolgte)
|
||||
* @return Snapshot mit Input-/Output-Preis (Nano-USD/Token) oder {@code null}-Feldern bei Fehler
|
||||
*/
|
||||
private PriceSnapshot loadPriceSnapshot(String modelName) {
|
||||
if (modelPriceRepository == null || modelName == null) {
|
||||
return PriceSnapshot.empty();
|
||||
}
|
||||
try {
|
||||
java.util.Optional<ModelPriceView> view =
|
||||
modelPriceRepository.findByProviderAndModelName(activeProviderIdentifier, modelName);
|
||||
if (view.isEmpty()) {
|
||||
if (headlessMode) {
|
||||
logger.warn("Kein Preis-Eintrag fuer Provider \"{}\" und Modell \"{}\" – "
|
||||
+ "Tokens werden persistiert, Snapshot bleibt leer. "
|
||||
+ "Hinweis: Modell-Preise koennen mit --upsert-model-price ergaenzt werden. Siehe betrieb.md.",
|
||||
activeProviderIdentifier, modelName);
|
||||
} else {
|
||||
logger.warn("Kein Preis-Eintrag fuer Provider \"{}\" und Modell \"{}\" – "
|
||||
+ "Tokens werden persistiert, Snapshot bleibt leer.",
|
||||
activeProviderIdentifier, modelName);
|
||||
}
|
||||
return PriceSnapshot.empty();
|
||||
}
|
||||
ModelPriceView priceView = view.get();
|
||||
return new PriceSnapshot(
|
||||
priceView.priceInputPerTokenNanoUsd(),
|
||||
priceView.priceOutputPerTokenNanoUsd());
|
||||
} catch (RuntimeException ex) {
|
||||
logger.error("Preis-Lookup fuer Provider \"{}\" und Modell \"{}\" fehlgeschlagen: {} – "
|
||||
+ "Attempt wird mit Snapshot-Feldern auf null persistiert.",
|
||||
activeProviderIdentifier, modelName, ex.getMessage(), ex);
|
||||
return PriceSnapshot.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Halterung fuer den geladenen Preis-Snapshot.
|
||||
*
|
||||
* @param inputPriceNanoUsd Input-Preis in Nano-USD pro Token oder {@code null}
|
||||
* @param outputPriceNanoUsd Output-Preis in Nano-USD pro Token oder {@code null}
|
||||
*/
|
||||
private record PriceSnapshot(Long inputPriceNanoUsd, Long outputPriceNanoUsd) {
|
||||
|
||||
/**
|
||||
* Liefert einen Snapshot ohne Werte (beide Felder {@code null}).
|
||||
*
|
||||
* @return leerer Snapshot
|
||||
*/
|
||||
static PriceSnapshot empty() {
|
||||
return new PriceSnapshot(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a human-readable failure message from the pipeline outcome and status outcome.
|
||||
*/
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
|
||||
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;
|
||||
|
||||
/**
|
||||
* Use Case zur Verwaltung persistierter Modell-Preise.
|
||||
*
|
||||
* <p>Bietet eine schmale CRUD-Fassade ueber das {@link ModelPriceRepository}
|
||||
* inklusive der ChangeSet-Konfliktvalidierung und einer Whitelist-Pruefung
|
||||
* auf bekannte Provider beim Upsert. Loeschungen sind auch fuer unbekannte
|
||||
* Provider erlaubt, damit verwaiste Eintraege entfernt werden koennen.
|
||||
*
|
||||
* <p>Der {@link ClockPort} liefert den {@code updatedAt}-Wert fuer alle
|
||||
* Upserts, so daß die Schreiblogik testbar bleibt.
|
||||
*
|
||||
* <p>Konfliktvalidierungsregeln (alle vier sind in dieser Reihenfolge wirksam):
|
||||
* <ol>
|
||||
* <li>Doppelte (provider, modelName)-Schluessel innerhalb der Upsert-Liste werden abgewiesen.</li>
|
||||
* <li>Doppelte (provider, modelName)-Schluessel innerhalb der Deletions-Liste werden abgewiesen.</li>
|
||||
* <li>Ein Schluessel darf nicht zugleich in {@code upserts} und {@code deletions} stehen.</li>
|
||||
* <li>Beim Upsert ist nur die V3.3-Provider-Whitelist erlaubt
|
||||
* ({@code openai-compatible}, {@code claude}).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Ein leeres ChangeSet erzeugt keinen Repository-Aufruf, sondern wird
|
||||
* als No-op mit INFO-Log behandelt.
|
||||
*/
|
||||
public class DefaultManageModelPricesUseCase {
|
||||
|
||||
private static final Logger LOG = LogManager.getLogger(DefaultManageModelPricesUseCase.class);
|
||||
|
||||
/**
|
||||
* Whitelist der in V3.3 fachlich unterstuetzten Provider.
|
||||
*
|
||||
* <p>Wird beim Upsert geprueft. Loeschungen umgehen die Whitelist
|
||||
* absichtlich, damit verwaiste Eintraege entfernbar bleiben.
|
||||
*/
|
||||
public static final Set<String> SUPPORTED_PROVIDERS = Set.of("openai-compatible", "claude");
|
||||
|
||||
private final ModelPriceRepository repository;
|
||||
private final ClockPort clockPort;
|
||||
|
||||
/**
|
||||
* Erzeugt den Use Case mit allen erforderlichen Ports.
|
||||
*
|
||||
* @param repository Repository-Port; nicht {@code null}
|
||||
* @param clockPort Clock-Port; nicht {@code null}
|
||||
*/
|
||||
public DefaultManageModelPricesUseCase(ModelPriceRepository repository, ClockPort clockPort) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
this.clockPort = Objects.requireNonNull(clockPort, "clockPort");
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert alle persistierten Modell-Preise.
|
||||
*
|
||||
* @return Liste aller Eintraege; nie {@code null}
|
||||
*/
|
||||
public List<ModelPriceView> findAll() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Preis-Eintrag zu einem Composite-Key.
|
||||
*
|
||||
* @param provider Provider-Identifikator
|
||||
* @param modelName Modellname
|
||||
* @return {@link Optional} mit Eintrag, leer wenn nicht vorhanden
|
||||
*/
|
||||
public Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName) {
|
||||
return repository.findByProviderAndModelName(provider, modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert das ChangeSet und persistiert es transaktional.
|
||||
*
|
||||
* <p>Bei einem leeren ChangeSet wird kein Repository-Aufruf ausgefuehrt;
|
||||
* die Methode kehrt direkt zurueck. Bei Konfliktverletzungen oder
|
||||
* Whitelist-Verstoessen wird eine {@link ModelPriceValidationException}
|
||||
* mit deutscher Meldung geworfen, ohne dass eine Transaktion gestartet
|
||||
* wird.
|
||||
*
|
||||
* <p>Vor der Repository-Weitergabe werden die Upsert-Eintraege auf den
|
||||
* Clock-Zeitpunkt der aktuellen Aktion umgeschrieben (gleicher
|
||||
* Zeitstempel fuer alle Eintraege eines ChangeSets).
|
||||
*
|
||||
* @param changeSet zu speicherndes ChangeSet; nicht {@code null}
|
||||
* @throws ModelPriceValidationException bei Konfliktverstoessen oder unbekanntem Provider beim Upsert
|
||||
*/
|
||||
public void saveAllChanges(ModelPriceChangeSet changeSet) {
|
||||
Objects.requireNonNull(changeSet, "changeSet");
|
||||
|
||||
if (changeSet.isEmpty()) {
|
||||
LOG.info("Modell-Preis-ChangeSet ist leer – kein Schreibvorgang durchgefuehrt");
|
||||
return;
|
||||
}
|
||||
|
||||
validateNoDuplicateUpsertKeys(changeSet.upserts());
|
||||
validateNoDuplicateDeletionKeys(changeSet.deletions());
|
||||
validateNoCrossOverlap(changeSet.upserts(), changeSet.deletions());
|
||||
validateUpsertProviders(changeSet.upserts());
|
||||
|
||||
Instant now = clockPort.now();
|
||||
ModelPriceChangeSet stamped = stampUpdatedAt(changeSet, now);
|
||||
|
||||
try {
|
||||
repository.saveAllChanges(stamped);
|
||||
LOG.info("Modell-Preis-Batch persistiert: {} Upserts, {} Deletions",
|
||||
stamped.upserts().size(), stamped.deletions().size());
|
||||
} catch (RuntimeException ex) {
|
||||
LOG.error("Modell-Preis-Batch fehlgeschlagen, Rollback ausgefuehrt: {}", ex.getMessage());
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Kopie des ChangeSets, in der jeder Upsert-Eintrag den
|
||||
* uebergebenen Zeitpunkt als {@code updatedAt} traegt.
|
||||
*
|
||||
* @param changeSet Original-ChangeSet
|
||||
* @param now neuer Zeitstempel
|
||||
* @return neues ChangeSet mit identischen Daten und einheitlichem {@code updatedAt}
|
||||
*/
|
||||
private ModelPriceChangeSet stampUpdatedAt(ModelPriceChangeSet changeSet, Instant now) {
|
||||
List<ModelPriceEntry> stampedUpserts = changeSet.upserts().stream()
|
||||
.map(entry -> new ModelPriceEntry(
|
||||
entry.provider(),
|
||||
entry.modelName(),
|
||||
entry.priceInputPerTokenNanoUsd(),
|
||||
entry.priceOutputPerTokenNanoUsd(),
|
||||
entry.currency(),
|
||||
now))
|
||||
.toList();
|
||||
return new ModelPriceChangeSet(stampedUpserts, changeSet.deletions());
|
||||
}
|
||||
|
||||
private void validateNoDuplicateUpsertKeys(List<ModelPriceEntry> upserts) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (ModelPriceEntry entry : upserts) {
|
||||
String key = entry.provider() + "|" + entry.modelName();
|
||||
if (!seen.add(key)) {
|
||||
throw new ModelPriceValidationException(
|
||||
"ChangeSet enthaelt doppelten Upsert fuer Provider \""
|
||||
+ entry.provider() + "\" und Modell \"" + entry.modelName() + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateNoDuplicateDeletionKeys(List<ModelPriceKey> deletions) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (ModelPriceKey key : deletions) {
|
||||
String composite = key.provider() + "|" + key.modelName();
|
||||
if (!seen.add(composite)) {
|
||||
throw new ModelPriceValidationException(
|
||||
"ChangeSet enthaelt doppelte Loeschung fuer Provider \""
|
||||
+ key.provider() + "\" und Modell \"" + key.modelName() + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateNoCrossOverlap(List<ModelPriceEntry> upserts, List<ModelPriceKey> deletions) {
|
||||
Set<String> upsertKeys = new HashSet<>();
|
||||
for (ModelPriceEntry entry : upserts) {
|
||||
upsertKeys.add(entry.provider() + "|" + entry.modelName());
|
||||
}
|
||||
for (ModelPriceKey key : deletions) {
|
||||
String composite = key.provider() + "|" + key.modelName();
|
||||
if (upsertKeys.contains(composite)) {
|
||||
throw new ModelPriceValidationException(
|
||||
"ChangeSet enthaelt Schluessel sowohl in Upserts als auch in Deletions: Provider \""
|
||||
+ key.provider() + "\", Modell \"" + key.modelName() + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUpsertProviders(List<ModelPriceEntry> upserts) {
|
||||
for (ModelPriceEntry entry : upserts) {
|
||||
if (!SUPPORTED_PROVIDERS.contains(entry.provider())) {
|
||||
throw new ModelPriceValidationException(
|
||||
"Unbekannter Provider beim Upsert: \"" + entry.provider()
|
||||
+ "\". Zulaessig sind: " + SUPPORTED_PROVIDERS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package de.gecheckt.pdf.umbenenner.application.usecase;
|
||||
|
||||
/**
|
||||
* Validierungsfehler beim Speichern von Modell-Preisen.
|
||||
*
|
||||
* <p>Wird vom {@link DefaultManageModelPricesUseCase} ausgeloest, wenn ein
|
||||
* {@link de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet}
|
||||
* intern nicht konsistent ist (z.B. Schluesselkonflikte) oder wenn ein
|
||||
* Eintrag gegen die fachlichen Regeln verstoß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);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+80
-6
@@ -454,7 +454,8 @@ public class BootstrapRunner {
|
||||
this.useCaseFactory = (startConfig, lock) -> buildProductionBatchUseCase(
|
||||
startConfig, lock,
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver.noOp(),
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled());
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken.neverCancelled(),
|
||||
true);
|
||||
this.commandFactory = SchedulerBatchCommand::new;
|
||||
this.guiAdapterFactory = GuiAdapter::new;
|
||||
this.singleInstanceGuardFactory = SingleInstanceGuard::new;
|
||||
@@ -479,6 +480,23 @@ public class BootstrapRunner {
|
||||
RunLockPort runLockPort,
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken) {
|
||||
return buildProductionBatchUseCase(
|
||||
startConfig, runLockPort, progressObserver, cancellationToken, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erweiterte Variante mit Headless-Modus-Flag fuer den Token-Tracking-Hook.
|
||||
*
|
||||
* @param headlessMode {@code true} fuer headless Laufkontext; aktiviert den
|
||||
* CLI-Hinweis bei fehlendem Modell-Preis-Eintrag
|
||||
* @return verdrahteter Use Case
|
||||
*/
|
||||
private BatchRunProcessingUseCase buildProductionBatchUseCase(
|
||||
StartConfiguration startConfig,
|
||||
RunLockPort runLockPort,
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProgressObserver progressObserver,
|
||||
de.gecheckt.pdf.umbenenner.application.port.in.BatchRunCancellationToken cancellationToken,
|
||||
boolean headlessMode) {
|
||||
AiContentSensitivity aiContentSensitivity = resolveAiContentSensitivity(startConfig.logAiSensitive());
|
||||
RuntimeConfiguration runtimeConfig = new RuntimeConfiguration(
|
||||
startConfig.maxPages(), startConfig.maxRetriesTransient(), aiContentSensitivity);
|
||||
@@ -499,12 +517,16 @@ public class BootstrapRunner {
|
||||
DocumentProcessingCoordinator.class, aiContentSensitivity);
|
||||
TargetFolderPort targetFolderPort = new FilesystemTargetFolderAdapter(startConfig.targetFolder());
|
||||
TargetFileCopyPort targetFileCopyPort = new FilesystemTargetFileCopyAdapter(startConfig.targetFolder());
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository modelPriceRepository =
|
||||
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl);
|
||||
DocumentProcessingCoordinator documentProcessingCoordinator =
|
||||
new DocumentProcessingCoordinator(documentRecordRepository, processingAttemptRepository,
|
||||
unitOfWorkPort, targetFolderPort, targetFileCopyPort, coordinatorLogger,
|
||||
startConfig.maxRetriesTransient(),
|
||||
startConfig.maxTitleLength(),
|
||||
activeFamily.getIdentifier());
|
||||
activeFamily.getIdentifier(),
|
||||
modelPriceRepository,
|
||||
headlessMode);
|
||||
|
||||
PromptPort promptPort = new FilesystemPromptPortAdapter(startConfig.promptTemplateFile());
|
||||
ClockPort clockPort = new SystemClockAdapter();
|
||||
@@ -916,6 +938,8 @@ public class BootstrapRunner {
|
||||
GuiManualFileCopyPort manualCopyPort = this::performGuiManualFileCopy;
|
||||
GuiHistoricalDocumentContextPort historicalDocumentContextPort = this::resolveHistoricalDocumentContextForGui;
|
||||
GuiHistoryOverviewPort historyOverviewPort = this::loadHistoryOverviewForGui;
|
||||
de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort modelPricePort =
|
||||
buildGuiModelPriceManagementPort();
|
||||
GuiHistoryDetailsPort historyDetailsPort = this::loadHistoryDetailsForGui;
|
||||
GuiCreateNewDatabasePort createNewDatabasePort = this::createNewDatabaseForGui;
|
||||
GuiHistoryResetDocumentStatusPort historyResetPort = this::resetHistoryDocumentStatusForGui;
|
||||
@@ -966,7 +990,8 @@ public class BootstrapRunner {
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
contextInitializer);
|
||||
contextInitializer,
|
||||
Optional.of(modelPricePort));
|
||||
}
|
||||
|
||||
Path configPath = Paths.get(configPathOverride.get());
|
||||
@@ -1002,7 +1027,8 @@ public class BootstrapRunner {
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
contextInitializer);
|
||||
contextInitializer,
|
||||
Optional.of(modelPricePort));
|
||||
}
|
||||
|
||||
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
|
||||
@@ -1025,7 +1051,8 @@ public class BootstrapRunner {
|
||||
historicalDocumentContextPort, applicationVersion, promptEditorPort,
|
||||
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
|
||||
this::buildGuiPromptEditorPort, createNewDatabasePort, contextError,
|
||||
schedulerUseCase, guiRunLockPort, contextInitializer);
|
||||
schedulerUseCase, guiRunLockPort, contextInitializer,
|
||||
Optional.of(modelPricePort));
|
||||
} catch (GuiConfigurationLoadException e) {
|
||||
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
|
||||
e.getMessage(), e);
|
||||
@@ -1057,7 +1084,8 @@ public class BootstrapRunner {
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
contextInitializer);
|
||||
contextInitializer,
|
||||
Optional.of(modelPricePort));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2169,6 +2197,52 @@ public class BootstrapRunner {
|
||||
migrationStep.runIfNeeded(effectiveConfigPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine GUI-Bridge-Implementierung fuer den Modell-Preis-Tab.
|
||||
*
|
||||
* <p>Pro Methodenaufruf wird ein frischer SQLite-Adapter aus der zur
|
||||
* Konfigurationsdatei gehoerenden JDBC-URL aufgebaut. Die Methoden des
|
||||
* Ports laufen auf einem GUI-Worker-Thread; die Connections werden
|
||||
* try-with-resources sofort wieder geschlossen.
|
||||
*
|
||||
* @return Bridge-Port-Implementierung; nie {@code null}
|
||||
*/
|
||||
private de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort
|
||||
buildGuiModelPriceManagementPort() {
|
||||
return new de.gecheckt.pdf.umbenenner.adapter.in.gui.modelprices.GuiModelPriceManagementPort() {
|
||||
@Override
|
||||
public java.util.List<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView>
|
||||
findAll(Path configFilePath) {
|
||||
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||
String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
|
||||
return new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl)
|
||||
.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView>
|
||||
findByProviderAndModelName(Path configFilePath, String provider, String modelName) {
|
||||
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||
String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
|
||||
return new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl)
|
||||
.findByProviderAndModelName(provider, modelName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveAllChanges(Path configFilePath,
|
||||
de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet changeSet) {
|
||||
Objects.requireNonNull(configFilePath, CONFIG_FILE_NOT_NULL);
|
||||
String jdbcUrl = resolveJdbcUrlForGui(configFilePath);
|
||||
de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository repository =
|
||||
new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteModelPriceRepositoryAdapter(jdbcUrl);
|
||||
de.gecheckt.pdf.umbenenner.application.usecase.DefaultManageModelPricesUseCase useCase =
|
||||
new de.gecheckt.pdf.umbenenner.application.usecase.DefaultManageModelPricesUseCase(
|
||||
repository, new SystemClockAdapter());
|
||||
useCase.saveAllChanges(changeSet);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die aktive JDBC-URL für GUI-Aufrufe, die keinen vollständigen
|
||||
* {@link StartConfiguration} benötigen.
|
||||
|
||||
+2
-1
@@ -104,6 +104,7 @@ final class StubAiInvocationPort implements AiInvocationPort {
|
||||
+ "\"title\": \"" + title + "\", "
|
||||
+ "\"reasoning\": \"" + reasoning + "\""
|
||||
+ "}";
|
||||
return new AiInvocationSuccess(request, new AiRawResponse(rawJson));
|
||||
return new AiInvocationSuccess(request, new AiRawResponse(rawJson),
|
||||
de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata.empty());
|
||||
}
|
||||
}
|
||||
|
||||
+42
-7
@@ -11,25 +11,37 @@ import java.util.Objects;
|
||||
* <li>AI infrastructure details (model name, prompt identifier)</li>
|
||||
* <li>Request size metrics (processed pages, sent character count)</li>
|
||||
* <li>Raw AI output (for audit and diagnostics; stored in SQLite, not in log files)</li>
|
||||
* <li>Token-Verbrauch (Standard- und Anthropic-Cache-Tokens) – nullable, da nicht
|
||||
* jeder Provider Token-Counts liefert und einzelne Felder vom Adapter wegen
|
||||
* ungueltiger Werte ({@code null}, < 0, > 10 Mio.) auf {@code null}
|
||||
* gesetzt werden koennen.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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.
|
||||
*
|
||||
* <p>Setzt alle vier Token-Felder auf {@code null}. Wird von Aufrufern verwendet,
|
||||
* die noch keine Token-Daten beistellen (z.B. KI-Aufrufe ohne usage-Antwort,
|
||||
* frueher V3.2-Bestand oder Tests, die das Token-Tracking nicht beruehren).
|
||||
*
|
||||
* @param modelName AI model name; never null
|
||||
* @param promptIdentifier stable prompt identifier; never null
|
||||
* @param processedPageCount number of PDF pages included; must be >= 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user