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

4
dc-backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
target/
.mvn/
*.md
dev.sh

44
dc-backend/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# =============================================================================
# Stage 1: Build
# =============================================================================
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
# Dependencies zuerst cachen (Layer-Cache bei Code-Änderungen stabil)
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q
# =============================================================================
# Stage 2: Runtime
# =============================================================================
FROM eclipse-temurin:21-jre
WORKDIR /app
# Quarkus Fast-JAR Layout kopieren
COPY --from=build /build/target/quarkus-app/lib/ ./lib/
COPY --from=build /build/target/quarkus-app/*.jar ./
COPY --from=build /build/target/quarkus-app/app/ ./app/
COPY --from=build /build/target/quarkus-app/quarkus/ ./quarkus/
EXPOSE 8090
# Alle Konfigurationswerte können als Env-Variablen überschrieben werden:
#
# DC_API_KEY API-Key für eingehende Anfragen
# DC_AI_BASE_URL Ollama-Endpunkt
# DC_AI_API_KEY Ollama X-API-KEY
# DC_AI_MAIN_MODEL Hauptmodell (Übersetzung + Auswertung)
# DC_AI_OCR_MODEL OCR-Modell
# QUARKUS_REST_CLIENT_ORDS_API_URL ORDS-Basis-URL
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -sf http://localhost:8090/check/health || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar quarkus-run.jar"]

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# Build dc-backend Docker-Image und push auf Registry
# =============================================================================
REGISTRY="registry.express-o.org"
IMAGE_NAME="frigosped/dc-backend"
TAG="${1:-latest}"
FULL_IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== dc-backend Build & Push ==="
echo "Image : $FULL_IMAGE"
echo ""
# --- Build ---
echo "→ Build Image..."
podman build \
--format docker \
--tag "$FULL_IMAGE" \
--label "build.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "build.version=$TAG" \
"$SCRIPT_DIR"
# Bei explizitem Tag zusätzlich 'latest' setzen
if [ "$TAG" != "latest" ]; then
podman tag "$FULL_IMAGE" "$REGISTRY/$IMAGE_NAME:latest"
echo "→ Auch getaggt als: $REGISTRY/$IMAGE_NAME:latest"
fi
# --- Push ---
echo "→ Push $FULL_IMAGE ..."
podman push "$FULL_IMAGE"
if [ "$TAG" != "latest" ]; then
echo "→ Push $REGISTRY/$IMAGE_NAME:latest ..."
podman push "$REGISTRY/$IMAGE_NAME:latest"
fi
echo ""
echo "✓ Fertig: $FULL_IMAGE"

5
dc-backend/dev.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
export PATH="$JAVA_HOME/bin:$PATH"
cd "$(dirname "$0")"
exec quarkus dev

View File

@@ -0,0 +1,88 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: dc-backend
namespace: ai-env
labels:
app: dc-backend
spec:
replicas: 1
selector:
matchLabels:
app: dc-backend
template:
metadata:
labels:
app: dc-backend
spec:
containers:
- name: dc-backend
image: registry.express-o.org/frigosped/dc-backend:latest
imagePullPolicy: Always
ports:
- containerPort: 8090
env:
# ORDS-Verbindung (Service im selben Namespace)
- name: QUARKUS_REST_CLIENT_ORDS_API_URL
value: "http://ords:8080/ords/FRIGOSPED_APP"
# API-Key für eingehende Anfragen (DC_API_KEY)
- name: DC_API_KEY
valueFrom:
secretKeyRef:
name: dc-backend-secrets
key: api-key
optional: true
# Ollama KI-Endpunkt
- name: DC_AI_BASE_URL
value: "https://ollama.aquantico.de"
- name: DC_AI_API_KEY
valueFrom:
secretKeyRef:
name: dc-backend-secrets
key: ollama-api-key
optional: true
livenessProbe:
httpGet:
path: /check/health
port: 8090
initialDelaySeconds: 20
periodSeconds: 30
failureThreshold: 3
readinessProbe:
httpGet:
path: /check/health
port: 8090
initialDelaySeconds: 15
periodSeconds: 10
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: dc-backend
namespace: ai-env
labels:
app: dc-backend
spec:
selector:
app: dc-backend
ports:
- port: 8090
targetPort: 8090
type: ClusterIP
---
apiVersion: v1
kind: Secret
metadata:
name: dc-backend-secrets
namespace: ai-env
type: Opaque
stringData:
api-key: ""
ollama-api-key: "324GF44-50AA-4B57-9386-K435DLJ764DFR"

18
dc-backend/logs.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export KUBECONFIG="$SCRIPT_DIR/../env/frigo-dev.yaml"
POD=$(kubectl get pod -n ai-env -l app=dc-backend \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [ -z "$POD" ]; then
echo "Kein laufender dc-backend Pod gefunden. Alle Pods:"
kubectl get pods -n ai-env -l app=dc-backend
exit 1
fi
echo "=== Logs: $POD ==="
kubectl logs -f "$POD" -n ai-env

146
dc-backend/pom.xml Normal file
View File

@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.frigosped</groupId>
<artifactId>dc-backend</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<quarkus.platform.version>3.15.1</quarkus.platform.version>
<langchain4j.version>0.36.2</langchain4j.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-bom</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- ================================================================ -->
<!-- Quarkus REST Server -->
<!-- ================================================================ -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<!-- MicroProfile REST Client für ORDS-Aufrufe -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>
<!-- Config -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-config-yaml</artifactId>
</dependency>
<!-- OpenAPI / Swagger UI (optional, für Entwicklung) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
<!-- ================================================================ -->
<!-- LangChain4J (roh, ohne Quarkus-Extension volle Kontrolle -->
<!-- über Modell-Konfiguration und Custom-Headers) -->
<!-- ================================================================ -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- ================================================================ -->
<!-- Dokumentenverarbeitung -->
<!-- ================================================================ -->
<!-- PDF → Seitenbild (für KI-OCR) -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
<!-- DOCX → Markdown -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
<!-- ODT → Text -->
<dependency>
<groupId>org.odftoolkit</groupId>
<artifactId>odfdom-java</artifactId>
<version>0.12.0</version>
</dependency>
<!-- ================================================================ -->
<!-- Tests -->
<!-- ================================================================ -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

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) + "";
}
}

View File

@@ -0,0 +1,64 @@
# =============================================================================
# Dokumenten-Check Backend Konfiguration
# =============================================================================
quarkus.application.name=dc-backend
quarkus.http.port=8090
# =============================================================================
# API-Key Absicherung (Env: DC_API_KEY)
# Leer = Auth deaktiviert (nur für lokale Entwicklung)
# =============================================================================
dc.api.key=${DC_API_KEY:}
# =============================================================================
# KI-Modelle (Ollama-kompatibler Endpunkt)
# =============================================================================
dc.ai.base-url=https://ollama.aquantico.de
dc.ai.api-key=324GF44-50AA-4B57-9386-K435DLJ764DFR
# Hauptmodell: Auswertung + Übersetzung
dc.ai.main.model=qwen3.6:35b-a3b-q4_K_M
dc.ai.main.timeout=300s
dc.ai.main.max-retries=2
dc.ai.main.temperature=0.1
# OCR-Modell: Bildtext-Extraktion aus PDF-Seiten
dc.ai.ocr.model=qwen3.6:35b-a3b-q4_K_M
dc.ai.ocr.timeout=120s
dc.ai.ocr.max-retries=2
# =============================================================================
# ORDS REST Client
# =============================================================================
# Basis-URL des ORDS-Servers (Schema-Pfad anpassen)
quarkus.rest-client.ords-api.url=http://ords:8080/ords/ai_dev
quarkus.rest-client.ords-api.scope=jakarta.inject.Singleton
quarkus.rest-client.ords-api.connect-timeout=10000
quarkus.rest-client.ords-api.read-timeout=60000
# ORDS-Authentifizierung (OAuth2 Bearer Token leer = kein Auth in Dev)
dc.ords.bearer-token=
# =============================================================================
# Logging
# =============================================================================
quarkus.log.level=INFO
quarkus.log.category."de.frigosped".level=DEBUG
# KI-Kommunikation mitloggen (nur in Dev)
%dev.dc.ai.log-requests=true
%dev.dc.ai.log-responses=true
%prod.dc.ai.log-requests=false
%prod.dc.ai.log-responses=false
# =============================================================================
# Jackson: snake_case für ORDS-Responses
# =============================================================================
quarkus.jackson.property-naming-strategy=SNAKE_CASE
# =============================================================================
# OpenAPI
# =============================================================================
quarkus.smallrye-openapi.path=/openapi
quarkus.swagger-ui.always-include=true

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,64 @@
# =============================================================================
# Dokumenten-Check Backend Konfiguration
# =============================================================================
quarkus.application.name=dc-backend
quarkus.http.port=8090
# =============================================================================
# API-Key Absicherung (Env: DC_API_KEY)
# Leer = Auth deaktiviert (nur für lokale Entwicklung)
# =============================================================================
dc.api.key=${DC_API_KEY:}
# =============================================================================
# KI-Modelle (Ollama-kompatibler Endpunkt)
# =============================================================================
dc.ai.base-url=https://ollama.aquantico.de
dc.ai.api-key=324GF44-50AA-4B57-9386-K435DLJ764DFR
# Hauptmodell: Auswertung + Übersetzung
dc.ai.main.model=gpt-oss:20b
dc.ai.main.timeout=300s
dc.ai.main.max-retries=2
dc.ai.main.temperature=0.1
# OCR-Modell: Bildtext-Extraktion aus PDF-Seiten
dc.ai.ocr.model=quen3.5:9b
dc.ai.ocr.timeout=120s
dc.ai.ocr.max-retries=2
# =============================================================================
# ORDS REST Client
# =============================================================================
# Basis-URL des ORDS-Servers (Schema-Pfad anpassen)
quarkus.rest-client.ords-api.url=http://localhost:8080/ords/FRIGOSPED_APP
quarkus.rest-client.ords-api.scope=jakarta.inject.Singleton
quarkus.rest-client.ords-api.connect-timeout=10000
quarkus.rest-client.ords-api.read-timeout=60000
# ORDS-Authentifizierung (OAuth2 Bearer Token leer = kein Auth in Dev)
dc.ords.bearer-token=
# =============================================================================
# Logging
# =============================================================================
quarkus.log.level=INFO
quarkus.log.category."de.frigosped".level=DEBUG
# KI-Kommunikation mitloggen (nur in Dev)
%dev.dc.ai.log-requests=true
%dev.dc.ai.log-responses=true
%prod.dc.ai.log-requests=false
%prod.dc.ai.log-responses=false
# =============================================================================
# Jackson: snake_case für ORDS-Responses
# =============================================================================
quarkus.jackson.property-naming-strategy=SNAKE_CASE
# =============================================================================
# OpenAPI
# =============================================================================
quarkus.smallrye-openapi.path=/openapi
quarkus.swagger-ui.always-include=true

View File

@@ -0,0 +1,4 @@
de/frigosped/dc/service/CheckOrchestrationService.class
de/frigosped/dc/service/EvaluationService.class
de/frigosped/dc/service/DocumentProcessingService.class
de/frigosped/dc/security/ApiKeyFilter.class

View File

@@ -0,0 +1,16 @@
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/EvaluationResult.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocument.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsProject.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ResultRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/TextsRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CheckResource.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/security/ApiKeyFilter.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java

View File

@@ -0,0 +1,12 @@
de/frigosped/dc/model/OrdsQuestion.class
de/frigosped/dc/model/OrdsQuestionList.class
de/frigosped/dc/model/ProgressRequest.class
de/frigosped/dc/client/OrdsClient.class
de/frigosped/dc/ai/AiModelProducer.class
de/frigosped/dc/model/OrdsDocument.class
de/frigosped/dc/model/OrdsProject.class
de/frigosped/dc/model/OrdsDocumentList.class
de/frigosped/dc/model/TextsRequest.class
de/frigosped/dc/model/ResultRequest.class
de/frigosped/dc/model/EvaluationResult.class
de/frigosped/dc/resource/CheckResource.class

View File

@@ -0,0 +1,15 @@
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/EvaluationResult.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocument.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsProject.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ResultRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/TextsRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CheckResource.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java

33
dc-backend/update.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export KUBECONFIG="$SCRIPT_DIR/../env/frigo-dev.yaml"
TAG="${1:-latest}"
echo "=== dc-backend Update ==="
echo ""
# --- Build & Push ---
echo "→ [1/3] Build & Push Image (Tag: $TAG)..."
bash "$SCRIPT_DIR/build-and-push.sh" "$TAG"
# --- Apply Kubernetes Manifests ---
echo ""
echo "→ [2/3] Kubernetes Manifests anwenden..."
kubectl apply -f "$SCRIPT_DIR/k8s/deployment.yaml"
# --- Rolling Restart (zieht neues 'latest'-Image) ---
echo ""
echo "→ [3/3] Rolling Restart..."
kubectl rollout restart deployment/dc-backend -n ai-env
echo ""
echo "→ Warte auf Rollout..."
kubectl rollout status deployment/dc-backend -n ai-env --timeout=120s
echo ""
echo "✓ Deployment abgeschlossen."
echo ""
kubectl get pods -n ai-env -l app=dc-backend