initial commit

This commit is contained in:
Wolf G. Beckmann
2026-04-27 14:57:33 +02:00
commit 1816168d1f
60 changed files with 4615 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
package de.frigosped.dc.ai;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Named;
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.api-key")
String apiKey;
@ConfigProperty(name = "dc.ai.main.model")
String mainModelName;
@ConfigProperty(name = "dc.ai.main.timeout", defaultValue = "300s")
String mainTimeout;
@ConfigProperty(name = "dc.ai.main.max-retries", defaultValue = "2")
int mainMaxRetries;
@ConfigProperty(name = "dc.ai.main.temperature", defaultValue = "0.1")
double mainTemperature;
@ConfigProperty(name = "dc.ai.ocr.model")
String ocrModelName;
@ConfigProperty(name = "dc.ai.ocr.timeout", defaultValue = "120s")
String ocrTimeout;
@ConfigProperty(name = "dc.ai.ocr.max-retries", defaultValue = "2")
int ocrMaxRetries;
@ConfigProperty(name = "dc.ai.log-requests", defaultValue = "false")
boolean logRequests;
@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() {
return OllamaChatModel.builder()
.baseUrl(baseUrl)
.modelName(mainModelName)
.temperature(mainTemperature)
.timeout(parseDuration(mainTimeout))
.maxRetries(mainMaxRetries)
.customHeaders(Map.of("X-API-KEY", apiKey))
.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() {
return OllamaChatModel.builder()
.baseUrl(baseUrl)
.modelName(ocrModelName)
.timeout(parseDuration(ocrTimeout))
.maxRetries(ocrMaxRetries)
.customHeaders(Map.of("X-API-KEY", apiKey))
.logRequests(logRequests)
.logResponses(logResponses)
.build();
}
/**
* Parst Timeout-Strings wie "300s", "5m", "120s" in Duration.
*/
private Duration parseDuration(String value) {
if (value.endsWith("s")) {
return Duration.ofSeconds(Long.parseLong(value.replace("s", "")));
} else if (value.endsWith("m")) {
return Duration.ofMinutes(Long.parseLong(value.replace("m", "")));
}
return Duration.ofSeconds(Long.parseLong(value));
}
}

View File

@@ -0,0 +1,125 @@
package de.frigosped.dc.client;
import de.frigosped.dc.model.*;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/**
* MicroProfile REST Client für die ORDS /api/dc/-Endpunkte.
*
* Basis-URL wird in application.properties gesetzt:
* quarkus.rest-client.ords-api.url=http://host/ords/SCHEMA
*
* Optionaler Bearer-Token für Produktion:
* dc.ords.bearer-token=eyJ...
* → @ClientHeaderParam(name = "Authorization", value = "${dc.ords.bearer-token}")
*/
@RegisterRestClient(configKey = "ords-api")
@Path("/api/dc")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface OrdsClient {
// =========================================================================
// Fragenkatalog (Lesend)
// =========================================================================
/**
* GET /api/dc/catalogs/:catalogId/questions/
* Liefert alle Fragen (mit Kategorie-Info) für einen Katalog.
*/
@GET
@Path("/catalogs/{catalogId}/questions/")
OrdsQuestionList getQuestions(@PathParam("catalogId") long catalogId);
// =========================================================================
// Projekte (Status-Steuerung)
// =========================================================================
/**
* GET /api/dc/projects/:projectId
* Projektdetails laden (Status, catalog_id, notification_email, ...).
*/
@GET
@Path("/projects/{projectId}")
OrdsProject getProject(@PathParam("projectId") long projectId);
/**
* POST /api/dc/projects/:projectId/start
* Setzt Status auf IN_PROGRESS. 409 wenn nicht PENDING.
*/
@POST
@Path("/projects/{projectId}/start")
Response startProject(@PathParam("projectId") long projectId);
/**
* PUT /api/dc/projects/:projectId/progress
* Aktualisiert den Fortschritt (0100).
*/
@PUT
@Path("/projects/{projectId}/progress")
Response updateProgress(@PathParam("projectId") long projectId,
ProgressRequest body);
/**
* POST /api/dc/projects/:projectId/complete
* Setzt Status auf COMPLETED + completed_at = SYSDATE.
*/
@POST
@Path("/projects/{projectId}/complete")
Response completeProject(@PathParam("projectId") long projectId);
// =========================================================================
// Dokumente
// =========================================================================
/**
* GET /api/dc/projects/:projectId/documents/
* Liste aller Dokumente (Metadaten, kein BLOB).
*/
@GET
@Path("/projects/{projectId}/documents/")
OrdsDocumentList getDocuments(@PathParam("projectId") long projectId);
/**
* GET /api/dc/documents/:docId/file
* Download des Original-BLOBs.
* Content-Type wird von ORDS aus mime_type-Spalte gesetzt.
*/
@GET
@Path("/documents/{docId}/file")
@Produces(MediaType.WILDCARD)
Response downloadFile(@PathParam("docId") long docId);
/**
* PUT /api/dc/documents/:docId/texts
* Schreibt original_text und/oder translated_text zurück.
*/
@PUT
@Path("/documents/{docId}/texts")
Response updateTexts(@PathParam("docId") long docId, TextsRequest body);
// =========================================================================
// Ergebnisse
// =========================================================================
/**
* POST /api/dc/projects/:projectId/results
* Einzelnes Prüfergebnis speichern.
*/
@POST
@Path("/projects/{projectId}/results")
Response saveResult(@PathParam("projectId") long projectId,
ResultRequest body);
/**
* DELETE /api/dc/projects/:projectId/results
* Alle Ergebnisse löschen (vor Re-Processing).
*/
@DELETE
@Path("/projects/{projectId}/results")
Response deleteResults(@PathParam("projectId") long projectId);
}

View File

@@ -0,0 +1,93 @@
package de.frigosped.dc.client;
import jakarta.annotation.Priority;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.client.ClientResponseFilter;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
* Loggt jede HTTP-Anfrage und -Antwort des ORDS REST Clients:
* - Vollständige URL (Methode + URI)
* - Request-Body
* - Response-Status + Body
*/
@Provider
@Priority(Javax.ws.rs.Priorities.AUTHENTICATION + 1)
public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFilter {
private static final Logger LOG = Logger.getLogger(OrdsLoggingFilter.class);
@Override
public void filter(ClientRequestContext ctx) throws IOException {
String method = ctx.getMethod();
String uri = ctx.getUri().toString();
String entity = readRequest(ctx);
LOG.infof("=== ORDS %s %s ===", method, uri);
if (entity != null && !entity.isBlank()) {
LOG.infof("REQUEST: %s", entity);
}
}
@Override
public void filter(ClientRequestContext requestCtx, ClientResponseContext responseCtx) throws IOException {
String body = readResponse(responseCtx);
LOG.infof("RESPONSE: HTTP %d%s",
responseCtx.getStatus(),
body != null ? " " + body : "");
LOG.infof("=== ORDS %s %s END ===", requestCtx.getMethod(), requestCtx.getUri());
}
private String readRequest(ClientRequestContext ctx) {
Object entity = ctx.getEntity();
if (entity == null) return null;
String body;
if (entity instanceof byte[] bytes) {
body = new String(bytes, StandardCharsets.UTF_8);
} else if (entity instanceof String str) {
body = str;
} else if (entity instanceof List<?> list) {
body = list.toString();
} else if (entity instanceof Object[] arr) {
body = Arrays.toString(arr);
} else {
body = entity.toString();
}
if (body.isBlank()) return null;
// Entity neu setzen — Stream nicht verbrauchen
ctx.setEntity(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)),
ctx.getHeaders().getFirst("Content-Type", MediaType.APPLICATION_OCTET_STREAM),
Long.valueOf(body.length()));
return body;
}
private String readResponse(ClientResponseContext ctx) throws IOException {
if (ctx.getEntityStream() == null) return null;
byte[] raw = ctx.getEntityStream().readAllBytes();
if (raw.length == 0) return null;
// Stream neu setzen für nachfolgende Consumer (z.B. Response.readEntity)
ctx.setEntityStream(new ByteArrayInputStream(raw));
if (raw.length > 4000) {
return String.format("[%.4k bytes, trunc. zu 4000]", raw.length);
}
String mediaType = ctx.getMediaType() != null ? ctx.getMediaType().toString() : "";
if (mediaType.contains("application/json") || mediaType.contains("text/") || mediaType.isEmpty()) {
return new String(raw, StandardCharsets.UTF_8);
}
return String.format("[%d bytes binary]", raw.length);
}
}

View File

@@ -0,0 +1,37 @@
package de.frigosped.dc.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Deserialisiertes JSON-Ergebnis aus der KI-Auswertung.
* Die KI gibt dieses Objekt als JSON-String zurück.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class EvaluationResult {
/** Antwort auf Deutsch (Zitate in Originalsprache + Übersetzung) */
private String answer;
/** Erfüllungsgrad 0100 */
private Integer score;
/** OK | UNKLAR | NOK */
private String resultType;
/** Kommentar auf Deutsch */
private String theComment;
public EvaluationResult() {}
public String getAnswer() { return answer; }
public void setAnswer(String v) { this.answer = v; }
public Integer getScore() { return score; }
public void setScore(Integer v) { this.score = v; }
public String getResultType() { return resultType; }
public void setResultType(String v) { this.resultType = v; }
public String getTheComment() { return theComment; }
public void setTheComment(String v) { this.theComment = v; }
}

View File

@@ -0,0 +1,57 @@
package de.frigosped.dc.model;
import java.time.LocalDateTime;
/**
* DTO für ORDS-Endpunkt GET /api/dc/projects/:id/documents/
* Kein BLOB; has_original_text / has_translated_text kommen als 0/1.
*/
public class OrdsDocument {
private Long id;
private String filename;
private String mimeType;
private Long documentTypeId;
private Long catalogId;
private LocalDateTime uploadedAt;
private Integer hasOriginalText; // 0 oder 1
private Integer hasTranslatedText; // 0 oder 1
private Integer rowVersion;
private LocalDateTime created;
private LocalDateTime updated;
public OrdsDocument() {}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getFilename() { return filename; }
public void setFilename(String filename) { this.filename = filename; }
public String getMimeType() { return mimeType; }
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
public Long getDocumentTypeId() { return documentTypeId; }
public void setDocumentTypeId(Long v) { this.documentTypeId = v; }
public Long getCatalogId() { return catalogId; }
public void setCatalogId(Long catalogId) { this.catalogId = catalogId; }
public LocalDateTime getUploadedAt() { return uploadedAt; }
public void setUploadedAt(LocalDateTime v) { this.uploadedAt = v; }
public Integer getHasOriginalText() { return hasOriginalText; }
public void setHasOriginalText(Integer v) { this.hasOriginalText = v; }
public Integer getHasTranslatedText() { return hasTranslatedText; }
public void setHasTranslatedText(Integer v) { this.hasTranslatedText = v; }
public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; }
public LocalDateTime getCreated() { return created; }
public void setCreated(LocalDateTime v) { this.created = v; }
public LocalDateTime getUpdated() { return updated; }
public void setUpdated(LocalDateTime v) { this.updated = v; }
}

View File

@@ -0,0 +1,25 @@
package de.frigosped.dc.model;
import java.util.List;
/**
* ORDS COLLECTION_FEED-Antwort für Dokumente.
* {"items":[...], "hasMore":false, "count":N}
*/
public class OrdsDocumentList {
private List<OrdsDocument> items;
private Boolean hasMore;
private Integer count;
public OrdsDocumentList() {}
public List<OrdsDocument> getItems() { return items; }
public void setItems(List<OrdsDocument> items) { this.items = items; }
public Boolean getHasMore() { return hasMore; }
public void setHasMore(Boolean hasMore) { this.hasMore = hasMore; }
public Integer getCount() { return count; }
public void setCount(Integer count) { this.count = count; }
}

View File

@@ -0,0 +1,61 @@
package de.frigosped.dc.model;
import java.time.LocalDateTime;
/**
* DTO für den ORDS-Endpunkt GET /api/dc/projects/:project_id
* (COLLECTION_ITEM einzelnes JSON-Objekt, snake_case von ORDS)
*/
public class OrdsProject {
private Long id;
private String name;
private String description;
private Long catalogId;
private Long createdByUser;
private String status; // PENDING | IN_PROGRESS | COMPLETED
private Integer progress; // 0100
private LocalDateTime completedAt;
private String notificationEmail;
private Integer rowVersion;
private LocalDateTime created;
private LocalDateTime updated;
public OrdsProject() {}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String d) { this.description = d; }
public Long getCatalogId() { return catalogId; }
public void setCatalogId(Long catalogId) { this.catalogId = catalogId; }
public Long getCreatedByUser() { return createdByUser; }
public void setCreatedByUser(Long v) { this.createdByUser = v; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Integer getProgress() { return progress; }
public void setProgress(Integer progress) { this.progress = progress; }
public LocalDateTime getCompletedAt() { return completedAt; }
public void setCompletedAt(LocalDateTime v) { this.completedAt = v; }
public String getNotificationEmail() { return notificationEmail; }
public void setNotificationEmail(String v) { this.notificationEmail = v; }
public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; }
public LocalDateTime getCreated() { return created; }
public void setCreated(LocalDateTime v) { this.created = v; }
public LocalDateTime getUpdated() { return updated; }
public void setUpdated(LocalDateTime v) { this.updated = v; }
}

View File

@@ -0,0 +1,55 @@
package de.frigosped.dc.model;
/**
* DTO für ORDS-Endpunkt GET /api/dc/catalogs/:id/questions/
* Flache Darstellung: Kategorie + Frage in einer Zeile.
*/
public class OrdsQuestion {
private Long questionId;
private Long categoryId;
private String categoryName;
private String categoryDescription;
private String questionText;
private String evaluationType; // z.B. SCORE, BINARY
private Integer threshold; // Schwellwert 0100
private String resultHandling; // ABWEICHUNG | HINWEIS
private String example0Percent; // Beispiel: Anforderung nicht erfüllt
private String example100Percent; // Beispiel: Anforderung voll erfüllt
private Integer rowVersion;
public OrdsQuestion() {}
public Long getQuestionId() { return questionId; }
public void setQuestionId(Long v) { this.questionId = v; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long v) { this.categoryId = v; }
public String getCategoryName() { return categoryName; }
public void setCategoryName(String v) { this.categoryName = v; }
public String getCategoryDescription() { return categoryDescription; }
public void setCategoryDescription(String v) { this.categoryDescription = v; }
public String getQuestionText() { return questionText; }
public void setQuestionText(String v) { this.questionText = v; }
public String getEvaluationType() { return evaluationType; }
public void setEvaluationType(String v) { this.evaluationType = v; }
public Integer getThreshold() { return threshold; }
public void setThreshold(Integer v) { this.threshold = v; }
public String getResultHandling() { return resultHandling; }
public void setResultHandling(String v) { this.resultHandling = v; }
public String getExample0Percent() { return example0Percent; }
public void setExample0Percent(String v) { this.example0Percent = v; }
public String getExample100Percent() { return example100Percent; }
public void setExample100Percent(String v) { this.example100Percent = v; }
public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; }
}

View File

@@ -0,0 +1,25 @@
package de.frigosped.dc.model;
import java.util.List;
/**
* ORDS COLLECTION_FEED-Antwort für Fragen.
* {"items":[...], "hasMore":false, "count":N}
*/
public class OrdsQuestionList {
private List<OrdsQuestion> items;
private Boolean hasMore;
private Integer count;
public OrdsQuestionList() {}
public List<OrdsQuestion> getItems() { return items; }
public void setItems(List<OrdsQuestion> items) { this.items = items; }
public Boolean getHasMore() { return hasMore; }
public void setHasMore(Boolean hasMore) { this.hasMore = hasMore; }
public Integer getCount() { return count; }
public void setCount(Integer count) { this.count = count; }
}

View File

@@ -0,0 +1,19 @@
package de.frigosped.dc.model;
/**
* Request-Body für PUT /api/dc/projects/:id/progress
* {"progress": 45}
*/
public class ProgressRequest {
private Integer progress;
public ProgressRequest() {}
public ProgressRequest(int progress) {
this.progress = progress;
}
public Integer getProgress() { return progress; }
public void setProgress(Integer v) { this.progress = v; }
}

View File

@@ -0,0 +1,55 @@
package de.frigosped.dc.model;
/**
* Request-Body für POST /api/dc/projects/:id/results
*/
public class ResultRequest {
private Long questionId;
private Long docId;
private String answer;
private Integer score;
private String resultType; // OK | UNKLAR | NOK
private Integer deviation; // 0 oder 1
private Integer warning; // 0 oder 1
private String theComment;
public ResultRequest() {}
public ResultRequest(Long questionId, Long docId, String answer,
Integer score, String resultType,
Integer deviation, Integer warning, String theComment) {
this.questionId = questionId;
this.docId = docId;
this.answer = answer;
this.score = score;
this.resultType = resultType;
this.deviation = deviation;
this.warning = warning;
this.theComment = theComment;
}
public Long getQuestionId() { return questionId; }
public void setQuestionId(Long v) { this.questionId = v; }
public Long getDocId() { return docId; }
public void setDocId(Long v) { this.docId = v; }
public String getAnswer() { return answer; }
public void setAnswer(String v) { this.answer = v; }
public Integer getScore() { return score; }
public void setScore(Integer v) { this.score = v; }
public String getResultType() { return resultType; }
public void setResultType(String v) { this.resultType = v; }
public Integer getDeviation() { return deviation; }
public void setDeviation(Integer v) { this.deviation = v; }
public Integer getWarning() { return warning; }
public void setWarning(Integer v) { this.warning = v; }
public String getTheComment() { return theComment; }
public void setTheComment(String v) { this.theComment = v; }
}

View File

@@ -0,0 +1,24 @@
package de.frigosped.dc.model;
/**
* Request-Body für PUT /api/dc/documents/:id/texts
* {"original_text": "...", "translated_text": "..."}
*/
public class TextsRequest {
private String originalText;
private String translatedText;
public TextsRequest() {}
public TextsRequest(String originalText, String translatedText) {
this.originalText = originalText;
this.translatedText = translatedText;
}
public String getOriginalText() { return originalText; }
public void setOriginalText(String v) { this.originalText = v; }
public String getTranslatedText() { return translatedText; }
public void setTranslatedText(String v) { this.translatedText = v; }
}

View File

@@ -0,0 +1,82 @@
package de.frigosped.dc.resource;
import de.frigosped.dc.service.CheckOrchestrationService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* REST-Endpunkte des Dokumenten-Check-Backend.
*
* POST /check/{projectId}
* → Startet die asynchrone Verarbeitung eines Projekts.
* → Gibt sofort 202 Accepted zurück; Fortschritt über ORDS-Endpunkte ablesbar.
*
* GET /check/health
* → Einfacher Health-Check.
*/
@Path("/check")
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = "Dokumenten-Check", description = "KI-gestützte Dokumentenprüfung")
public class CheckResource {
@Inject
CheckOrchestrationService orchestrationService;
// =========================================================================
// POST /check/{projectId}
// =========================================================================
@POST
@Path("/{projectId}")
@Operation(
summary = "Startet die Dokumentenprüfung für ein Projekt",
description = """
Triggert den vollständigen Prüfablauf asynchron:
1. OCR / Konvertierung der Dokumente (KI)
2. Übersetzung ins Deutsche (KI)
3. Auswertung des Fragenkatalogs (KI)
4. Ergebnisse werden in ORDS gespeichert
Das Projekt muss im Status PENDING sein.
Fortschritt: GET {ords}/api/dc/projects/{id}
"""
)
public Response startCheck(@PathParam("projectId") long projectId) {
// Asynchron starten Fire & Forget.
// Der aufrufende Thread (z.B. APEX-Button) bekommt sofort 202.
// Der Verarbeitungs-Thread läuft im ForkJoin-CommonPool.
CompletableFuture
.runAsync(() -> orchestrationService.process(projectId))
.exceptionally(ex -> {
// Der Orchestrator fängt intern; dies ist eine letzte Absicherung.
// Logging ist dort bereits vorhanden.
return null;
});
return Response
.accepted(Map.of(
"message", "Verarbeitung gestartet",
"project_id", projectId,
"hint", "Fortschritt: GET /api/dc/projects/" + projectId
))
.build();
}
// =========================================================================
// GET /check/health
// =========================================================================
@GET
@Path("/health")
@Operation(summary = "Health-Check", description = "Gibt UP zurück wenn der Service läuft.")
public Response health() {
return Response.ok(Map.of("status", "UP", "service", "dc-backend")).build();
}
}

View File

@@ -0,0 +1,56 @@
package de.frigosped.dc.security;
import jakarta.annotation.Priority;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.util.Map;
import java.util.Optional;
/**
* Prüft den X-API-KEY-Header bei allen Anfragen.
* Konfiguration: dc.api.key (Env: DC_API_KEY)
* Wenn kein Key konfiguriert ist, läuft der Service ohne Auth (Dev-Modus).
*/
@Provider
@Priority(Priorities.AUTHENTICATION)
public class ApiKeyFilter implements ContainerRequestFilter {
private static final Logger LOG = Logger.getLogger(ApiKeyFilter.class);
private static final String HEADER = "X-API-KEY";
@ConfigProperty(name = "dc.api.key")
Optional<String> apiKey;
@Override
public void filter(ContainerRequestContext ctx) {
// Health-Endpunkt immer durchlassen (Docker-Healthcheck)
if (ctx.getUriInfo().getPath().endsWith("/health")) {
return;
}
// Kein Key konfiguriert → Auth deaktiviert (Dev-Modus)
if (apiKey.isEmpty() || apiKey.get().isBlank()) {
LOG.warnf("DC_API_KEY nicht gesetzt Anfrage ohne Auth durchgelassen: %s",
ctx.getUriInfo().getPath());
return;
}
String provided = ctx.getHeaderString(HEADER);
if (provided == null || !provided.equals(apiKey.get())) {
LOG.warnf("Ungültiger oder fehlender API-Key für: %s", ctx.getUriInfo().getPath());
ctx.abortWith(Response
.status(Response.Status.UNAUTHORIZED)
.type(MediaType.APPLICATION_JSON)
.entity(Map.of("error", "Ungültiger oder fehlender API-Key",
"header", HEADER))
.build());
}
}
}

View File

@@ -0,0 +1,221 @@
package de.frigosped.dc.service;
import de.frigosped.dc.client.OrdsClient;
import de.frigosped.dc.model.*;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Orchestriert den kompletten Prüf-Ablauf für ein Projekt:
*
* 1. Projekt laden → IN_PROGRESS setzen
* 2. Dokumente herunterladen → OCR / Konvertierung → Texte speichern
* 3. Texte ins Deutsche übersetzen → gespeichert
* 4. Fragenkatalog für jeden Dokument-Text auswerten
* 5. Ergebnisse Frage × Dokument in DB speichern
* 6. Projekt auf COMPLETED setzen
*
* Wird von CheckResource.startCheck() asynchron (CompletableFuture) aufgerufen.
*/
@ApplicationScoped
public class CheckOrchestrationService {
private static final Logger LOG = Logger.getLogger(CheckOrchestrationService.class);
@RestClient
OrdsClient ordsClient;
@Inject
DocumentProcessingService documentProcessingService;
@Inject
EvaluationService evaluationService;
// =========================================================================
// Haupt-Einstiegspunkt
// =========================================================================
public void process(long projectId) {
LOG.infof("=== Starte Verarbeitung Projekt %d ===", projectId);
try {
// 1. Projekt laden
OrdsProject project = ordsClient.getProject(projectId);
LOG.infof("Projekt: '%s', Status: %s, Katalog: %d",
project.getName(), project.getStatus(), project.getCatalogId());
// Absicherung: nur PENDING starten
if (!"PENDING".equals(project.getStatus())) {
LOG.warnf("Projekt %d ist nicht PENDING (ist: %s) Abbruch",
projectId, project.getStatus());
return;
}
// 2. Status → IN_PROGRESS
Response startResp = ordsClient.startProject(projectId);
if (startResp.getStatus() == 409) {
LOG.warnf("Projekt %d konnte nicht gestartet werden (409) Abbruch", projectId);
return;
}
// 3. Dokumente + Fragen laden
List<OrdsDocument> documents = ordsClient.getDocuments(projectId).getItems();
List<OrdsQuestion> questions = ordsClient
.getQuestions(project.getCatalogId()).getItems();
LOG.infof("Projekt %d: %d Dokument(e), %d Frage(n)",
projectId, documents.size(), questions.size());
if (documents.isEmpty()) {
LOG.warnf("Projekt %d hat keine Dokumente markiere als abgeschlossen", projectId);
ordsClient.completeProject(projectId);
return;
}
// 4. Fortschrittsberechnung
// Pro Dokument: 1 Schritt OCR/Übersetzung + questions.size() Auswertungsschritte
int totalSteps = documents.size() * (1 + questions.size());
int[] stepHolder = {0}; // Array für Verwendung in Lambda
// 5. Alte Ergebnisse löschen (ermöglicht Re-Processing)
Response delResp = ordsClient.deleteResults(projectId);
LOG.debugf("Alte Ergebnisse gelöscht: %s", delResp.getStatus());
// 6. Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS)
Map<Long, String> originalTexts = new HashMap<>();
// ─── PHASE A: OCR + Übersetzung ───────────────────────────────
for (OrdsDocument doc : documents) {
LOG.infof("Verarbeite Dokument %d: %s", doc.getId(), doc.getFilename());
String originalText = "";
String translatedText = "";
try {
// Datei herunterladen
Response fileResp = ordsClient.downloadFile(doc.getId());
if (fileResp.getStatus() != 200) {
LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)",
doc.getId(), fileResp.getStatus());
} else {
byte[] fileBytes = fileResp.readEntity(byte[].class);
// OCR / Konvertierung → Markdown
originalText = documentProcessingService.extractText(
fileBytes, doc.getMimeType(), doc.getFilename());
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
(long) doc.getId(), (long) originalText.length());
// Übersetzung ins Deutsche
translatedText = evaluationService.translate(originalText);
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
(long) doc.getId(), (long) translatedText.length());
// Texte in ORDS zurückschreiben
ordsClient.updateTexts(doc.getId(),
new TextsRequest(originalText, translatedText));
}
} catch (Exception e) {
LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
}
originalTexts.put(doc.getId(), originalText);
// Fortschritt aktualisieren
stepHolder[0]++;
pushProgress(projectId, stepHolder[0], totalSteps);
}
// ─── PHASE B: Fragen auswerten ────────────────────────────────
for (OrdsDocument doc : documents) {
String originalText = originalTexts.getOrDefault(doc.getId(), "");
if (originalText.isBlank()) {
LOG.warnf("Kein Text für Dokument %d überspringe Auswertung", doc.getId());
stepHolder[0] += questions.size();
pushProgress(projectId, stepHolder[0], totalSteps);
continue;
}
for (OrdsQuestion question : questions) {
try {
LOG.debugf("Auswertung: Dok %d × Frage %d",
doc.getId(), question.getQuestionId());
EvaluationResult result = evaluationService.evaluate(question, originalText);
// Abweichung / Hinweis aus Schwellwert + result_handling berechnen
int deviation = 0;
int warning = 0;
if (result.getScore() != null && question.getThreshold() != null
&& result.getScore() < question.getThreshold()) {
if ("ABWEICHUNG".equalsIgnoreCase(question.getResultHandling())) {
deviation = 1;
} else if ("HINWEIS".equalsIgnoreCase(question.getResultHandling())) {
warning = 1;
}
}
ResultRequest rr = new ResultRequest(
question.getQuestionId(),
doc.getId(),
result.getAnswer(),
result.getScore(),
result.getResultType(),
deviation,
warning,
result.getTheComment()
);
Response saveResp = ordsClient.saveResult(projectId, rr);
if (saveResp.getStatus() != 201) {
LOG.warnf("Ergebnis speichern: HTTP %d (Frage %d, Dok %d)",
saveResp.getStatus(), question.getQuestionId(), doc.getId());
}
} catch (Exception e) {
LOG.errorf(e, "Auswertungsfehler: Frage %d / Dokument %d",
question.getQuestionId(), doc.getId());
}
stepHolder[0]++;
pushProgress(projectId, stepHolder[0], totalSteps);
}
}
// 7. Abschließen
ordsClient.completeProject(projectId);
LOG.infof("=== Projekt %d erfolgreich abgeschlossen ===", projectId);
} catch (Exception e) {
// Kritischer Fehler: Projekt bleibt in IN_PROGRESS.
// APEX-Oberfläche / Admin muss manuell zurücksetzen.
LOG.errorf(e, "=== Kritischer Fehler bei Projekt %d bleibt IN_PROGRESS ===",
projectId);
}
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/**
* Sendet den aktuellen Fortschritt an ORDS (0100%).
* Fehler werden nur geloggt, kein Abbruch.
*/
private void pushProgress(long projectId, int step, int total) {
int pct = total > 0 ? Math.min(step * 100 / total, 99) : 0;
try {
ordsClient.updateProgress(projectId, new ProgressRequest(pct));
} catch (Exception e) {
LOG.debugf("Fortschritt konnte nicht gesetzt werden (%d%%): %s", pct, e.getMessage());
}
}
}

View File

@@ -0,0 +1,270 @@
package de.frigosped.dc.service;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatLanguageModel;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.poi.xwpf.usermodel.*;
import org.jboss.logging.Logger;
import org.odftoolkit.odfdom.doc.OdfTextDocument;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
* Konvertiert hochgeladene Dokumente in Markdown-Text.
*
* Unterstützte Formate:
* - PDF → KI-OCR (jede Seite als Bild an quen3.5:9b)
* - DOCX → Apache POI → Markdown
* - ODT → ODF Toolkit → Markdown
* - TXT → direkt als Markdown
*/
@ApplicationScoped
public class DocumentProcessingService {
private static final Logger LOG = Logger.getLogger(DocumentProcessingService.class);
/** 200 DPI: gutes Gleichgewicht zwischen Qualität und Dateigröße für OCR */
private static final float OCR_DPI = 200f;
@Inject
@Named("ocr")
ChatLanguageModel ocrModel;
// =========================================================================
// Öffentliche API
// =========================================================================
/**
* Extrahiert den Textinhalt eines Dokuments als Markdown.
*
* @param fileBytes rohe Datei-Bytes (aus BLOB)
* @param mimeType MIME-Typ des Dokuments
* @param filename Dateiname (Fallback für MIME-Typ-Erkennung)
* @return Markdown-String
*/
public String extractText(byte[] fileBytes, String mimeType, String filename) {
String effectiveMime = resolveMimeType(mimeType, filename);
LOG.debugf("Extrahiere Text: %s (%s)", filename, effectiveMime);
try {
return switch (effectiveMime) {
case "application/pdf"
-> extractFromPdf(fileBytes);
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword"
-> extractFromDocx(fileBytes);
case "application/vnd.oasis.opendocument.text"
-> extractFromOdt(fileBytes);
case "text/plain", "text/markdown"
-> new String(fileBytes, java.nio.charset.StandardCharsets.UTF_8);
default -> {
LOG.warnf("Unbekannter MIME-Typ '%s' versuche als Text", effectiveMime);
yield new String(fileBytes, java.nio.charset.StandardCharsets.UTF_8);
}
};
} catch (Exception e) {
LOG.errorf(e, "Fehler bei Textextraktion (%s)", filename);
throw new RuntimeException("Textextraktion fehlgeschlagen: " + e.getMessage(), e);
}
}
// =========================================================================
// PDF: Seiten rendern + KI-OCR
// =========================================================================
private String extractFromPdf(byte[] fileBytes) throws Exception {
LOG.debug("Starte PDF-OCR...");
StringBuilder result = new StringBuilder();
try (PDDocument pdf = Loader.loadPDF(fileBytes)) {
PDFRenderer renderer = new PDFRenderer(pdf);
int pageCount = pdf.getNumberOfPages();
LOG.debugf("PDF hat %d Seite(n)", pageCount);
for (int i = 0; i < pageCount; i++) {
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);
result.append(pageText).append("\n\n");
}
}
return result.toString().trim();
}
/**
* Sendet eine PDF-Seite als Base64-Bild an das Vision-Modell.
*/
private String ocrPage(String base64Image, int pageNum, int totalPages) {
UserMessage message = UserMessage.from(
ImageContent.from(base64Image, "image/png"),
TextContent.from(
"Du bist ein OCR-Spezialist. " +
"Extrahiere den gesamten Text aus diesem Dokument-Bild (Seite " + pageNum
+ " von " + totalPages + ").\n\n" +
"Regeln:\n" +
"- Formatiere den Text als Markdown\n" +
"- Überschriften als # / ## / ###\n" +
"- Tabellen als Markdown-Tabellen\n" +
"- Listen als - oder 1. 2. 3.\n" +
"- Behalte den Originaltext exakt (keine Korrekturen, keine Zusammenfassungen)\n" +
"- Antworte NUR mit dem extrahierten Markdown, ohne Präambel"
)
);
return ocrModel.generate(List.of(message)).content().text();
}
// =========================================================================
// DOCX → Markdown (Apache POI)
// =========================================================================
private String extractFromDocx(byte[] fileBytes) throws Exception {
LOG.debug("Konvertiere DOCX → Markdown...");
StringBuilder md = new StringBuilder();
try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(fileBytes))) {
for (IBodyElement element : doc.getBodyElements()) {
if (element instanceof XWPFParagraph para) {
md.append(paragraphToMarkdown(para)).append("\n");
} else if (element instanceof XWPFTable table) {
md.append(tableToMarkdown(table)).append("\n");
}
}
}
return md.toString().trim();
}
private String paragraphToMarkdown(XWPFParagraph para) {
String text = para.getText();
if (text == null || text.isBlank()) return "";
String style = para.getStyle();
if (style == null) return text;
// Überschriften
if (style.equalsIgnoreCase("Heading1") || style.equalsIgnoreCase("berschrift1"))
return "# " + text;
if (style.equalsIgnoreCase("Heading2") || style.equalsIgnoreCase("berschrift2"))
return "## " + text;
if (style.equalsIgnoreCase("Heading3") || style.equalsIgnoreCase("berschrift3"))
return "### " + text;
if (style.equalsIgnoreCase("Heading4") || style.equalsIgnoreCase("berschrift4"))
return "#### " + text;
// Nummerierte Liste
if (para.getNumIlvl() != null) {
return "- " + text;
}
return text;
}
private String tableToMarkdown(XWPFTable table) {
StringBuilder sb = new StringBuilder();
List<XWPFTableRow> rows = table.getRows();
if (rows.isEmpty()) return "";
// Header-Zeile
XWPFTableRow header = rows.get(0);
sb.append("| ");
header.getTableCells().forEach(c -> sb.append(c.getText()).append(" | "));
sb.append("\n|");
header.getTableCells().forEach(c -> sb.append("---|"));
sb.append("\n");
// Daten-Zeilen
for (int i = 1; i < rows.size(); i++) {
sb.append("| ");
rows.get(i).getTableCells().forEach(c -> sb.append(c.getText()).append(" | "));
sb.append("\n");
}
return sb.toString();
}
// =========================================================================
// ODT → Markdown (ODF Toolkit)
// =========================================================================
private String extractFromOdt(byte[] fileBytes) throws Exception {
LOG.debug("Konvertiere ODT → Markdown...");
StringBuilder md = new StringBuilder();
try (OdfTextDocument odfDoc = OdfTextDocument.loadDocument(
new ByteArrayInputStream(fileBytes))) {
NodeList paragraphs = odfDoc.getContentDom()
.getElementsByTagName("text:p");
for (int i = 0; i < paragraphs.getLength(); i++) {
Node node = paragraphs.item(i);
String text = node.getTextContent();
if (text != null && !text.isBlank()) {
// Stil-Attribut auslesen
Node styleAttr = node.getAttributes()
.getNamedItem("text:style-name");
String style = styleAttr != null ? styleAttr.getNodeValue() : "";
if (style.startsWith("Heading_20_1") || style.startsWith("Heading1"))
md.append("# ").append(text).append("\n");
else if (style.startsWith("Heading_20_2") || style.startsWith("Heading2"))
md.append("## ").append(text).append("\n");
else if (style.startsWith("Heading_20_3") || style.startsWith("Heading3"))
md.append("### ").append(text).append("\n");
else
md.append(text).append("\n");
}
}
}
return md.toString().trim();
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/**
* Leitet MIME-Typ aus Dateiendung ab, wenn der gespeicherte MIME-Typ
* fehlt oder generic ist.
*/
private String resolveMimeType(String mimeType, String filename) {
if (mimeType != null && !mimeType.isBlank()
&& !mimeType.equals("application/octet-stream")) {
return mimeType;
}
if (filename == null) return "application/octet-stream";
String lower = filename.toLowerCase();
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
if (lower.endsWith(".doc")) return "application/msword";
if (lower.endsWith(".odt")) return "application/vnd.oasis.opendocument.text";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".md")) return "text/markdown";
return "application/octet-stream";
}
}

View File

@@ -0,0 +1,241 @@
package de.frigosped.dc.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.frigosped.dc.model.EvaluationResult;
import de.frigosped.dc.model.OrdsQuestion;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.output.Response;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* KI-gestützte Auswertung:
* 1. Übersetzung eines Dokuments ins Deutsche
* 2. Prüfung des Dokuments gegen eine einzelne Frage
*
* Verwendet das Hauptmodell (gpt-oss:20b).
*/
@ApplicationScoped
public class EvaluationService {
private static final Logger LOG = Logger.getLogger(EvaluationService.class);
/** Regex zum Extrahieren von JSON aus der KI-Antwort (toleriert Markdown-Codeblöcke) */
private static final Pattern JSON_PATTERN =
Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```|([{][\\s\\S]*[}])",
Pattern.DOTALL);
@Inject
@Named("main")
ChatLanguageModel mainModel;
@Inject
ObjectMapper objectMapper;
// =========================================================================
// Übersetzung
// =========================================================================
/**
* Übersetzt einen Markdown-Text aus der Quellsprache ins Deutsche.
* Wenn der Text bereits auf Deutsch ist, wird er unverändert zurückgegeben.
*
* @param originalText Markdown-Text in Originalsprache
* @return Markdown-Text auf Deutsch
*/
public String translate(String originalText) {
if (originalText == null || originalText.isBlank()) return "";
LOG.debugf("Starte Übersetzung (%d Zeichen)...", originalText.length());
List<ChatMessage> messages = List.of(
SystemMessage.from(
"Du bist ein professioneller Übersetzer für rechtliche und logistische Dokumente.\n" +
"Übersetze den folgenden Text ins Deutsche.\n" +
"Regeln:\n" +
"- Behalte die Markdown-Formatierung exakt bei\n" +
"- Fachbegriffe korrekt übersetzen\n" +
"- Wenn der Text bereits auf Deutsch ist, gib ihn unverändert zurück\n" +
"- Antworte NUR mit dem übersetzten Text, ohne Kommentar oder Präambel"
),
UserMessage.from(originalText)
);
Response<AiMessage> response = mainModel.generate(messages);
return response.content().text().trim();
}
// =========================================================================
// Fragen-Auswertung
// =========================================================================
/**
* Wertet eine einzelne Frage gegen den Dokument-Text aus.
*
* Der Schwellwert (threshold) und die Abweichungs-Logik werden
* im Orchestrator berechnet die KI liefert nur score + answer + comment.
*
* @param question Frage aus dem Katalog (inkl. Beispiele, Schwellwert)
* @param documentText Originaltext des Dokuments (Markdown)
* @return Strukturiertes Auswertungsergebnis
*/
public EvaluationResult evaluate(OrdsQuestion question, String documentText) {
LOG.debugf("Werte Frage %d aus: %s",
question.getQuestionId(),
abbreviate(question.getQuestionText(), 80));
String systemPrompt = buildEvaluationSystem();
String userPrompt = buildEvaluationUser(question, documentText);
List<ChatMessage> messages = List.of(
SystemMessage.from(systemPrompt),
UserMessage.from(userPrompt)
);
Response<AiMessage> response = mainModel.generate(messages);
String rawText = response.content().text();
return parseEvaluationResult(rawText, question);
}
// =========================================================================
// Prompt-Builder
// =========================================================================
private String buildEvaluationSystem() {
return """
Du bist ein Experte für Transportrecht, AGB-Recht und Vertragsanalyse.
Du prüfst Transport- und Speditionsdokumente gegen einen Fragenkatalog.
Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Objekt kein Text davor oder danach.
Das JSON-Objekt muss folgende Felder enthalten:
{
"answer": "<Deine Antwort auf Deutsch. Zitate aus dem Dokument in Originalsprache angeben und danach in Klammern übersetzen>",
"score": <Zahl 0 bis 100, wie gut das Dokument die Anforderung erfüllt>,
"result_type": "<OK wenn score >= Schwellwert, UNKLAR wenn grenzwertig, NOK wenn klar nicht erfüllt>",
"the_comment": "<Kurzer fachlicher Kommentar auf Deutsch, max. 3 Sätze>"
}
""";
}
private String buildEvaluationUser(OrdsQuestion question, String documentText) {
StringBuilder sb = new StringBuilder();
sb.append("## Zu prüfendes Dokument\n\n");
sb.append(documentText).append("\n\n");
sb.append("---\n\n");
sb.append("## Prüffrage (Kategorie: ").append(question.getCategoryName()).append(")\n\n");
sb.append(question.getQuestionText()).append("\n\n");
if (question.getThreshold() != null) {
sb.append("**Schwellwert:** ").append(question.getThreshold())
.append("% unterhalb gilt die Anforderung als nicht erfüllt\n\n");
}
if (question.getExample0Percent() != null && !question.getExample0Percent().isBlank()) {
sb.append("**Beispiel: 0% Erfüllung (nicht ok):**\n")
.append(question.getExample0Percent()).append("\n\n");
}
if (question.getExample100Percent() != null && !question.getExample100Percent().isBlank()) {
sb.append("**Beispiel: 100% Erfüllung (vollständig ok):**\n")
.append(question.getExample100Percent()).append("\n\n");
}
sb.append("---\n\nAntworte jetzt mit dem JSON-Objekt:");
return sb.toString();
}
// =========================================================================
// JSON-Parsing mit Fallback
// =========================================================================
/**
* Parst das KI-Ergebnis zu einem EvaluationResult.
* Toleriert Markdown-Codeblöcke und leichte Formatierungsfehler.
*/
private EvaluationResult parseEvaluationResult(String rawText, OrdsQuestion question) {
try {
String json = extractJson(rawText);
// Jackson liest snake_case dank quarkus.jackson.property-naming-strategy=SNAKE_CASE
EvaluationResult result = objectMapper.readValue(json, EvaluationResult.class);
// Validierung + Defaults
if (result.getScore() == null) result.setScore(0);
if (result.getResultType() == null || !isValidResultType(result.getResultType())) {
result.setResultType(deriveResultType(result.getScore(), question.getThreshold()));
}
if (result.getAnswer() == null) result.setAnswer("Keine Antwort erhalten.");
return result;
} catch (Exception e) {
LOG.warnf("JSON-Parsing fehlgeschlagen für Frage %d, verwende Fallback. Fehler: %s",
question.getQuestionId(), e.getMessage());
LOG.debugf("Rohe KI-Antwort: %s", rawText);
// Fallback: UNKLAR mit dem Rohtext als Antwort
EvaluationResult fallback = new EvaluationResult();
fallback.setAnswer(rawText != null ? rawText.trim() : "Parsing-Fehler");
fallback.setScore(0);
fallback.setResultType("UNKLAR");
fallback.setTheComment("Automatische Auswertung konnte nicht strukturiert werden.");
return fallback;
}
}
/**
* Extrahiert den JSON-Teil aus der KI-Antwort
* (toleriert ```json ... ``` Blöcke oder reines JSON).
*/
private String extractJson(String text) {
if (text == null || text.isBlank()) {
throw new IllegalArgumentException("Leere KI-Antwort");
}
// Versuch 1: JSON in ```-Block
Matcher m = JSON_PATTERN.matcher(text);
if (m.find()) {
String candidate = m.group(1) != null ? m.group(1) : m.group(2);
if (candidate != null && !candidate.isBlank()) {
return candidate.trim();
}
}
// Versuch 2: Alles ab erstem { bis letztem }
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
throw new IllegalArgumentException("Kein JSON-Objekt in KI-Antwort gefunden");
}
private boolean isValidResultType(String type) {
return "OK".equals(type) || "UNKLAR".equals(type) || "NOK".equals(type);
}
private String deriveResultType(int score, Integer threshold) {
if (threshold == null) threshold = 70;
if (score >= threshold) return "OK";
if (score >= threshold / 2) return "UNKLAR";
return "NOK";
}
private String abbreviate(String s, int maxLen) {
if (s == null) return "";
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "";
}
}