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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 09:49:50 +02:00
parent b63dcf5efa
commit 08ec021b5f
40 changed files with 2779 additions and 120 deletions
@@ -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&times;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&szlig;lich im GUI-Layer
* (CostFormatter).
*/
public final class CostCalculator {
/** Divisor fuer die Umrechnung Nano-USD &rarr; 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&times;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);
}
}
@@ -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&shy;
* scheidung von &quot;keine Werte&quot; und &quot;Mikrobetrag&quot;.</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) {
}
@@ -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, &quot;&lt;&nbsp;$0.0001&quot;) zustaendig.
*/
package de.gecheckt.pdf.umbenenner.application.cost;
@@ -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);
}
}
@@ -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&auml;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();
}
}
@@ -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&nbsp;000&nbsp;000 (entspricht $0,10/Token bzw. $100&nbsp;000/1M Tokens)</li>
* <li>{@code currency}: ausschlie&szlig;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");
}
}
}
@@ -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");
}
}
}
@@ -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&shy;liche String in
* {@link #invalidUpdatedAtRaw()} gehalten, damit die GUI &quot;ung&uuml;ltig&quot;
* 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) {
}
@@ -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;
@@ -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");
}
}
@@ -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&szlig;lich
* {@link #saveAllChanges(ModelPriceChangeSet)} fuer transaktionale Batch-
* Speicherungen. Die Methoden {@link #upsert(ModelPriceEntry)} und
* {@link #delete(String, String)} sind ausschlie&szlig;lich fuer Tests und
* Werkzeuge gedacht; sie sind keine Bestandteile des regul&auml;ren
* Bedienpfads.
*
* <p>Lesemethoden liefern {@link ModelPriceView} (mit nullable
* {@link ModelPriceView#updatedAt()} bei besch&auml;digten DB-Werten).
* Schreibmethoden akzeptieren ausschlie&szlig;lich {@link ModelPriceEntry},
* dessen Konstruktor s&auml;mtliche Wertgrenzen pr&uuml;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);
}
@@ -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 &gt;= 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 &gt;= 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 &gt;= 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);
}
}
@@ -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 ->
@@ -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&shy;
* 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.
*/
@@ -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&szlig; 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);
}
}
}
}
@@ -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&szlig;t (z.B. unbekannter
* Provider beim Upsert). Die Exception enthaelt eine deutsche
* Fehlermeldung, die ohne weitere Verarbeitung an die GUI/CLI durch&shy;
* 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);
}
}
@@ -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) {