Kosten Endpunkt
This commit is contained in:
@@ -33,7 +33,8 @@
|
|||||||
"Bash(NLS_LANG=GERMAN_GERMANY.AL32UTF8 sql *)",
|
"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 logs *)",
|
||||||
"Bash(KUBECONFIG=/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml kubectl get pods -n ai-env -l app=dc-backend)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
Scripts/KI-Kostenverfolgung.sql
Normal file
143
Scripts/KI-Kostenverfolgung.sql
Normal file
@@ -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;
|
||||||
|
-- /
|
||||||
@@ -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;
|
COMMIT;
|
||||||
END;
|
END;
|
||||||
/
|
/
|
||||||
|
|||||||
@@ -73,6 +73,11 @@
|
|||||||
<artifactId>langchain4j-ollama</artifactId>
|
<artifactId>langchain4j-ollama</artifactId>
|
||||||
<version>${langchain4j.version}</version>
|
<version>${langchain4j.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.langchain4j</groupId>
|
||||||
|
<artifactId>langchain4j-mistral-ai</artifactId>
|
||||||
|
<version>${langchain4j.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- ================================================================ -->
|
<!-- ================================================================ -->
|
||||||
<!-- Dokumentenverarbeitung -->
|
<!-- Dokumentenverarbeitung -->
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.frigosped.dc.ai;
|
package de.frigosped.dc.ai;
|
||||||
|
|
||||||
import dev.langchain4j.model.chat.ChatLanguageModel;
|
import dev.langchain4j.model.chat.ChatLanguageModel;
|
||||||
|
import dev.langchain4j.model.mistralai.MistralAiChatModel;
|
||||||
import dev.langchain4j.model.ollama.OllamaChatModel;
|
import dev.langchain4j.model.ollama.OllamaChatModel;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.enterprise.inject.Produces;
|
import jakarta.enterprise.inject.Produces;
|
||||||
@@ -10,29 +11,42 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
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
|
@ApplicationScoped
|
||||||
public class AiModelProducer {
|
public class AiModelProducer {
|
||||||
|
|
||||||
@ConfigProperty(name = "dc.ai.base-url")
|
@ConfigProperty(name = "dc.ai.provider", defaultValue = "ollama")
|
||||||
String baseUrl;
|
String provider;
|
||||||
|
|
||||||
@ConfigProperty(name = "dc.ai.api-key")
|
// --- Ollama ---
|
||||||
String apiKey;
|
@ConfigProperty(name = "dc.ai.ollama.base-url", defaultValue = "")
|
||||||
|
String ollamaBaseUrl;
|
||||||
|
|
||||||
@ConfigProperty(name = "dc.ai.main.model")
|
@ConfigProperty(name = "dc.ai.ollama.api-key", defaultValue = "")
|
||||||
String mainModelName;
|
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")
|
@ConfigProperty(name = "dc.ai.main.timeout", defaultValue = "300s")
|
||||||
String mainTimeout;
|
String mainTimeout;
|
||||||
|
|
||||||
@@ -42,9 +56,6 @@ public class AiModelProducer {
|
|||||||
@ConfigProperty(name = "dc.ai.main.temperature", defaultValue = "0.1")
|
@ConfigProperty(name = "dc.ai.main.temperature", defaultValue = "0.1")
|
||||||
double mainTemperature;
|
double mainTemperature;
|
||||||
|
|
||||||
@ConfigProperty(name = "dc.ai.catalog.model")
|
|
||||||
String catalogModelName;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "dc.ai.catalog.timeout", defaultValue = "1200s")
|
@ConfigProperty(name = "dc.ai.catalog.timeout", defaultValue = "1200s")
|
||||||
String catalogTimeout;
|
String catalogTimeout;
|
||||||
|
|
||||||
@@ -54,9 +65,6 @@ public class AiModelProducer {
|
|||||||
@ConfigProperty(name = "dc.ai.catalog.temperature", defaultValue = "0.1")
|
@ConfigProperty(name = "dc.ai.catalog.temperature", defaultValue = "0.1")
|
||||||
double catalogTemperature;
|
double catalogTemperature;
|
||||||
|
|
||||||
@ConfigProperty(name = "dc.ai.ocr.model")
|
|
||||||
String ocrModelName;
|
|
||||||
|
|
||||||
@ConfigProperty(name = "dc.ai.ocr.timeout", defaultValue = "120s")
|
@ConfigProperty(name = "dc.ai.ocr.timeout", defaultValue = "120s")
|
||||||
String ocrTimeout;
|
String ocrTimeout;
|
||||||
|
|
||||||
@@ -69,68 +77,89 @@ public class AiModelProducer {
|
|||||||
@ConfigProperty(name = "dc.ai.log-responses", defaultValue = "false")
|
@ConfigProperty(name = "dc.ai.log-responses", defaultValue = "false")
|
||||||
boolean logResponses;
|
boolean logResponses;
|
||||||
|
|
||||||
/**
|
|
||||||
* Hauptmodell: Auswertung der Fragen + Übersetzung
|
|
||||||
* Modell: gpt-oss:20b
|
|
||||||
*/
|
|
||||||
@Produces
|
@Produces
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@Named("main")
|
@Named("main")
|
||||||
public ChatLanguageModel mainModel() {
|
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()
|
return OllamaChatModel.builder()
|
||||||
.baseUrl(baseUrl)
|
.baseUrl(ollamaBaseUrl)
|
||||||
.modelName(mainModelName)
|
.modelName(ollamaMainModel)
|
||||||
.temperature(mainTemperature)
|
.temperature(mainTemperature)
|
||||||
.timeout(parseDuration(mainTimeout))
|
.timeout(parseDuration(mainTimeout))
|
||||||
.maxRetries(mainMaxRetries)
|
.maxRetries(mainMaxRetries)
|
||||||
.customHeaders(Map.of("X-API-KEY", apiKey))
|
.customHeaders(Map.of("X-API-KEY", ollamaApiKey))
|
||||||
.logRequests(logRequests)
|
.logRequests(logRequests)
|
||||||
.logResponses(logResponses)
|
.logResponses(logResponses)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Katalog-Modell: Vollständige Katalog-Generierung aus Regelwerk-Dokument.
|
|
||||||
* Längerer Timeout (1200s) notwendig da komplette Dokumente analysiert werden.
|
|
||||||
*/
|
|
||||||
@Produces
|
@Produces
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@Named("catalog")
|
@Named("catalog")
|
||||||
public ChatLanguageModel catalogModel() {
|
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()
|
return OllamaChatModel.builder()
|
||||||
.baseUrl(baseUrl)
|
.baseUrl(ollamaBaseUrl)
|
||||||
.modelName(catalogModelName)
|
.modelName(ollamaCatalogModel)
|
||||||
.temperature(catalogTemperature)
|
.temperature(catalogTemperature)
|
||||||
.timeout(parseDuration(catalogTimeout))
|
.timeout(parseDuration(catalogTimeout))
|
||||||
.maxRetries(catalogMaxRetries)
|
.maxRetries(catalogMaxRetries)
|
||||||
.customHeaders(Map.of("X-API-KEY", apiKey))
|
.customHeaders(Map.of("X-API-KEY", ollamaApiKey))
|
||||||
.logRequests(logRequests)
|
.logRequests(logRequests)
|
||||||
.logResponses(logResponses)
|
.logResponses(logResponses)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* OCR-Modell: Bildtext-Extraktion aus PDF-Seiten (Vision-Modus)
|
|
||||||
* Modell: quen3.5:9b
|
|
||||||
*/
|
|
||||||
@Produces
|
@Produces
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@Named("ocr")
|
@Named("ocr")
|
||||||
public ChatLanguageModel ocrModel() {
|
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()
|
return OllamaChatModel.builder()
|
||||||
.baseUrl(baseUrl)
|
.baseUrl(ollamaBaseUrl)
|
||||||
.modelName(ocrModelName)
|
.modelName(ollamaOcrModel)
|
||||||
.timeout(parseDuration(ocrTimeout))
|
.timeout(parseDuration(ocrTimeout))
|
||||||
.maxRetries(ocrMaxRetries)
|
.maxRetries(ocrMaxRetries)
|
||||||
.customHeaders(Map.of("X-API-KEY", apiKey))
|
.customHeaders(Map.of("X-API-KEY", ollamaApiKey))
|
||||||
.logRequests(logRequests)
|
.logRequests(logRequests)
|
||||||
.logResponses(logResponses)
|
.logResponses(logResponses)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private boolean isMistral() {
|
||||||
* Parst Timeout-Strings wie "300s", "5m", "120s" in Duration.
|
return "mistral".equalsIgnoreCase(provider);
|
||||||
*/
|
}
|
||||||
|
|
||||||
private Duration parseDuration(String value) {
|
private Duration parseDuration(String value) {
|
||||||
if (value.endsWith("s")) {
|
if (value.endsWith("s")) {
|
||||||
return Duration.ofSeconds(Long.parseLong(value.replace("s", "")));
|
return Duration.ofSeconds(Long.parseLong(value.replace("s", "")));
|
||||||
|
|||||||
@@ -179,4 +179,16 @@ public interface OrdsClient {
|
|||||||
@DELETE
|
@DELETE
|
||||||
@Path("/projects/{projectId}/results")
|
@Path("/projects/{projectId}/results")
|
||||||
Response deleteResults(@PathParam("projectId") long projectId);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,9 @@ public class CatalogGenerationService {
|
|||||||
@Named("catalog")
|
@Named("catalog")
|
||||||
ChatLanguageModel catalogModel;
|
ChatLanguageModel catalogModel;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
AiCostTrackerService costTracker;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
ObjectMapper objectMapper;
|
ObjectMapper objectMapper;
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ public class CatalogGenerationService {
|
|||||||
catalogId, chunkNr, totalChunks, chunks.get(i).length());
|
catalogId, chunkNr, totalChunks, chunks.get(i).length());
|
||||||
|
|
||||||
GeneratedCatalogStructure chunkResult =
|
GeneratedCatalogStructure chunkResult =
|
||||||
generateStructureFromChunk(catalogName, chunks.get(i), chunkNr, totalChunks);
|
generateStructureFromChunk(catalogName, chunks.get(i), chunkNr, totalChunks, catalogId);
|
||||||
|
|
||||||
if (chunkResult == null || chunkResult.getCategories() == null) {
|
if (chunkResult == null || chunkResult.getCategories() == null) {
|
||||||
LOG.warnf("[Katalog %d] Chunk %d lieferte kein Ergebnis – übersprungen",
|
LOG.warnf("[Katalog %d] Chunk %d lieferte kein Ergebnis – übersprungen",
|
||||||
@@ -316,7 +319,8 @@ public class CatalogGenerationService {
|
|||||||
private GeneratedCatalogStructure generateStructureFromChunk(String catalogName,
|
private GeneratedCatalogStructure generateStructureFromChunk(String catalogName,
|
||||||
String chunkText,
|
String chunkText,
|
||||||
int chunkNr,
|
int chunkNr,
|
||||||
int totalChunks) {
|
int totalChunks,
|
||||||
|
long catalogId) {
|
||||||
List<ChatMessage> messages = List.of(
|
List<ChatMessage> messages = List.of(
|
||||||
SystemMessage.from(buildChunkSystemPrompt(chunkNr, totalChunks)),
|
SystemMessage.from(buildChunkSystemPrompt(chunkNr, totalChunks)),
|
||||||
UserMessage.from(
|
UserMessage.from(
|
||||||
@@ -327,6 +331,7 @@ public class CatalogGenerationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Response<AiMessage> response = catalogModel.generate(messages);
|
Response<AiMessage> response = catalogModel.generate(messages);
|
||||||
|
costTracker.track("catalog", "CATALOG", null, catalogId, response.tokenUsage());
|
||||||
String rawJson = response.content().text();
|
String rawJson = response.content().text();
|
||||||
return parseCatalogStructure(rawJson, catalogName + " Chunk " + chunkNr);
|
return parseCatalogStructure(rawJson, catalogName + " Chunk " + chunkNr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,11 +151,11 @@ public class CheckOrchestrationService {
|
|||||||
byte[] fileBytes = fileResp.readEntity(byte[].class);
|
byte[] fileBytes = fileResp.readEntity(byte[].class);
|
||||||
|
|
||||||
originalText = documentProcessingService.extractText(
|
originalText = documentProcessingService.extractText(
|
||||||
fileBytes, doc.getMimeType(), doc.getFilename());
|
fileBytes, doc.getMimeType(), doc.getFilename(), projectId);
|
||||||
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
|
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
|
||||||
(long) doc.getId(), (long) originalText.length());
|
(long) doc.getId(), (long) originalText.length());
|
||||||
|
|
||||||
translatedText = evaluationService.translate(originalText);
|
translatedText = evaluationService.translate(originalText, projectId);
|
||||||
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
|
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
|
||||||
(long) doc.getId(), (long) translatedText.length());
|
(long) doc.getId(), (long) translatedText.length());
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ public class CheckOrchestrationService {
|
|||||||
LOG.debugf("Auswertung: Dok %d × Frage %d",
|
LOG.debugf("Auswertung: Dok %d × Frage %d",
|
||||||
doc.getId(), question.getQuestionId());
|
doc.getId(), question.getQuestionId());
|
||||||
|
|
||||||
EvaluationResult result = evaluationService.evaluate(question, originalText);
|
EvaluationResult result = evaluationService.evaluate(question, originalText, projectId);
|
||||||
|
|
||||||
int deviation = 0;
|
int deviation = 0;
|
||||||
int warning = 0;
|
int warning = 0;
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ public class DocumentProcessingService {
|
|||||||
@Named("ocr")
|
@Named("ocr")
|
||||||
ChatLanguageModel ocrModel;
|
ChatLanguageModel ocrModel;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
AiCostTrackerService costTracker;
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Öffentliche API
|
// Öffentliche API
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -59,13 +62,17 @@ public class DocumentProcessingService {
|
|||||||
* @return Markdown-String
|
* @return Markdown-String
|
||||||
*/
|
*/
|
||||||
public String extractText(byte[] fileBytes, String mimeType, String filename) {
|
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);
|
String effectiveMime = resolveMimeType(mimeType, filename);
|
||||||
LOG.debugf("Extrahiere Text: %s (%s)", filename, effectiveMime);
|
LOG.debugf("Extrahiere Text: %s (%s)", filename, effectiveMime);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return switch (effectiveMime) {
|
return switch (effectiveMime) {
|
||||||
case "application/pdf"
|
case "application/pdf"
|
||||||
-> extractFromPdf(fileBytes);
|
-> extractFromPdf(fileBytes, projectId);
|
||||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
"application/msword"
|
"application/msword"
|
||||||
-> extractFromDocx(fileBytes);
|
-> extractFromDocx(fileBytes);
|
||||||
@@ -88,7 +95,7 @@ public class DocumentProcessingService {
|
|||||||
// PDF: Seiten rendern + KI-OCR
|
// 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...");
|
LOG.debug("Starte PDF-OCR...");
|
||||||
StringBuilder result = new StringBuilder();
|
StringBuilder result = new StringBuilder();
|
||||||
|
|
||||||
@@ -101,12 +108,11 @@ public class DocumentProcessingService {
|
|||||||
LOG.debugf("OCR Seite %d/%d", i + 1, pageCount);
|
LOG.debugf("OCR Seite %d/%d", i + 1, pageCount);
|
||||||
BufferedImage image = renderer.renderImageWithDPI(i, OCR_DPI, ImageType.RGB);
|
BufferedImage image = renderer.renderImageWithDPI(i, OCR_DPI, ImageType.RGB);
|
||||||
|
|
||||||
// Bild in PNG-Base64 umwandeln
|
|
||||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
ImageIO.write(image, "PNG", baos);
|
ImageIO.write(image, "PNG", baos);
|
||||||
String base64Image = Base64.getEncoder().encodeToString(baos.toByteArray());
|
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");
|
result.append(pageText).append("\n\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,10 +120,7 @@ public class DocumentProcessingService {
|
|||||||
return result.toString().trim();
|
return result.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private String ocrPage(String base64Image, int pageNum, int totalPages, Long projectId) {
|
||||||
* Sendet eine PDF-Seite als Base64-Bild an das Vision-Modell.
|
|
||||||
*/
|
|
||||||
private String ocrPage(String base64Image, int pageNum, int totalPages) {
|
|
||||||
UserMessage message = UserMessage.from(
|
UserMessage message = UserMessage.from(
|
||||||
ImageContent.from(base64Image, "image/png"),
|
ImageContent.from(base64Image, "image/png"),
|
||||||
TextContent.from(
|
TextContent.from(
|
||||||
@@ -134,7 +137,10 @@ public class DocumentProcessingService {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return ocrModel.generate(List.of(message)).content().text();
|
dev.langchain4j.model.output.Response<dev.langchain4j.data.message.AiMessage> response =
|
||||||
|
ocrModel.generate(List.of(message));
|
||||||
|
costTracker.track("ocr", "OCR", projectId, null, response.tokenUsage());
|
||||||
|
return response.content().text();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class EvaluationService {
|
|||||||
@Named("main")
|
@Named("main")
|
||||||
ChatLanguageModel mainModel;
|
ChatLanguageModel mainModel;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
AiCostTrackerService costTracker;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
ObjectMapper objectMapper;
|
ObjectMapper objectMapper;
|
||||||
|
|
||||||
@@ -51,9 +54,10 @@ public class EvaluationService {
|
|||||||
* Wenn der Text bereits auf Deutsch ist, wird er unverändert zurückgegeben.
|
* Wenn der Text bereits auf Deutsch ist, wird er unverändert zurückgegeben.
|
||||||
*
|
*
|
||||||
* @param originalText Markdown-Text in Originalsprache
|
* @param originalText Markdown-Text in Originalsprache
|
||||||
|
* @param projectId Für Kostenzuordnung
|
||||||
* @return Markdown-Text auf Deutsch
|
* @return Markdown-Text auf Deutsch
|
||||||
*/
|
*/
|
||||||
public String translate(String originalText) {
|
public String translate(String originalText, long projectId) {
|
||||||
if (originalText == null || originalText.isBlank()) return "";
|
if (originalText == null || originalText.isBlank()) return "";
|
||||||
LOG.debugf("Starte Übersetzung (%d Zeichen)...", originalText.length());
|
LOG.debugf("Starte Übersetzung (%d Zeichen)...", originalText.length());
|
||||||
|
|
||||||
@@ -71,6 +75,7 @@ public class EvaluationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Response<AiMessage> response = mainModel.generate(messages);
|
Response<AiMessage> response = mainModel.generate(messages);
|
||||||
|
costTracker.track("main", "TRANSLATE", projectId, null, response.tokenUsage());
|
||||||
return response.content().text().trim();
|
return response.content().text().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +93,7 @@ public class EvaluationService {
|
|||||||
* @param documentText Originaltext des Dokuments (Markdown)
|
* @param documentText Originaltext des Dokuments (Markdown)
|
||||||
* @return Strukturiertes Auswertungsergebnis
|
* @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",
|
LOG.debugf("Werte Frage %d aus: %s",
|
||||||
question.getQuestionId(),
|
question.getQuestionId(),
|
||||||
abbreviate(question.getQuestionText(), 80));
|
abbreviate(question.getQuestionText(), 80));
|
||||||
@@ -102,6 +107,7 @@ public class EvaluationService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Response<AiMessage> response = mainModel.generate(messages);
|
Response<AiMessage> response = mainModel.generate(messages);
|
||||||
|
costTracker.track("main", "EVALUATE", projectId, null, response.tokenUsage());
|
||||||
String rawText = response.content().text();
|
String rawText = response.content().text();
|
||||||
|
|
||||||
return parseEvaluationResult(rawText, question);
|
return parseEvaluationResult(rawText, question);
|
||||||
|
|||||||
@@ -12,25 +12,55 @@ quarkus.http.port=8090
|
|||||||
dc.api.key=${DC_API_KEY:}
|
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
|
# Hauptmodell: Auswertung + Übersetzung
|
||||||
dc.ai.main.model=qwen3.6:35b-a3b-q4_K_M
|
|
||||||
dc.ai.main.timeout=300s
|
dc.ai.main.timeout=300s
|
||||||
dc.ai.main.max-retries=2
|
dc.ai.main.max-retries=2
|
||||||
dc.ai.main.temperature=0.1
|
dc.ai.main.temperature=0.1
|
||||||
|
|
||||||
# Katalog-Generierungsmodell: längerer Timeout für vollständige Katalog-Analyse
|
# Katalog-Generierungsmodell
|
||||||
dc.ai.catalog.model=qwen3.6:35b-a3b-q4_K_M
|
|
||||||
dc.ai.catalog.timeout=1200s
|
dc.ai.catalog.timeout=1200s
|
||||||
dc.ai.catalog.max-retries=1
|
dc.ai.catalog.max-retries=1
|
||||||
dc.ai.catalog.temperature=0.1
|
dc.ai.catalog.temperature=0.1
|
||||||
|
|
||||||
# OCR-Modell: Bildtext-Extraktion aus PDF-Seiten
|
# 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.timeout=120s
|
||||||
dc.ai.ocr.max-retries=2
|
dc.ai.ocr.max-retries=2
|
||||||
|
|
||||||
|
|||||||
@@ -12,25 +12,55 @@ quarkus.http.port=8090
|
|||||||
dc.api.key=${DC_API_KEY:}
|
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
|
# Hauptmodell: Auswertung + Übersetzung
|
||||||
dc.ai.main.model=qwen3.6:35b-a3b-q4_K_M
|
|
||||||
dc.ai.main.timeout=300s
|
dc.ai.main.timeout=300s
|
||||||
dc.ai.main.max-retries=2
|
dc.ai.main.max-retries=2
|
||||||
dc.ai.main.temperature=0.1
|
dc.ai.main.temperature=0.1
|
||||||
|
|
||||||
# Katalog-Generierungsmodell: längerer Timeout für vollständige Katalog-Analyse
|
# Katalog-Generierungsmodell
|
||||||
dc.ai.catalog.model=qwen3.6:35b-a3b-q4_K_M
|
|
||||||
dc.ai.catalog.timeout=1200s
|
dc.ai.catalog.timeout=1200s
|
||||||
dc.ai.catalog.max-retries=1
|
dc.ai.catalog.max-retries=1
|
||||||
dc.ai.catalog.temperature=0.1
|
dc.ai.catalog.temperature=0.1
|
||||||
|
|
||||||
# OCR-Modell: Bildtext-Extraktion aus PDF-Seiten
|
# 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.timeout=120s
|
||||||
dc.ai.ocr.max-retries=2
|
dc.ai.ocr.max-retries=2
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,7 @@ de/frigosped/dc/service/MarkdownExportService.class
|
|||||||
de/frigosped/dc/service/DocumentProcessingService.class
|
de/frigosped/dc/service/DocumentProcessingService.class
|
||||||
de/frigosped/dc/model/CatalogGenerateResponse.class
|
de/frigosped/dc/model/CatalogGenerateResponse.class
|
||||||
de/frigosped/dc/service/MarkdownExportService$Seg.class
|
de/frigosped/dc/service/MarkdownExportService$Seg.class
|
||||||
|
de/frigosped/dc/model/AiCostRequest.class
|
||||||
de/frigosped/dc/model/OrdsCatalogStatusResponse.class
|
de/frigosped/dc/model/OrdsCatalogStatusResponse.class
|
||||||
de/frigosped/dc/model/OrdsDocumentTexts.class
|
de/frigosped/dc/model/OrdsDocumentTexts.class
|
||||||
de/frigosped/dc/resource/CatalogResource.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/service/MarkdownExportService$PdfWriter.class
|
||||||
de/frigosped/dc/security/ApiKeyFilter.class
|
de/frigosped/dc/security/ApiKeyFilter.class
|
||||||
de/frigosped/dc/model/GeneratedCatalogStructure$GeneratedQuestion.class
|
de/frigosped/dc/model/GeneratedCatalogStructure$GeneratedQuestion.class
|
||||||
|
de/frigosped/dc/service/AiCostTrackerService.class
|
||||||
|
|||||||
@@ -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/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/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/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/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/EvaluationResult.java
|
||||||
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ExportRequest.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/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/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/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/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/CheckOrchestrationService.java
|
||||||
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java
|
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java
|
||||||
|
|||||||
Reference in New Issue
Block a user