Kosten Endpunkt
This commit is contained in:
@@ -73,6 +73,11 @@
|
||||
<artifactId>langchain4j-ollama</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-mistral-ai</artifactId>
|
||||
<version>${langchain4j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ================================================================ -->
|
||||
<!-- Dokumentenverarbeitung -->
|
||||
|
||||
@@ -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", "")));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
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<ChatMessage> messages = List.of(
|
||||
SystemMessage.from(buildChunkSystemPrompt(chunkNr, totalChunks)),
|
||||
UserMessage.from(
|
||||
@@ -327,6 +331,7 @@ public class CatalogGenerationService {
|
||||
);
|
||||
|
||||
Response<AiMessage> response = catalogModel.generate(messages);
|
||||
costTracker.track("catalog", "CATALOG", null, catalogId, response.tokenUsage());
|
||||
String rawJson = response.content().text();
|
||||
return parseCatalogStructure(rawJson, catalogName + " Chunk " + chunkNr);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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")
|
||||
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<AiMessage> 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<AiMessage> response = mainModel.generate(messages);
|
||||
costTracker.track("main", "EVALUATE", projectId, null, response.tokenUsage());
|
||||
String rawText = response.content().text();
|
||||
|
||||
return parseEvaluationResult(rawText, question);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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/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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user