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