initial commit
This commit is contained in:
110
dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java
Normal file
110
dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java
Normal 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));
|
||||
}
|
||||
}
|
||||
125
dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
Normal file
125
dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
Normal 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 (0–100).
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 0–100 */
|
||||
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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; // 0–100
|
||||
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; }
|
||||
}
|
||||
@@ -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 0–100
|
||||
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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (0–100%).
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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) + "…";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user