diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 36fa764..adbc757 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -33,7 +33,8 @@ "Bash(NLS_LANG=GERMAN_GERMANY.AL32UTF8 sql *)", "Bash(KUBECONFIG=/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml kubectl logs *)", "Bash(KUBECONFIG=/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml kubectl get pods -n ai-env -l app=dc-backend)", - "Bash(chmod +x *)" + "Bash(chmod +x *)", + "Bash(JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 mvn compile -q)" ] } } diff --git a/Scripts/KI-Kostenverfolgung.sql b/Scripts/KI-Kostenverfolgung.sql new file mode 100644 index 0000000..cbca21b --- /dev/null +++ b/Scripts/KI-Kostenverfolgung.sql @@ -0,0 +1,143 @@ +-- ============================================================================= +-- KI-Kostenverfolgung für Dokumenten-Check +-- Tabelle: dc_ai_cost_log +-- Package: DC_COSTS_PKG +-- ============================================================================= +-- Deployment: +-- sqlplus user/pass@db @"Scripts/KI-Kostenverfolgung.sql" +-- +-- Skript ist idempotent (DROP IF EXISTS vor jedem CREATE). +-- ============================================================================= + + +-- ============================================================================= +-- Tabelle: dc_ai_cost_log +-- ============================================================================= +BEGIN + EXECUTE IMMEDIATE 'DROP TABLE dc_ai_cost_log CASCADE CONSTRAINTS'; +EXCEPTION WHEN OTHERS THEN NULL; +END; +/ + +CREATE TABLE dc_ai_cost_log ( + id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + called_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + provider VARCHAR2(30) NOT NULL, -- mistral | ollama + model_name VARCHAR2(100) NOT NULL, + operation VARCHAR2(30) NOT NULL, -- TRANSLATE | EVALUATE | OCR | CATALOG + project_id NUMBER, + catalog_id NUMBER, + prompt_tokens NUMBER DEFAULT 0 NOT NULL, + completion_tokens NUMBER DEFAULT 0 NOT NULL, + total_tokens NUMBER DEFAULT 0 NOT NULL, + cost_eur NUMBER(14, 8) DEFAULT 0 NOT NULL, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + created_by VARCHAR2(200) DEFAULT 'SYSTEM' NOT NULL +); + +COMMENT ON TABLE dc_ai_cost_log IS 'Protokoll aller KI-API-Aufrufe mit Token-Verbrauch und Kosten'; +COMMENT ON COLUMN dc_ai_cost_log.operation IS 'TRANSLATE, EVALUATE, OCR, CATALOG'; +COMMENT ON COLUMN dc_ai_cost_log.cost_eur IS 'Berechnete Kosten in EUR basierend auf konfigurierten Preisen'; + +CREATE INDEX idx_ai_cost_called_at ON dc_ai_cost_log (called_at); +CREATE INDEX idx_ai_cost_project_id ON dc_ai_cost_log (project_id); +CREATE INDEX idx_ai_cost_catalog_id ON dc_ai_cost_log (catalog_id); +CREATE INDEX idx_ai_cost_ym ON dc_ai_cost_log (TO_CHAR(called_at, 'YYYY-MM')); + + +-- ============================================================================= +-- PL/SQL Package: DC_COSTS_PKG +-- ============================================================================= +CREATE OR REPLACE PACKAGE DC_COSTS_PKG AS + + -- ------------------------------------------------------------------------- + -- Gesamtkosten + Kosten des laufenden Monats + -- ------------------------------------------------------------------------- + PROCEDURE get_summary( + p_total_eur OUT NUMBER, + p_total_tokens OUT NUMBER, + p_total_calls OUT NUMBER, + p_month_eur OUT NUMBER, + p_month_tokens OUT NUMBER, + p_month_calls OUT NUMBER + ); + + -- ------------------------------------------------------------------------- + -- Kosten pro Monat + Modell (für APEX-Reports / direkte Abfragen) + -- Gibt einen REF CURSOR zurück (Spalten: month, provider, model_name, + -- operation, call_count, prompt_tokens, completion_tokens, total_tokens, cost_eur) + -- ------------------------------------------------------------------------- + FUNCTION get_monthly_detail RETURN SYS_REFCURSOR; + +END DC_COSTS_PKG; +/ + +CREATE OR REPLACE PACKAGE BODY DC_COSTS_PKG AS + + PROCEDURE get_summary( + p_total_eur OUT NUMBER, + p_total_tokens OUT NUMBER, + p_total_calls OUT NUMBER, + p_month_eur OUT NUMBER, + p_month_tokens OUT NUMBER, + p_month_calls OUT NUMBER + ) IS + BEGIN + -- Gesamtkosten (all time) + SELECT NVL(SUM(cost_eur), 0), + NVL(SUM(total_tokens), 0), + COUNT(*) + INTO p_total_eur, p_total_tokens, p_total_calls + FROM dc_ai_cost_log; + + -- Laufender Monat + SELECT NVL(SUM(cost_eur), 0), + NVL(SUM(total_tokens), 0), + COUNT(*) + INTO p_month_eur, p_month_tokens, p_month_calls + FROM dc_ai_cost_log + WHERE called_at >= TRUNC(SYSDATE, 'MM'); + + EXCEPTION + WHEN NO_DATA_FOUND THEN + p_total_eur := 0; p_total_tokens := 0; p_total_calls := 0; + p_month_eur := 0; p_month_tokens := 0; p_month_calls := 0; + END get_summary; + + + FUNCTION get_monthly_detail RETURN SYS_REFCURSOR IS + v_cur SYS_REFCURSOR; + BEGIN + OPEN v_cur FOR + SELECT + TO_CHAR(called_at, 'YYYY-MM') AS month, + provider, + model_name, + operation, + COUNT(*) AS call_count, + SUM(prompt_tokens) AS prompt_tokens, + SUM(completion_tokens) AS completion_tokens, + SUM(total_tokens) AS total_tokens, + ROUND(SUM(cost_eur), 6) AS cost_eur + FROM dc_ai_cost_log + WHERE called_at >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -11) + GROUP BY TO_CHAR(called_at, 'YYYY-MM'), provider, model_name, operation + ORDER BY month DESC, cost_eur DESC; + RETURN v_cur; + END get_monthly_detail; + +END DC_COSTS_PKG; +/ + +-- ============================================================================= +-- Kurztest (optionale Verifikation nach Deployment) +-- ============================================================================= +-- DECLARE +-- v_te NUMBER; v_tt NUMBER; v_tc NUMBER; +-- v_me NUMBER; v_mt NUMBER; v_mc NUMBER; +-- BEGIN +-- DC_COSTS_PKG.get_summary(v_te,v_tt,v_tc,v_me,v_mt,v_mc); +-- DBMS_OUTPUT.PUT_LINE('Total EUR: ' || v_te || ', Calls: ' || v_tc); +-- DBMS_OUTPUT.PUT_LINE('Month EUR: ' || v_me || ', Calls: ' || v_mc); +-- END; +-- / diff --git a/Scripts/ORDS REST Services.sql b/Scripts/ORDS REST Services.sql index 8636e75..c1016eb 100644 --- a/Scripts/ORDS REST Services.sql +++ b/Scripts/ORDS REST Services.sql @@ -1026,6 +1026,136 @@ BEGIN ]' ); + -- =========================================================================== + -- TEMPLATE 17: ai-costs (POST = Eintrag speichern, GET = Auswertung) + -- =========================================================================== + ORDS.DEFINE_TEMPLATE( + p_module_name => 'frigosped.dc', + p_pattern => 'ai-costs', + p_priority => 0, + p_comments => 'KI-Kostenprotokoll: POST speichert Aufruf, GET liefert Auswertung' + ); + + -- 17a. POST /api/dc/ai-costs + -- Body: {"provider":"mistral","model_name":"mistral-small-latest","operation":"TRANSLATE", + -- "project_id":42,"catalog_id":null, + -- "prompt_tokens":1234,"completion_tokens":567,"total_tokens":1801,"cost_eur":0.000157} + ORDS.DEFINE_HANDLER( + p_module_name => 'frigosped.dc', + p_pattern => 'ai-costs', + p_method => 'POST', + p_source_type => ORDS.SOURCE_TYPE_PLSQL, + p_comments => '201 Created mit neuer cost_log id', + p_source => q'[ + DECLARE + v_body CLOB; + v_new_id NUMBER; + BEGIN + v_body := :body_text; + + INSERT INTO dc_ai_cost_log ( + provider, model_name, operation, + project_id, catalog_id, + prompt_tokens, completion_tokens, total_tokens, + cost_eur + ) VALUES ( + JSON_VALUE(v_body, '$.provider'), + JSON_VALUE(v_body, '$.model_name'), + JSON_VALUE(v_body, '$.operation'), + TO_NUMBER(JSON_VALUE(v_body, '$.project_id')), + TO_NUMBER(JSON_VALUE(v_body, '$.catalog_id')), + NVL(TO_NUMBER(JSON_VALUE(v_body, '$.prompt_tokens')), 0), + NVL(TO_NUMBER(JSON_VALUE(v_body, '$.completion_tokens')), 0), + NVL(TO_NUMBER(JSON_VALUE(v_body, '$.total_tokens')), 0), + NVL(TO_NUMBER(JSON_VALUE(v_body, '$.cost_eur')), 0) + ) + RETURNING id INTO v_new_id; + + :status_code := 201; + HTP.P('{"id":' || v_new_id || '}'); + + EXCEPTION + WHEN OTHERS THEN + ROLLBACK; + :status_code := 500; + HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}'); + END; + ]' + ); + + -- 17b. GET /api/dc/ai-costs + -- Liefert: Gesamtkosten, laufender Monat, Monats-Aufschlüsselung (12 Monate) + ORDS.DEFINE_HANDLER( + p_module_name => 'frigosped.dc', + p_pattern => 'ai-costs', + p_method => 'GET', + p_source_type => ORDS.SOURCE_TYPE_PLSQL, + p_comments => 'Kostenauswertung: total, current_month, monatliche Aufschluesselung via DC_COSTS_PKG', + p_source => q'[ + DECLARE + v_total_eur NUMBER; + v_total_tokens NUMBER; + v_total_calls NUMBER; + v_month_eur NUMBER; + v_month_tokens NUMBER; + v_month_calls NUMBER; + v_monthly_json CLOB; + BEGIN + DC_COSTS_PKG.get_summary( + v_total_eur, v_total_tokens, v_total_calls, + v_month_eur, v_month_tokens, v_month_calls + ); + + SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'month' VALUE month, + 'cost_eur' VALUE ROUND(cost_eur, 6), + 'prompt_tokens' VALUE prompt_tokens, + 'completion_tokens' VALUE completion_tokens, + 'total_tokens' VALUE total_tokens, + 'call_count' VALUE call_count + ) + ORDER BY month DESC + RETURNING CLOB + ) + INTO v_monthly_json + FROM ( + SELECT + TO_CHAR(called_at, 'YYYY-MM') AS month, + SUM(cost_eur) AS cost_eur, + SUM(prompt_tokens) AS prompt_tokens, + SUM(completion_tokens) AS completion_tokens, + SUM(total_tokens) AS total_tokens, + COUNT(*) AS call_count + FROM dc_ai_cost_log + WHERE called_at >= ADD_MONTHS(TRUNC(SYSDATE, 'MM'), -11) + GROUP BY TO_CHAR(called_at, 'YYYY-MM') + ); + + :status_code := 200; + HTP.P( + '{"total":{' + || '"cost_eur":' || ROUND(NVL(v_total_eur, 0), 6) + || ',"total_tokens":'|| NVL(v_total_tokens, 0) + || ',"call_count":' || NVL(v_total_calls, 0) + || '},"current_month":{' + || '"month":"' || TO_CHAR(SYSDATE, 'YYYY-MM') || '"' + || ',"cost_eur":' || ROUND(NVL(v_month_eur, 0), 6) + || ',"total_tokens":'|| NVL(v_month_tokens, 0) + || ',"call_count":' || NVL(v_month_calls, 0) + || '},"monthly":' || NVL(v_monthly_json, '[]') + || '}' + ); + + EXCEPTION + WHEN OTHERS THEN + :status_code := 500; + HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}'); + END; + ]' + ); + + COMMIT; END; / diff --git a/dc-backend/pom.xml b/dc-backend/pom.xml index 5583b63..92c5416 100644 --- a/dc-backend/pom.xml +++ b/dc-backend/pom.xml @@ -73,6 +73,11 @@ langchain4j-ollama ${langchain4j.version} + + dev.langchain4j + langchain4j-mistral-ai + ${langchain4j.version} + diff --git a/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java b/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java index 7ce042e..630b392 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java +++ b/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java @@ -1,6 +1,7 @@ package de.frigosped.dc.ai; import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.mistralai.MistralAiChatModel; import dev.langchain4j.model.ollama.OllamaChatModel; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; @@ -10,29 +11,42 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import java.time.Duration; import java.util.Map; -/** - * CDI-Producer für die zwei KI-Modelle. - * - * Beide Modelle sprechen denselben Ollama-kompatiblen Endpunkt an, - * unterscheiden sich aber in Modellname und Timeout. - * Der X-API-KEY-Header wird über customHeaders gesetzt. - * - * Einsatz: - * @Inject @Named("main") ChatLanguageModel mainModel → Auswertung + Übersetzung - * @Inject @Named("ocr") ChatLanguageModel ocrModel → PDF-OCR (Vision) - */ @ApplicationScoped public class AiModelProducer { - @ConfigProperty(name = "dc.ai.base-url") - String baseUrl; + @ConfigProperty(name = "dc.ai.provider", defaultValue = "ollama") + String provider; - @ConfigProperty(name = "dc.ai.api-key") - String apiKey; + // --- Ollama --- + @ConfigProperty(name = "dc.ai.ollama.base-url", defaultValue = "") + String ollamaBaseUrl; - @ConfigProperty(name = "dc.ai.main.model") - String mainModelName; + @ConfigProperty(name = "dc.ai.ollama.api-key", defaultValue = "") + String ollamaApiKey; + @ConfigProperty(name = "dc.ai.ollama.main.model", defaultValue = "") + String ollamaMainModel; + + @ConfigProperty(name = "dc.ai.ollama.catalog.model", defaultValue = "") + String ollamaCatalogModel; + + @ConfigProperty(name = "dc.ai.ollama.ocr.model", defaultValue = "") + String ollamaOcrModel; + + // --- Mistral --- + @ConfigProperty(name = "dc.ai.mistral.api-key", defaultValue = "") + String mistralApiKey; + + @ConfigProperty(name = "dc.ai.mistral.main.model", defaultValue = "mistral-small-latest") + String mistralMainModel; + + @ConfigProperty(name = "dc.ai.mistral.catalog.model", defaultValue = "mistral-small-latest") + String mistralCatalogModel; + + @ConfigProperty(name = "dc.ai.mistral.ocr.model", defaultValue = "pixtral-12b-2409") + String mistralOcrModel; + + // --- Gemeinsame Parameter --- @ConfigProperty(name = "dc.ai.main.timeout", defaultValue = "300s") String mainTimeout; @@ -42,9 +56,6 @@ public class AiModelProducer { @ConfigProperty(name = "dc.ai.main.temperature", defaultValue = "0.1") double mainTemperature; - @ConfigProperty(name = "dc.ai.catalog.model") - String catalogModelName; - @ConfigProperty(name = "dc.ai.catalog.timeout", defaultValue = "1200s") String catalogTimeout; @@ -54,9 +65,6 @@ public class AiModelProducer { @ConfigProperty(name = "dc.ai.catalog.temperature", defaultValue = "0.1") double catalogTemperature; - @ConfigProperty(name = "dc.ai.ocr.model") - String ocrModelName; - @ConfigProperty(name = "dc.ai.ocr.timeout", defaultValue = "120s") String ocrTimeout; @@ -69,68 +77,89 @@ public class AiModelProducer { @ConfigProperty(name = "dc.ai.log-responses", defaultValue = "false") boolean logResponses; - /** - * Hauptmodell: Auswertung der Fragen + Übersetzung - * Modell: gpt-oss:20b - */ @Produces @ApplicationScoped @Named("main") public ChatLanguageModel mainModel() { + if (isMistral()) { + return MistralAiChatModel.builder() + .apiKey(mistralApiKey) + .modelName(mistralMainModel) + .temperature(mainTemperature) + .timeout(parseDuration(mainTimeout)) + .maxRetries(mainMaxRetries) + .logRequests(logRequests) + .logResponses(logResponses) + .build(); + } return OllamaChatModel.builder() - .baseUrl(baseUrl) - .modelName(mainModelName) + .baseUrl(ollamaBaseUrl) + .modelName(ollamaMainModel) .temperature(mainTemperature) .timeout(parseDuration(mainTimeout)) .maxRetries(mainMaxRetries) - .customHeaders(Map.of("X-API-KEY", apiKey)) + .customHeaders(Map.of("X-API-KEY", ollamaApiKey)) .logRequests(logRequests) .logResponses(logResponses) .build(); } - /** - * Katalog-Modell: Vollständige Katalog-Generierung aus Regelwerk-Dokument. - * Längerer Timeout (1200s) notwendig da komplette Dokumente analysiert werden. - */ @Produces @ApplicationScoped @Named("catalog") public ChatLanguageModel catalogModel() { + if (isMistral()) { + return MistralAiChatModel.builder() + .apiKey(mistralApiKey) + .modelName(mistralCatalogModel) + .temperature(catalogTemperature) + .timeout(parseDuration(catalogTimeout)) + .maxRetries(catalogMaxRetries) + .logRequests(logRequests) + .logResponses(logResponses) + .build(); + } return OllamaChatModel.builder() - .baseUrl(baseUrl) - .modelName(catalogModelName) + .baseUrl(ollamaBaseUrl) + .modelName(ollamaCatalogModel) .temperature(catalogTemperature) .timeout(parseDuration(catalogTimeout)) .maxRetries(catalogMaxRetries) - .customHeaders(Map.of("X-API-KEY", apiKey)) + .customHeaders(Map.of("X-API-KEY", ollamaApiKey)) .logRequests(logRequests) .logResponses(logResponses) .build(); } - /** - * OCR-Modell: Bildtext-Extraktion aus PDF-Seiten (Vision-Modus) - * Modell: quen3.5:9b - */ @Produces @ApplicationScoped @Named("ocr") public ChatLanguageModel ocrModel() { + if (isMistral()) { + return MistralAiChatModel.builder() + .apiKey(mistralApiKey) + .modelName(mistralOcrModel) + .timeout(parseDuration(ocrTimeout)) + .maxRetries(ocrMaxRetries) + .logRequests(logRequests) + .logResponses(logResponses) + .build(); + } return OllamaChatModel.builder() - .baseUrl(baseUrl) - .modelName(ocrModelName) + .baseUrl(ollamaBaseUrl) + .modelName(ollamaOcrModel) .timeout(parseDuration(ocrTimeout)) .maxRetries(ocrMaxRetries) - .customHeaders(Map.of("X-API-KEY", apiKey)) + .customHeaders(Map.of("X-API-KEY", ollamaApiKey)) .logRequests(logRequests) .logResponses(logResponses) .build(); } - /** - * Parst Timeout-Strings wie "300s", "5m", "120s" in Duration. - */ + private boolean isMistral() { + return "mistral".equalsIgnoreCase(provider); + } + private Duration parseDuration(String value) { if (value.endsWith("s")) { return Duration.ofSeconds(Long.parseLong(value.replace("s", ""))); diff --git a/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java b/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java index 16c35af..f7b5550 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java +++ b/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java @@ -179,4 +179,16 @@ public interface OrdsClient { @DELETE @Path("/projects/{projectId}/results") Response deleteResults(@PathParam("projectId") long projectId); + + // ========================================================================= + // KI-Kostenverfolgung + // ========================================================================= + + /** + * POST /api/dc/ai-costs + * Speichert einen KI-Aufruf mit Token-Verbrauch und Kosten in dc_ai_cost_log. + */ + @POST + @Path("/ai-costs") + Response logAiCost(AiCostRequest body); } diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/AiCostRequest.java b/dc-backend/src/main/java/de/frigosped/dc/model/AiCostRequest.java new file mode 100644 index 0000000..69858d9 --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/model/AiCostRequest.java @@ -0,0 +1,41 @@ +package de.frigosped.dc.model; + +public class AiCostRequest { + + private String provider; + private String modelName; + private String operation; + private Long projectId; + private Long catalogId; + private int promptTokens; + private int completionTokens; + private int totalTokens; + private double costEur; + + public String getProvider() { return provider; } + public void setProvider(String v) { this.provider = v; } + + public String getModelName() { return modelName; } + public void setModelName(String v) { this.modelName = v; } + + public String getOperation() { return operation; } + public void setOperation(String v) { this.operation = v; } + + public Long getProjectId() { return projectId; } + public void setProjectId(Long v) { this.projectId = v; } + + public Long getCatalogId() { return catalogId; } + public void setCatalogId(Long v) { this.catalogId = v; } + + public int getPromptTokens() { return promptTokens; } + public void setPromptTokens(int v) { this.promptTokens = v; } + + public int getCompletionTokens() { return completionTokens; } + public void setCompletionTokens(int v) { this.completionTokens = v; } + + public int getTotalTokens() { return totalTokens; } + public void setTotalTokens(int v) { this.totalTokens = v; } + + public double getCostEur() { return costEur; } + public void setCostEur(double v) { this.costEur = v; } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/AiCostTrackerService.java b/dc-backend/src/main/java/de/frigosped/dc/service/AiCostTrackerService.java new file mode 100644 index 0000000..16ebd68 --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/service/AiCostTrackerService.java @@ -0,0 +1,112 @@ +package de.frigosped.dc.service; + +import de.frigosped.dc.client.OrdsClient; +import de.frigosped.dc.model.AiCostRequest; +import dev.langchain4j.model.output.TokenUsage; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; + +import java.util.concurrent.CompletableFuture; + +/** + * Erfasst Token-Verbrauch und Kosten für jeden KI-Aufruf und persistiert + * sie asynchron via ORDS in dc_ai_cost_log. + * + * Aufruf-Muster (qualifier = "main" | "catalog" | "ocr"): + * costTracker.track("main", "TRANSLATE", projectId, null, response.tokenUsage()); + */ +@ApplicationScoped +public class AiCostTrackerService { + + private static final Logger LOG = Logger.getLogger(AiCostTrackerService.class); + + @ConfigProperty(name = "dc.ai.provider", defaultValue = "ollama") + String provider; + + // Modellnamen je Provider + @ConfigProperty(name = "dc.ai.ollama.main.model", defaultValue = "") String ollamaMainModel; + @ConfigProperty(name = "dc.ai.ollama.catalog.model", defaultValue = "") String ollamaCatalogModel; + @ConfigProperty(name = "dc.ai.ollama.ocr.model", defaultValue = "") String ollamaOcrModel; + + @ConfigProperty(name = "dc.ai.mistral.main.model", defaultValue = "mistral-small-latest") String mistralMainModel; + @ConfigProperty(name = "dc.ai.mistral.catalog.model", defaultValue = "mistral-small-latest") String mistralCatalogModel; + @ConfigProperty(name = "dc.ai.mistral.ocr.model", defaultValue = "pixtral-12b-2409") String mistralOcrModel; + + // Preise in EUR pro 1 Mio. Tokens (Mistral; Ollama = 0) + @ConfigProperty(name = "dc.ai.mistral.main.price-input-per-1m-eur", defaultValue = "0.092") double mistralMainInput; + @ConfigProperty(name = "dc.ai.mistral.main.price-output-per-1m-eur", defaultValue = "0.276") double mistralMainOutput; + @ConfigProperty(name = "dc.ai.mistral.catalog.price-input-per-1m-eur", defaultValue = "0.092") double mistralCatalogInput; + @ConfigProperty(name = "dc.ai.mistral.catalog.price-output-per-1m-eur",defaultValue = "0.276") double mistralCatalogOutput; + @ConfigProperty(name = "dc.ai.mistral.ocr.price-input-per-1m-eur", defaultValue = "0.138") double mistralOcrInput; + @ConfigProperty(name = "dc.ai.mistral.ocr.price-output-per-1m-eur", defaultValue = "0.138") double mistralOcrOutput; + + @Inject + @RestClient + OrdsClient ordsClient; + + /** + * @param qualifier "main" | "catalog" | "ocr" + * @param operation "TRANSLATE" | "EVALUATE" | "OCR" | "CATALOG" + * @param projectId Projekt-ID (null bei Katalog-Operationen) + * @param catalogId Katalog-ID (null bei Prüf-Operationen) + * @param tokenUsage aus response.tokenUsage() – null wird als 0 behandelt + */ + public void track(String qualifier, String operation, + Long projectId, Long catalogId, TokenUsage tokenUsage) { + + int promptTokens = safeCount(tokenUsage != null ? tokenUsage.inputTokenCount() : null); + int completionTokens = safeCount(tokenUsage != null ? tokenUsage.outputTokenCount() : null); + int totalTokens = safeCount(tokenUsage != null ? tokenUsage.totalTokenCount() : null); + double costEur = calcCost(qualifier, promptTokens, completionTokens); + + AiCostRequest req = new AiCostRequest(); + req.setProvider(provider); + req.setModelName(resolveModel(qualifier)); + req.setOperation(operation); + req.setProjectId(projectId); + req.setCatalogId(catalogId); + req.setPromptTokens(promptTokens); + req.setCompletionTokens(completionTokens); + req.setTotalTokens(totalTokens); + req.setCostEur(costEur); + + CompletableFuture.runAsync(() -> { + try { + ordsClient.logAiCost(req); + LOG.debugf("Kosten erfasst: %s/%s %.6f EUR (%d tokens)", + qualifier, operation, costEur, totalTokens); + } catch (Exception e) { + LOG.warnf("Kostenverfolgung fehlgeschlagen (%s/%s): %s", + qualifier, operation, e.getMessage()); + } + }); + } + + private String resolveModel(String qualifier) { + boolean isMistral = "mistral".equalsIgnoreCase(provider); + return switch (qualifier) { + case "catalog" -> isMistral ? mistralCatalogModel : ollamaCatalogModel; + case "ocr" -> isMistral ? mistralOcrModel : ollamaOcrModel; + default -> isMistral ? mistralMainModel : ollamaMainModel; + }; + } + + private double calcCost(String qualifier, int prompt, int completion) { + if (!"mistral".equalsIgnoreCase(provider)) return 0.0; + double inPrice, outPrice; + switch (qualifier) { + case "ocr" -> { inPrice = mistralOcrInput; outPrice = mistralOcrOutput; } + case "catalog" -> { inPrice = mistralCatalogInput; outPrice = mistralCatalogOutput; } + default -> { inPrice = mistralMainInput; outPrice = mistralMainOutput; } + } + return (prompt / 1_000_000.0) * inPrice + + (completion / 1_000_000.0) * outPrice; + } + + private int safeCount(Integer value) { + return value != null ? value : 0; + } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java b/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java index be02237..c1eefcd 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java +++ b/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java @@ -56,6 +56,9 @@ public class CatalogGenerationService { @Named("catalog") ChatLanguageModel catalogModel; + @Inject + AiCostTrackerService costTracker; + @Inject ObjectMapper objectMapper; @@ -106,7 +109,7 @@ public class CatalogGenerationService { catalogId, chunkNr, totalChunks, chunks.get(i).length()); GeneratedCatalogStructure chunkResult = - generateStructureFromChunk(catalogName, chunks.get(i), chunkNr, totalChunks); + generateStructureFromChunk(catalogName, chunks.get(i), chunkNr, totalChunks, catalogId); if (chunkResult == null || chunkResult.getCategories() == null) { LOG.warnf("[Katalog %d] Chunk %d lieferte kein Ergebnis – übersprungen", @@ -316,7 +319,8 @@ public class CatalogGenerationService { private GeneratedCatalogStructure generateStructureFromChunk(String catalogName, String chunkText, int chunkNr, - int totalChunks) { + int totalChunks, + long catalogId) { List messages = List.of( SystemMessage.from(buildChunkSystemPrompt(chunkNr, totalChunks)), UserMessage.from( @@ -327,6 +331,7 @@ public class CatalogGenerationService { ); Response response = catalogModel.generate(messages); + costTracker.track("catalog", "CATALOG", null, catalogId, response.tokenUsage()); String rawJson = response.content().text(); return parseCatalogStructure(rawJson, catalogName + " Chunk " + chunkNr); } diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java b/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java index e6e5e8e..4645502 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java +++ b/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java @@ -151,11 +151,11 @@ public class CheckOrchestrationService { byte[] fileBytes = fileResp.readEntity(byte[].class); originalText = documentProcessingService.extractText( - fileBytes, doc.getMimeType(), doc.getFilename()); + fileBytes, doc.getMimeType(), doc.getFilename(), projectId); LOG.debugf("Dokument %d: %d Zeichen extrahiert", (long) doc.getId(), (long) originalText.length()); - translatedText = evaluationService.translate(originalText); + translatedText = evaluationService.translate(originalText, projectId); LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)", (long) doc.getId(), (long) translatedText.length()); @@ -204,7 +204,7 @@ public class CheckOrchestrationService { LOG.debugf("Auswertung: Dok %d × Frage %d", doc.getId(), question.getQuestionId()); - EvaluationResult result = evaluationService.evaluate(question, originalText); + EvaluationResult result = evaluationService.evaluate(question, originalText, projectId); int deviation = 0; int warning = 0; diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java b/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java index 2c198ef..a743a73 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java +++ b/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java @@ -46,6 +46,9 @@ public class DocumentProcessingService { @Named("ocr") ChatLanguageModel ocrModel; + @Inject + AiCostTrackerService costTracker; + // ========================================================================= // Öffentliche API // ========================================================================= @@ -59,13 +62,17 @@ public class DocumentProcessingService { * @return Markdown-String */ public String extractText(byte[] fileBytes, String mimeType, String filename) { + return extractText(fileBytes, mimeType, filename, null); + } + + public String extractText(byte[] fileBytes, String mimeType, String filename, Long projectId) { String effectiveMime = resolveMimeType(mimeType, filename); LOG.debugf("Extrahiere Text: %s (%s)", filename, effectiveMime); try { return switch (effectiveMime) { case "application/pdf" - -> extractFromPdf(fileBytes); + -> extractFromPdf(fileBytes, projectId); case "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/msword" -> extractFromDocx(fileBytes); @@ -88,7 +95,7 @@ public class DocumentProcessingService { // PDF: Seiten rendern + KI-OCR // ========================================================================= - private String extractFromPdf(byte[] fileBytes) throws Exception { + private String extractFromPdf(byte[] fileBytes, Long projectId) throws Exception { LOG.debug("Starte PDF-OCR..."); StringBuilder result = new StringBuilder(); @@ -101,12 +108,11 @@ public class DocumentProcessingService { LOG.debugf("OCR Seite %d/%d", i + 1, pageCount); BufferedImage image = renderer.renderImageWithDPI(i, OCR_DPI, ImageType.RGB); - // Bild in PNG-Base64 umwandeln ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(image, "PNG", baos); String base64Image = Base64.getEncoder().encodeToString(baos.toByteArray()); - String pageText = ocrPage(base64Image, i + 1, pageCount); + String pageText = ocrPage(base64Image, i + 1, pageCount, projectId); result.append(pageText).append("\n\n"); } } @@ -114,10 +120,7 @@ public class DocumentProcessingService { return result.toString().trim(); } - /** - * Sendet eine PDF-Seite als Base64-Bild an das Vision-Modell. - */ - private String ocrPage(String base64Image, int pageNum, int totalPages) { + private String ocrPage(String base64Image, int pageNum, int totalPages, Long projectId) { UserMessage message = UserMessage.from( ImageContent.from(base64Image, "image/png"), TextContent.from( @@ -134,7 +137,10 @@ public class DocumentProcessingService { ) ); - return ocrModel.generate(List.of(message)).content().text(); + dev.langchain4j.model.output.Response response = + ocrModel.generate(List.of(message)); + costTracker.track("ocr", "OCR", projectId, null, response.tokenUsage()); + return response.content().text(); } // ========================================================================= diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java b/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java index b442857..dc099e5 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java +++ b/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java @@ -39,6 +39,9 @@ public class EvaluationService { @Named("main") ChatLanguageModel mainModel; + @Inject + AiCostTrackerService costTracker; + @Inject ObjectMapper objectMapper; @@ -51,9 +54,10 @@ public class EvaluationService { * Wenn der Text bereits auf Deutsch ist, wird er unverändert zurückgegeben. * * @param originalText Markdown-Text in Originalsprache + * @param projectId Für Kostenzuordnung * @return Markdown-Text auf Deutsch */ - public String translate(String originalText) { + public String translate(String originalText, long projectId) { if (originalText == null || originalText.isBlank()) return ""; LOG.debugf("Starte Übersetzung (%d Zeichen)...", originalText.length()); @@ -71,6 +75,7 @@ public class EvaluationService { ); Response response = mainModel.generate(messages); + costTracker.track("main", "TRANSLATE", projectId, null, response.tokenUsage()); return response.content().text().trim(); } @@ -88,7 +93,7 @@ public class EvaluationService { * @param documentText Originaltext des Dokuments (Markdown) * @return Strukturiertes Auswertungsergebnis */ - public EvaluationResult evaluate(OrdsQuestion question, String documentText) { + public EvaluationResult evaluate(OrdsQuestion question, String documentText, long projectId) { LOG.debugf("Werte Frage %d aus: %s", question.getQuestionId(), abbreviate(question.getQuestionText(), 80)); @@ -102,6 +107,7 @@ public class EvaluationService { ); Response response = mainModel.generate(messages); + costTracker.track("main", "EVALUATE", projectId, null, response.tokenUsage()); String rawText = response.content().text(); return parseEvaluationResult(rawText, question); diff --git a/dc-backend/src/main/resources/application.properties b/dc-backend/src/main/resources/application.properties index b9d65bc..2baab1e 100644 --- a/dc-backend/src/main/resources/application.properties +++ b/dc-backend/src/main/resources/application.properties @@ -12,25 +12,55 @@ quarkus.http.port=8090 dc.api.key=${DC_API_KEY:} # ============================================================================= -# KI-Modelle (Ollama-kompatibler Endpunkt) +# KI-Provider: ollama | mistral +# ============================================================================= +dc.ai.provider=mistral + +# ============================================================================= +# Ollama-Konfiguration (aktiv wenn dc.ai.provider=ollama) +# ============================================================================= +dc.ai.ollama.base-url=https://ollama.aquantico.de +dc.ai.ollama.api-key=324GF44-50AA-4B57-9386-K435DLJ764DFR + +dc.ai.ollama.main.model=qwen3.6:35b-a3b-q4_K_M +dc.ai.ollama.catalog.model=qwen3.6:35b-a3b-q4_K_M +dc.ai.ollama.ocr.model=qwen3.6:35b-a3b-q4_K_M + +# ============================================================================= +# Mistral-Konfiguration (aktiv wenn dc.ai.provider=mistral) +# ============================================================================= +dc.ai.mistral.api-key=${MISTRAL_API_KEY:2AkoohJC8MjfjhfLGObQESZs9jxbDnc0} + +dc.ai.mistral.main.model=mistral-small-latest +dc.ai.mistral.catalog.model=mistral-small-latest +# pixtral-12b-2409 unterstützt Vision (PDF-Seiten als Bild) +dc.ai.mistral.ocr.model=pixtral-12b-2409 + +# Preise in EUR pro 1 Mio. Tokens (Stand 2025, 1 USD = 0.92 EUR) +# mistral-small-latest: $0.10/$0.30 pro 1M tokens +dc.ai.mistral.main.price-input-per-1m-eur=0.092 +dc.ai.mistral.main.price-output-per-1m-eur=0.276 +dc.ai.mistral.catalog.price-input-per-1m-eur=0.092 +dc.ai.mistral.catalog.price-output-per-1m-eur=0.276 +# pixtral-12b-2409: $0.15/$0.15 pro 1M tokens +dc.ai.mistral.ocr.price-input-per-1m-eur=0.138 +dc.ai.mistral.ocr.price-output-per-1m-eur=0.138 + +# ============================================================================= +# Gemeinsame Modell-Parameter (für beide Provider) # ============================================================================= -dc.ai.base-url=https://ollama.aquantico.de -dc.ai.api-key=324GF44-50AA-4B57-9386-K435DLJ764DFR # Hauptmodell: Auswertung + Übersetzung -dc.ai.main.model=qwen3.6:35b-a3b-q4_K_M dc.ai.main.timeout=300s dc.ai.main.max-retries=2 dc.ai.main.temperature=0.1 -# Katalog-Generierungsmodell: längerer Timeout für vollständige Katalog-Analyse -dc.ai.catalog.model=qwen3.6:35b-a3b-q4_K_M +# Katalog-Generierungsmodell dc.ai.catalog.timeout=1200s dc.ai.catalog.max-retries=1 dc.ai.catalog.temperature=0.1 # OCR-Modell: Bildtext-Extraktion aus PDF-Seiten -dc.ai.ocr.model=qwen3.6:35b-a3b-q4_K_M dc.ai.ocr.timeout=120s dc.ai.ocr.max-retries=2 diff --git a/dc-backend/target/classes/application.properties b/dc-backend/target/classes/application.properties index b9d65bc..2baab1e 100644 --- a/dc-backend/target/classes/application.properties +++ b/dc-backend/target/classes/application.properties @@ -12,25 +12,55 @@ quarkus.http.port=8090 dc.api.key=${DC_API_KEY:} # ============================================================================= -# KI-Modelle (Ollama-kompatibler Endpunkt) +# KI-Provider: ollama | mistral +# ============================================================================= +dc.ai.provider=mistral + +# ============================================================================= +# Ollama-Konfiguration (aktiv wenn dc.ai.provider=ollama) +# ============================================================================= +dc.ai.ollama.base-url=https://ollama.aquantico.de +dc.ai.ollama.api-key=324GF44-50AA-4B57-9386-K435DLJ764DFR + +dc.ai.ollama.main.model=qwen3.6:35b-a3b-q4_K_M +dc.ai.ollama.catalog.model=qwen3.6:35b-a3b-q4_K_M +dc.ai.ollama.ocr.model=qwen3.6:35b-a3b-q4_K_M + +# ============================================================================= +# Mistral-Konfiguration (aktiv wenn dc.ai.provider=mistral) +# ============================================================================= +dc.ai.mistral.api-key=${MISTRAL_API_KEY:2AkoohJC8MjfjhfLGObQESZs9jxbDnc0} + +dc.ai.mistral.main.model=mistral-small-latest +dc.ai.mistral.catalog.model=mistral-small-latest +# pixtral-12b-2409 unterstützt Vision (PDF-Seiten als Bild) +dc.ai.mistral.ocr.model=pixtral-12b-2409 + +# Preise in EUR pro 1 Mio. Tokens (Stand 2025, 1 USD = 0.92 EUR) +# mistral-small-latest: $0.10/$0.30 pro 1M tokens +dc.ai.mistral.main.price-input-per-1m-eur=0.092 +dc.ai.mistral.main.price-output-per-1m-eur=0.276 +dc.ai.mistral.catalog.price-input-per-1m-eur=0.092 +dc.ai.mistral.catalog.price-output-per-1m-eur=0.276 +# pixtral-12b-2409: $0.15/$0.15 pro 1M tokens +dc.ai.mistral.ocr.price-input-per-1m-eur=0.138 +dc.ai.mistral.ocr.price-output-per-1m-eur=0.138 + +# ============================================================================= +# Gemeinsame Modell-Parameter (für beide Provider) # ============================================================================= -dc.ai.base-url=https://ollama.aquantico.de -dc.ai.api-key=324GF44-50AA-4B57-9386-K435DLJ764DFR # Hauptmodell: Auswertung + Übersetzung -dc.ai.main.model=qwen3.6:35b-a3b-q4_K_M dc.ai.main.timeout=300s dc.ai.main.max-retries=2 dc.ai.main.temperature=0.1 -# Katalog-Generierungsmodell: längerer Timeout für vollständige Katalog-Analyse -dc.ai.catalog.model=qwen3.6:35b-a3b-q4_K_M +# Katalog-Generierungsmodell dc.ai.catalog.timeout=1200s dc.ai.catalog.max-retries=1 dc.ai.catalog.temperature=0.1 # OCR-Modell: Bildtext-Extraktion aus PDF-Seiten -dc.ai.ocr.model=qwen3.6:35b-a3b-q4_K_M dc.ai.ocr.timeout=120s dc.ai.ocr.max-retries=2 diff --git a/dc-backend/target/classes/de/frigosped/dc/ai/AiModelProducer.class b/dc-backend/target/classes/de/frigosped/dc/ai/AiModelProducer.class index 00a5781..547f242 100644 Binary files a/dc-backend/target/classes/de/frigosped/dc/ai/AiModelProducer.class and b/dc-backend/target/classes/de/frigosped/dc/ai/AiModelProducer.class differ diff --git a/dc-backend/target/classes/de/frigosped/dc/client/OrdsClient.class b/dc-backend/target/classes/de/frigosped/dc/client/OrdsClient.class index 57bbf49..2ce67b0 100644 Binary files a/dc-backend/target/classes/de/frigosped/dc/client/OrdsClient.class and b/dc-backend/target/classes/de/frigosped/dc/client/OrdsClient.class differ diff --git a/dc-backend/target/classes/de/frigosped/dc/service/CheckOrchestrationService.class b/dc-backend/target/classes/de/frigosped/dc/service/CheckOrchestrationService.class index 4d0beac..4b58c06 100644 Binary files a/dc-backend/target/classes/de/frigosped/dc/service/CheckOrchestrationService.class and b/dc-backend/target/classes/de/frigosped/dc/service/CheckOrchestrationService.class differ diff --git a/dc-backend/target/classes/de/frigosped/dc/service/DocumentProcessingService.class b/dc-backend/target/classes/de/frigosped/dc/service/DocumentProcessingService.class index eac5b01..8a07175 100644 Binary files a/dc-backend/target/classes/de/frigosped/dc/service/DocumentProcessingService.class and b/dc-backend/target/classes/de/frigosped/dc/service/DocumentProcessingService.class differ diff --git a/dc-backend/target/classes/de/frigosped/dc/service/EvaluationService.class b/dc-backend/target/classes/de/frigosped/dc/service/EvaluationService.class index 63696f7..95e0077 100644 Binary files a/dc-backend/target/classes/de/frigosped/dc/service/EvaluationService.class and b/dc-backend/target/classes/de/frigosped/dc/service/EvaluationService.class differ diff --git a/dc-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/dc-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index b39f459..545e599 100644 --- a/dc-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/dc-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -9,6 +9,7 @@ de/frigosped/dc/service/MarkdownExportService.class de/frigosped/dc/service/DocumentProcessingService.class de/frigosped/dc/model/CatalogGenerateResponse.class de/frigosped/dc/service/MarkdownExportService$Seg.class +de/frigosped/dc/model/AiCostRequest.class de/frigosped/dc/model/OrdsCatalogStatusResponse.class de/frigosped/dc/model/OrdsDocumentTexts.class de/frigosped/dc/resource/CatalogResource.class @@ -21,3 +22,4 @@ de/frigosped/dc/client/OrdsLoggingFilter.class de/frigosped/dc/service/MarkdownExportService$PdfWriter.class de/frigosped/dc/security/ApiKeyFilter.class de/frigosped/dc/model/GeneratedCatalogStructure$GeneratedQuestion.class +de/frigosped/dc/service/AiCostTrackerService.class diff --git a/dc-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/dc-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index c3dd4dd..f3b4cd9 100644 --- a/dc-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/dc-backend/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -1,6 +1,7 @@ /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsLoggingFilter.java +/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/AiCostRequest.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/CatalogGenerateResponse.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/EvaluationResult.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ExportRequest.java @@ -23,6 +24,7 @@ /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CheckResource.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/ExportResource.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/security/ApiKeyFilter.java +/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/AiCostTrackerService.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java /mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java