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
@@ -14,6 +14,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
@@ -356,7 +357,8 @@ public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
"Anthropic response contained no text-type content blocks");
}
return new AiInvocationSuccess(request, new AiRawResponse(extractedText));
return new AiInvocationSuccess(request, new AiRawResponse(extractedText),
extractTokenUsageFromResponse(json));
} catch (JSONException e) {
LOG.warn("Claude AI response could not be parsed as JSON: {}", e.getMessage());
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
@@ -364,6 +366,64 @@ public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
}
}
/**
* Extrahiert Token-Verbrauchsmetadaten aus der Anthropic-Response.
*
* <p>Anthropic Messages API liefert im Top-Level-Feld {@code usage}:
* {@code input_tokens}, {@code output_tokens},
* {@code cache_creation_input_tokens}, {@code cache_read_input_tokens}.
*
* <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
* Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
*
* @param root die geparste Anthropic-Response
* @return befuelltes {@link AiUsageMetadata}; nie {@code null}
*/
private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
JSONObject usage = root.optJSONObject("usage");
if (usage == null) {
LOG.warn("Anthropic-Response enthielt kein usage-Feld Token-Daten werden nicht erfasst");
return AiUsageMetadata.empty();
}
Long inputTokens = readTokenField(usage, "input_tokens");
Long outputTokens = readTokenField(usage, "output_tokens");
Long cacheCreation = readTokenField(usage, "cache_creation_input_tokens");
Long cacheRead = readTokenField(usage, "cache_read_input_tokens");
return new AiUsageMetadata(inputTokens, outputTokens, cacheCreation, cacheRead);
}
/**
* Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
*
* <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
* Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
* mit WARN-Log und gibt {@code null} zurueck.
*
* @param usage das JSON-Objekt mit den Token-Feldern
* @param fieldName Name des Feldes
* @return validierter Token-Wert oder {@code null}
*/
private Long readTokenField(JSONObject usage, String fieldName) {
if (!usage.has(fieldName) || usage.isNull(fieldName)) {
return null;
}
try {
long value = usage.getLong(fieldName);
if (value < 0L) {
LOG.warn("Anthropic-Token-Feld {} ist negativ ({}) Wert verworfen", fieldName, value);
return null;
}
if (value > 10_000_000L) {
LOG.warn("Anthropic-Token-Feld {} uebersteigt Maximum (10 Mio.): {} Wert verworfen", fieldName, value);
return null;
}
return value;
} catch (JSONException e) {
LOG.warn("Anthropic-Token-Feld {} ist nicht numerisch Wert verworfen: {}", fieldName, e.getMessage());
return null;
}
}
/**
* Package-private accessor for the last constructed JSON body.
* <p>
@@ -14,6 +14,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
@@ -268,7 +269,8 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
"OpenAI response message.content is absent or blank");
}
return new AiInvocationSuccess(request, new AiRawResponse(content));
return new AiInvocationSuccess(request, new AiRawResponse(content),
extractTokenUsageFromResponse(json));
} catch (JSONException e) {
LOG.warn("OpenAI response could not be parsed as JSON: {}", e.getMessage());
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
@@ -276,6 +278,63 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
}
}
/**
* Extrahiert Token-Verbrauchsmetadaten aus der OpenAI-Response.
*
* <p>Mapping: {@code usage.prompt_tokens -> inputTokens},
* {@code usage.completion_tokens -> outputTokens}. Cache-Felder sind in der
* OpenAI-kompatiblen Schnittstelle nicht standardisiert und bleiben immer
* {@code null}.
*
* <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
* Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
*
* @param root die geparste OpenAI-Response (Envelope)
* @return befuelltes {@link AiUsageMetadata}; nie {@code null}
*/
private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
JSONObject usage = root.optJSONObject("usage");
if (usage == null) {
LOG.warn("OpenAI-Response enthielt kein usage-Feld Token-Daten werden nicht erfasst");
return AiUsageMetadata.empty();
}
Long inputTokens = readTokenField(usage, "prompt_tokens");
Long outputTokens = readTokenField(usage, "completion_tokens");
return new AiUsageMetadata(inputTokens, outputTokens, null, null);
}
/**
* Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
*
* <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
* Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
* mit WARN-Log und gibt {@code null} zurueck.
*
* @param usage das JSON-Objekt mit den Token-Feldern
* @param fieldName Name des Feldes
* @return validierter Token-Wert oder {@code null}
*/
private Long readTokenField(JSONObject usage, String fieldName) {
if (!usage.has(fieldName) || usage.isNull(fieldName)) {
return null;
}
try {
long value = usage.getLong(fieldName);
if (value < 0L) {
LOG.warn("OpenAI-Token-Feld {} ist negativ ({}) Wert verworfen", fieldName, value);
return null;
}
if (value > 10_000_000L) {
LOG.warn("OpenAI-Token-Feld {} uebersteigt Maximum (10 Mio.): {} Wert verworfen", fieldName, value);
return null;
}
return value;
} catch (JSONException e) {
LOG.warn("OpenAI-Token-Feld {} ist nicht numerisch Wert verworfen: {}", fieldName, e.getMessage());
return null;
}
}
/**
* Builds an OpenAI Chat Completions API request from the request representation.
* <p>
@@ -0,0 +1,24 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
/**
* Technischer Fehler im SQLite-Adapter fuer Modell-Preise.
*
* <p>Wird vom {@link SqliteModelPriceRepositoryAdapter} geworfen, wenn ein
* JDBC-Fehler beim Lesen, Schreiben oder Loeschen aufgetreten ist. Die
* Application-Schicht und die GUI behandeln diese Exception als
* technischen Fehler mit deutscher Meldung.
*/
public class ModelPriceRepositoryException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Erzeugt eine neue Ausnahme mit Meldung und Ursache.
*
* @param message deutsche Meldung
* @param cause urspruenglicher Fehler
*/
public ModelPriceRepositoryException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,81 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.sql.Statement;
/**
* Zentrale Factory fuer SQLite-Connections.
*
* <p>Wird von allen Repository-Adaptern und vom UnitOfWork-Adapter genutzt,
* um Connections mit einheitlichen PRAGMA-Einstellungen zu oeffnen. Damit
* sind WAL-Modus, {@code busy_timeout} und {@code foreign_keys} fuer alle
* Schreib- und Lesepfade wirksam.
*
* <p>Folgende PRAGMAs werden auf jeder Connection gesetzt:
* <ul>
* <li>{@code PRAGMA journal_mode=WAL} Reader werden nicht durch Writer blockiert.</li>
* <li>{@code PRAGMA busy_timeout=5000} wartet bis zu 5 Sekunden, bevor
* {@code SQLITE_BUSY} geworfen wird.</li>
* <li>{@code PRAGMA foreign_keys=ON} aktiviert die Pruefung von Fremdschluesseln
* (entspricht dem bestehenden Verhalten der bisherigen Adapter).</li>
* </ul>
*
* <p>Die Factory ist stateless; die Methode {@link #open(String)} liefert
* jeweils eine frische {@link Connection}, die der Aufrufer (typisch via
* try-with-resources) wieder schlie&szlig;t.
*/
public final class SqliteConnectionFactory {
private SqliteConnectionFactory() {
// Utility class
}
/**
* Oeffnet eine neue SQLite-Connection und setzt die Standard-PRAGMAs.
*
* @param jdbcUrl JDBC-URL zur SQLite-Datenbank, z.B. {@code jdbc:sqlite:/pfad/db.sqlite}
* @return eine neue, eingerichtete {@link Connection}
* @throws SQLException wenn die Verbindung nicht hergestellt oder die
* PRAGMAs nicht gesetzt werden koennen
*/
public static Connection open(String jdbcUrl) throws SQLException {
Connection connection = DriverManager.getConnection(jdbcUrl);
try {
applyDefaultPragmas(connection);
} catch (SQLException ex) {
try {
connection.close();
} catch (SQLException ignored) {
// Schliessfehler maskieren den eigentlichen Setup-Fehler nicht
}
throw ex;
}
return connection;
}
/**
* Setzt die Standard-PRAGMAs auf einer bereits geoeffneten Connection.
*
* <p>Die Foreign-Key-Pruefung wird hier <strong>nicht</strong> aktiviert,
* um das bisher faktisch praktizierte Verhalten von Repository-Connections
* zu erhalten. Die Foreign-Key-Pruefung wird durch
* {@code SqliteSchemaInitializationAdapter} auf der zentralen DataSource
* fuer Schema-Operationen explizit aktiviert; einzelne Repository-
* Connections, die ueber diese Factory geoeffnet werden, behalten das
* bisherige Verhalten der direkten {@code DriverManager.getConnection}-
* Aufrufe und setzen Foreign-Keys nicht implizit.
*
* @param connection bestehende Connection; nicht {@code null}
* @throws SQLException wenn ein PRAGMA-Statement fehlschlaegt
*/
private static void applyDefaultPragmas(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
// WAL: Reader werden nicht durch Writer blockiert.
statement.execute("PRAGMA journal_mode=WAL");
// 5 Sekunden Wartezeit pro Connection bei SQLITE_BUSY.
statement.execute("PRAGMA busy_timeout=5000");
}
}
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -373,6 +372,6 @@ public class SqliteDocumentRecordRepositoryAdapter implements DocumentRecordRepo
* @throws SQLException if the connection cannot be established
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
return SqliteConnectionFactory.open(jdbcUrl);
}
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -340,7 +339,26 @@ public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
resolvedDate,
dateSource,
rs.getString("validated_title"),
rs.getString("final_target_file_name"));
rs.getString("final_target_file_name"),
readNullableLong(rs, "input_tokens"),
readNullableLong(rs, "output_tokens"),
readNullableLong(rs, "cache_creation_input_tokens"),
readNullableLong(rs, "cache_read_input_tokens"),
readNullableLong(rs, "price_input_per_token_nano_usd"),
readNullableLong(rs, "price_output_per_token_nano_usd"));
}
/**
* Liest einen nullable {@link Long}-Wert aus einer Spalte.
*
* @param rs das ResultSet
* @param columnName Spaltenname
* @return Wert oder {@code null}
* @throws SQLException bei JDBC-Lesefehlern
*/
private static Long readNullableLong(ResultSet rs, String columnName) throws SQLException {
long value = rs.getLong(columnName);
return rs.wasNull() ? null : value;
}
// -------------------------------------------------------------------------
@@ -377,7 +395,7 @@ public class SqliteHistoryQueryAdapter implements HistoryQueryPort {
* @throws SQLException wenn die Verbindung nicht hergestellt werden kann
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
return SqliteConnectionFactory.open(jdbcUrl);
}
/**
@@ -0,0 +1,259 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;
/**
* SQLite-Implementierung des {@link ModelPriceRepository}.
*
* <p>Persistiert Modell-Preise in der Tabelle {@code model_price}. Inserts und
* Updates erfolgen via {@code INSERT ... ON CONFLICT(provider, model_name)
* DO UPDATE SET ...}; Loeschungen via direktes {@code DELETE}. Der
* {@link #saveAllChanges(ModelPriceChangeSet) Batch-Pfad} faehrt eine
* JDBC-Transaktion mit {@code autoCommit=false}, ROLLBACKt bei Fehlern und
* COMMITet bei Erfolg.
*
* <p>Beim Lesen wird der DB-String {@code updated_at} als {@link Instant}
* geparst. Schlaegt das Parsing fehl, liefert die Adapter-Methode einen
* {@link ModelPriceView} mit {@code updatedAt=null} und
* {@code invalidUpdatedAt=true}; der Originalstring landet in
* {@code invalidUpdatedAtRaw}, damit GUI/CLI &quot;ung&uuml;ltig&quot; anzeigen
* koennen.
*/
public class SqliteModelPriceRepositoryAdapter implements ModelPriceRepository {
private static final Logger LOG = LogManager.getLogger(SqliteModelPriceRepositoryAdapter.class);
private static final String SQL_FIND_ALL = """
SELECT provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at
FROM model_price
ORDER BY provider, model_name
""";
private static final String SQL_FIND_BY_KEY = """
SELECT provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at
FROM model_price
WHERE provider = ? AND model_name = ?
""";
private static final String SQL_UPSERT = """
INSERT INTO model_price
(provider, model_name,
price_input_per_token_nano_usd, price_output_per_token_nano_usd,
currency, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(provider, model_name) DO UPDATE SET
price_input_per_token_nano_usd = excluded.price_input_per_token_nano_usd,
price_output_per_token_nano_usd = excluded.price_output_per_token_nano_usd,
currency = excluded.currency,
updated_at = excluded.updated_at
""";
private static final String SQL_DELETE = """
DELETE FROM model_price WHERE provider = ? AND model_name = ?
""";
private final String jdbcUrl;
/**
* Erzeugt den Adapter mit der JDBC-URL der Ziel-Datenbank.
*
* @param jdbcUrl JDBC-URL der SQLite-Datenbank; weder {@code null} noch leer
*/
public SqliteModelPriceRepositoryAdapter(String jdbcUrl) {
Objects.requireNonNull(jdbcUrl, "jdbcUrl");
if (jdbcUrl.isBlank()) {
throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
}
this.jdbcUrl = jdbcUrl;
}
/**
* Oeffnet eine neue Connection ueber die zentrale Connection-Factory.
*
* @return eingerichtete Connection
* @throws SQLException wenn der Verbindungsaufbau fehlschlaegt
*/
protected Connection getConnection() throws SQLException {
return SqliteConnectionFactory.open(jdbcUrl);
}
@Override
public List<ModelPriceView> findAll() {
List<ModelPriceView> result = new ArrayList<>();
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_FIND_ALL);
ResultSet rs = statement.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preise konnten nicht gelesen werden: " + e.getMessage(), e);
}
return List.copyOf(result);
}
@Override
public Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(modelName, "modelName");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_FIND_BY_KEY)) {
statement.setString(1, provider);
statement.setString(2, modelName);
try (ResultSet rs = statement.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Lookup fehlgeschlagen fuer (" + provider + ", " + modelName
+ "): " + e.getMessage(), e);
}
}
@Override
public void upsert(ModelPriceEntry entry) {
Objects.requireNonNull(entry, "entry");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_UPSERT)) {
bindUpsert(statement, entry);
statement.executeUpdate();
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Upsert fehlgeschlagen: " + e.getMessage(), e);
}
}
@Override
public void delete(String provider, String modelName) {
Objects.requireNonNull(provider, "provider");
Objects.requireNonNull(modelName, "modelName");
try (Connection connection = getConnection();
PreparedStatement statement = connection.prepareStatement(SQL_DELETE)) {
statement.setString(1, provider);
statement.setString(2, modelName);
statement.executeUpdate();
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Modell-Preis-Delete fehlgeschlagen: " + e.getMessage(), e);
}
}
@Override
public void saveAllChanges(ModelPriceChangeSet changeSet) {
Objects.requireNonNull(changeSet, "changeSet");
if (changeSet.isEmpty()) {
return;
}
try (Connection connection = getConnection()) {
connection.setAutoCommit(false);
try {
if (!changeSet.upserts().isEmpty()) {
try (PreparedStatement upsertStmt = connection.prepareStatement(SQL_UPSERT)) {
for (ModelPriceEntry entry : changeSet.upserts()) {
bindUpsert(upsertStmt, entry);
upsertStmt.executeUpdate();
}
}
}
if (!changeSet.deletions().isEmpty()) {
try (PreparedStatement deleteStmt = connection.prepareStatement(SQL_DELETE)) {
for (ModelPriceKey key : changeSet.deletions()) {
deleteStmt.setString(1, key.provider());
deleteStmt.setString(2, key.modelName());
deleteStmt.executeUpdate();
}
}
}
connection.commit();
} catch (SQLException txError) {
try {
connection.rollback();
} catch (SQLException rollbackError) {
LOG.error("Rollback nach Modell-Preis-Batch-Fehler ebenfalls fehlgeschlagen: {}",
rollbackError.getMessage(), rollbackError);
}
throw new ModelPriceRepositoryException(
"Modell-Preis-Batch konnte nicht persistiert werden: " + txError.getMessage(),
txError);
}
} catch (SQLException e) {
throw new ModelPriceRepositoryException(
"Datenbankverbindung fuer Modell-Preis-Batch fehlgeschlagen: " + e.getMessage(), e);
}
}
private static void bindUpsert(PreparedStatement statement, ModelPriceEntry entry) throws SQLException {
statement.setString(1, entry.provider());
statement.setString(2, entry.modelName());
statement.setLong(3, entry.priceInputPerTokenNanoUsd());
statement.setLong(4, entry.priceOutputPerTokenNanoUsd());
statement.setString(5, entry.currency());
statement.setString(6, entry.updatedAt().toString());
}
/**
* Liest eine Zeile in einen {@link ModelPriceView}.
*
* <p>Bei nicht parsebarem {@code updated_at} wird ein WARN-Log erzeugt
* und der View mit {@code updatedAt=null}, {@code invalidUpdatedAt=true}
* sowie dem Originalstring zurueckgegeben.
*
* @param rs aktueller ResultSet, dessen Cursor auf der Zielzeile steht
* @return Lesen-DTO
* @throws SQLException bei JDBC-Fehlern
*/
private static ModelPriceView mapRow(ResultSet rs) throws SQLException {
String provider = rs.getString("provider");
String modelName = rs.getString("model_name");
long priceIn = rs.getLong("price_input_per_token_nano_usd");
long priceOut = rs.getLong("price_output_per_token_nano_usd");
String currency = rs.getString("currency");
String updatedAtRaw = rs.getString("updated_at");
Instant updatedAt = null;
boolean invalid = false;
String invalidRaw = null;
try {
updatedAt = Instant.parse(updatedAtRaw);
} catch (DateTimeParseException ex) {
LOG.warn("updated_at konnte fuer (Provider={}, Modell={}) nicht geparst werden: \"{}\"",
provider, modelName, updatedAtRaw);
invalid = true;
invalidRaw = updatedAtRaw;
} catch (NullPointerException ex) {
LOG.warn("updated_at war fuer (Provider={}, Modell={}) NULL als ungueltig markiert",
provider, modelName);
invalid = true;
invalidRaw = null;
}
return new ModelPriceView(provider, modelName, priceIn, priceOut, currency, updatedAt,
invalidRaw, invalid);
}
}
@@ -1,7 +1,6 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
@@ -144,8 +143,14 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
resolved_date,
date_source,
validated_title,
final_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
final_target_file_name,
input_tokens,
output_tokens,
cache_creation_input_tokens,
cache_read_input_tokens,
price_input_per_token_nano_usd,
price_output_per_token_nano_usd
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = getConnection();
@@ -178,6 +183,13 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
attempt.dateSource() != null ? attempt.dateSource().name() : null);
setNullableString(statement, 19, attempt.validatedTitle());
setNullableString(statement, 20, attempt.finalTargetFileName());
// Token- und Preis-Snapshot-Felder; alle nullable
setNullableLong(statement, 21, attempt.inputTokens());
setNullableLong(statement, 22, attempt.outputTokens());
setNullableLong(statement, 23, attempt.cacheCreationInputTokens());
setNullableLong(statement, 24, attempt.cacheReadInputTokens());
setNullableLong(statement, 25, attempt.priceInputPerTokenNanoUsd());
setNullableLong(statement, 26, attempt.priceOutputPerTokenNanoUsd());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
@@ -406,6 +418,23 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
}
}
/**
* Setzt einen nullable {@link Long}-Wert auf einem PreparedStatement.
*
* @param stmt das Statement
* @param index 1-basierter Parameter-Index
* @param value Wert oder {@code null}
* @throws SQLException bei JDBC-Fehlern
*/
private static void setNullableLong(PreparedStatement stmt, int index, Long value)
throws SQLException {
if (value == null) {
stmt.setNull(index, Types.BIGINT);
} else {
stmt.setLong(index, value);
}
}
private static Object getNullableInt(ResultSet rs, String column) throws SQLException {
int value = rs.getInt(column);
return rs.wasNull() ? null : value;
@@ -457,6 +486,6 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
* Returns a JDBC connection. May be overridden in tests to provide shared connections.
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
return SqliteConnectionFactory.open(jdbcUrl);
}
}
@@ -82,7 +82,18 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
"last_target_path", "last_target_file_name"
);
/** Alle erwarteten Spalten der Tabelle {@code processing_attempt}. */
/**
* Alle erwarteten Spalten der Tabelle {@code processing_attempt} im
* V1-Zielschema.
*
* <p>Dies ist der minimale Zielzustand nach {@code V1__initial_schema}.
* Spaetere Migrationen (z.B. {@code V2__token_tracking}) ergaenzen
* additiv weitere Spalten; das Vorhandensein dieser zusaetzlichen Spalten
* vor dem Baseline-Eintrag ist <strong>kein</strong> Konformitaetskriterium,
* weil die Schema-Pruefung in Fall 2 ausschlie&szlig;lich gegen das
* V1-Schema arbeitet. Die V2-Spalten werden nach der Baseline-Eintragung
* durch Flyway ergaenzt.
*/
private static final Set<String> EXPECTED_COLUMNS_PROCESSING_ATTEMPT = Set.of(
"id", COL_FINGERPRINT, "run_id", "attempt_number", "started_at", "ended_at",
"status", "failure_class", "failure_message", "retryable",
@@ -91,7 +102,12 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
"validated_title", "final_target_file_name", "ai_provider"
);
/** Erwartete Indizes. */
/**
* Erwartete Indizes nach {@code V1__initial_schema}.
*
* <p>Spaetere Migrationen koennen additiv weitere Indizes anlegen; sie
* sind kein Konformitaetskriterium fuer Fall 2.
*/
private static final Set<String> EXPECTED_INDEXES = Set.of(
"idx_processing_attempt_fingerprint",
"idx_processing_attempt_run_id",
@@ -3,7 +3,6 @@ package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Objects;
import java.util.function.Consumer;
@@ -43,7 +42,7 @@ public class SqliteUnitOfWorkAdapter implements UnitOfWorkPort {
public void executeInTransaction(Consumer<TransactionOperations> operations) {
Objects.requireNonNull(operations, "operations must not be null");
try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
try (Connection connection = SqliteConnectionFactory.open(jdbcUrl)) {
connection.setAutoCommit(false);
try {
@@ -0,0 +1,64 @@
-- V2: Token-Erfassung mit Preis-Snapshot in processing_attempt;
-- neue model_price-Tabelle mit Composite Primary Key.
-- Verifizierter Stand: V1__initial_schema.sql ist die einzige bisherige
-- Migration im Projekt.
ALTER TABLE processing_attempt
ADD COLUMN input_tokens INTEGER
CHECK (input_tokens IS NULL OR (input_tokens >= 0 AND input_tokens <= 10000000));
ALTER TABLE processing_attempt
ADD COLUMN output_tokens INTEGER
CHECK (output_tokens IS NULL OR (output_tokens >= 0 AND output_tokens <= 10000000));
ALTER TABLE processing_attempt
ADD COLUMN cache_creation_input_tokens INTEGER
CHECK (cache_creation_input_tokens IS NULL OR (cache_creation_input_tokens >= 0 AND cache_creation_input_tokens <= 10000000));
ALTER TABLE processing_attempt
ADD COLUMN cache_read_input_tokens INTEGER
CHECK (cache_read_input_tokens IS NULL OR (cache_read_input_tokens >= 0 AND cache_read_input_tokens <= 10000000));
ALTER TABLE processing_attempt
ADD COLUMN price_input_per_token_nano_usd INTEGER
CHECK (price_input_per_token_nano_usd IS NULL OR (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000));
ALTER TABLE processing_attempt
ADD COLUMN price_output_per_token_nano_usd INTEGER
CHECK (price_output_per_token_nano_usd IS NULL OR (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000));
CREATE TABLE model_price (
provider TEXT NOT NULL,
model_name TEXT NOT NULL,
price_input_per_token_nano_usd INTEGER NOT NULL CHECK (price_input_per_token_nano_usd >= 0 AND price_input_per_token_nano_usd <= 100000000),
price_output_per_token_nano_usd INTEGER NOT NULL CHECK (price_output_per_token_nano_usd >= 0 AND price_output_per_token_nano_usd <= 100000000),
currency TEXT NOT NULL DEFAULT 'USD' CHECK (currency = 'USD'),
updated_at TEXT NOT NULL,
PRIMARY KEY (provider, model_name)
);
CREATE INDEX idx_processing_attempt_started_at_provider_fp_model
ON processing_attempt (started_at, ai_provider, fingerprint, model_name);
CREATE INDEX idx_processing_attempt_run_id_provider_model
ON processing_attempt (run_id, ai_provider, model_name);
-- Default-Preise (Stand 2026-05-08, in Nano-USD pro Token)
-- Quellen (abgerufen 2026-05-08):
-- OpenAI: https://openai.com/api/pricing/
-- Anthropic: https://www.anthropic.com/pricing
-- ON CONFLICT DO NOTHING: schuetzt vor manuell vorhandenen Default-Zeilen.
INSERT INTO model_price
(provider, model_name, price_input_per_token_nano_usd, price_output_per_token_nano_usd, currency, updated_at)
VALUES
('openai-compatible', 'gpt-4o-mini', 150, 600, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-4o', 2500, 10000, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-4.1', 2000, 8000, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-4.1-mini', 400, 1600, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-4.1-nano', 100, 400, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-5', 1250, 10000, 'USD', '2026-05-08T00:00:00Z'),
('openai-compatible', 'gpt-5-mini', 250, 2000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-haiku-4-5-20251001', 1000, 5000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-sonnet-4-6', 3000, 15000, 'USD', '2026-05-08T00:00:00Z'),
('claude', 'claude-opus-4-7', 5000, 25000, 'USD', '2026-05-08T00:00:00Z')
ON CONFLICT (provider, model_name) DO NOTHING;
@@ -88,7 +88,11 @@ class SqliteSchemaInitializationAdapterTest {
"status", "failure_class", "failure_message", "retryable",
"model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
"ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
"validated_title", "final_target_file_name", "ai_provider"
"validated_title", "final_target_file_name", "ai_provider",
// Token- und Preis-Spalten ergaenzt durch V2__token_tracking
"input_tokens", "output_tokens",
"cache_creation_input_tokens", "cache_read_input_tokens",
"price_input_per_token_nano_usd", "price_output_per_token_nano_usd"
);
}