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:
+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) {
|
||||
|
||||
Reference in New Issue
Block a user