feat: Add catalog generation functionality and related models

- Implemented CatalogResource for handling catalog generation requests and status checks.
- Created CatalogGenerationService to manage the asynchronous generation process.
- Added models for catalog generation responses, requests, and structured catalog data.
- Introduced utility scripts for database interaction and Kubernetes pod retrieval.
- Enhanced error handling and logging throughout the catalog generation workflow.
This commit is contained in:
Wolf G. Beckmann
2026-05-04 19:03:54 +02:00
parent 522bc21439
commit ac9ab7ab15
21 changed files with 1853 additions and 202 deletions

View File

@@ -42,6 +42,18 @@ public class AiModelProducer {
@ConfigProperty(name = "dc.ai.main.temperature", defaultValue = "0.1")
double mainTemperature;
@ConfigProperty(name = "dc.ai.catalog.model")
String catalogModelName;
@ConfigProperty(name = "dc.ai.catalog.timeout", defaultValue = "1200s")
String catalogTimeout;
@ConfigProperty(name = "dc.ai.catalog.max-retries", defaultValue = "1")
int catalogMaxRetries;
@ConfigProperty(name = "dc.ai.catalog.temperature", defaultValue = "0.1")
double catalogTemperature;
@ConfigProperty(name = "dc.ai.ocr.model")
String ocrModelName;
@@ -77,6 +89,26 @@ public class AiModelProducer {
.build();
}
/**
* Katalog-Modell: Vollständige Katalog-Generierung aus Regelwerk-Dokument.
* Längerer Timeout (1200s) notwendig da komplette Dokumente analysiert werden.
*/
@Produces
@ApplicationScoped
@Named("catalog")
public ChatLanguageModel catalogModel() {
return OllamaChatModel.builder()
.baseUrl(baseUrl)
.modelName(catalogModelName)
.temperature(catalogTemperature)
.timeout(parseDuration(catalogTimeout))
.maxRetries(catalogMaxRetries)
.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

View File

@@ -27,7 +27,7 @@ import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
public interface OrdsClient {
// =========================================================================
// Fragenkatalog (Lesend)
// Fragenkatalog (Lesend + Schreibend)
// =========================================================================
/**
@@ -38,6 +38,52 @@ public interface OrdsClient {
@Path("/catalogs/{catalogId}/questions/")
OrdsQuestionList getQuestions(@PathParam("catalogId") long catalogId);
/**
* POST /api/dc/catalogs/
* Legt einen neuen Fragenkatalog an.
* Antwort: 201 Created, Body {"id": <new_id>, "name": "...", ...}
*/
@POST
@Path("/catalogs/")
Response createCatalog(OrdsCatalogCreateRequest body);
/**
* POST /api/dc/catalogs/:catalogId/categories/
* Legt eine neue Fragenkategorie in einem Katalog an.
* Antwort: 201 Created, Body {"id": <new_id>, ...}
*/
@POST
@Path("/catalogs/{catalogId}/categories/")
Response createCategory(@PathParam("catalogId") long catalogId,
OrdsCategoryCreateRequest body);
/**
* POST /api/dc/categories/:categoryId/questions/
* Legt eine neue Frage in einer Kategorie an.
* Antwort: 201 Created, Body {"id": <new_id>, ...}
*/
@POST
@Path("/categories/{categoryId}/questions/")
Response createQuestion(@PathParam("categoryId") long categoryId,
OrdsQuestionCreateRequest body);
/**
* GET /api/dc/catalogs/:catalogId/generation-status
* Liest den Generierungsstatus (GENERATING | READY | ERROR).
*/
@GET
@Path("/catalogs/{catalogId}/generation-status")
OrdsCatalogStatusResponse getCatalogStatus(@PathParam("catalogId") long catalogId);
/**
* PUT /api/dc/catalogs/:catalogId/generation-status
* Setzt den Generierungsstatus nach Abschluss oder Fehler.
*/
@PUT
@Path("/catalogs/{catalogId}/generation-status")
Response updateCatalogStatus(@PathParam("catalogId") long catalogId,
OrdsCatalogStatusUpdateRequest body);
// =========================================================================
// Projekte (Status-Steuerung)
// =========================================================================

View File

@@ -0,0 +1,31 @@
package de.frigosped.dc.model;
public class CatalogGenerateResponse {
private long catalogId;
private String catalogName;
private String description;
private String generationStatus;
public CatalogGenerateResponse() {}
public CatalogGenerateResponse(long catalogId, String catalogName,
String description, String generationStatus) {
this.catalogId = catalogId;
this.catalogName = catalogName;
this.description = description;
this.generationStatus = generationStatus;
}
public long getCatalogId() { return catalogId; }
public void setCatalogId(long v) { this.catalogId = v; }
public String getCatalogName() { return catalogName; }
public void setCatalogName(String v) { this.catalogName = v; }
public String getDescription() { return description; }
public void setDescription(String v) { this.description = v; }
public String getGenerationStatus() { return generationStatus; }
public void setGenerationStatus(String v) { this.generationStatus = v; }
}

View File

@@ -0,0 +1,69 @@
package de.frigosped.dc.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
/**
* KI-Ausgabe: strukturierter Fragenkatalog aus einem Regelwerk-Dokument.
* Jackson mappt snake_case-JSON automatisch auf camelCase-Felder.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class GeneratedCatalogStructure {
private List<GeneratedCategory> categories;
public List<GeneratedCategory> getCategories() { return categories; }
public void setCategories(List<GeneratedCategory> categories) { this.categories = categories; }
@JsonIgnoreProperties(ignoreUnknown = true)
public static class GeneratedCategory {
private String name;
private String description;
private int orderNr;
private List<GeneratedQuestion> questions;
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public String getDescription() { return description; }
public void setDescription(String v) { this.description = v; }
public int getOrderNr() { return orderNr; }
public void setOrderNr(int v) { this.orderNr = v; }
public List<GeneratedQuestion> getQuestions() { return questions; }
public void setQuestions(List<GeneratedQuestion> questions) { this.questions = questions; }
}
@JsonIgnoreProperties(ignoreUnknown = true)
public static class GeneratedQuestion {
private String questionText;
private String evaluationType;
private Integer threshold;
private String resultHandling;
private String example0Percent;
private String example100Percent;
private int orderNr;
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 int getOrderNr() { return orderNr; }
public void setOrderNr(int v) { this.orderNr = v; }
}
}

View File

@@ -0,0 +1,25 @@
package de.frigosped.dc.model;
public class OrdsCatalogCreateRequest {
private String name;
private String description;
private long documentTypeId;
public OrdsCatalogCreateRequest() {}
public OrdsCatalogCreateRequest(String name, String description, long documentTypeId) {
this.name = name;
this.description = description;
this.documentTypeId = documentTypeId;
}
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public String getDescription() { return description; }
public void setDescription(String v) { this.description = v; }
public long getDocumentTypeId() { return documentTypeId; }
public void setDocumentTypeId(long v) { this.documentTypeId = v; }
}

View File

@@ -0,0 +1,28 @@
package de.frigosped.dc.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public class OrdsCatalogStatusResponse {
private Long catalogId;
private String name;
private String description;
private String generationStatus;
private String generationError;
public Long getCatalogId() { return catalogId; }
public void setCatalogId(Long v) { this.catalogId = v; }
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public String getDescription() { return description; }
public void setDescription(String v) { this.description = v; }
public String getGenerationStatus() { return generationStatus; }
public void setGenerationStatus(String v) { this.generationStatus = v; }
public String getGenerationError() { return generationError; }
public void setGenerationError(String v) { this.generationError = v; }
}

View File

@@ -0,0 +1,20 @@
package de.frigosped.dc.model;
public class OrdsCatalogStatusUpdateRequest {
private String generationStatus;
private String generationError;
public OrdsCatalogStatusUpdateRequest() {}
public OrdsCatalogStatusUpdateRequest(String generationStatus, String generationError) {
this.generationStatus = generationStatus;
this.generationError = generationError;
}
public String getGenerationStatus() { return generationStatus; }
public void setGenerationStatus(String v) { this.generationStatus = v; }
public String getGenerationError() { return generationError; }
public void setGenerationError(String v) { this.generationError = v; }
}

View File

@@ -0,0 +1,25 @@
package de.frigosped.dc.model;
public class OrdsCategoryCreateRequest {
private String name;
private String description;
private int orderNr;
public OrdsCategoryCreateRequest() {}
public OrdsCategoryCreateRequest(String name, String description, int orderNr) {
this.name = name;
this.description = description;
this.orderNr = orderNr;
}
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public String getDescription() { return description; }
public void setDescription(String v) { this.description = v; }
public int getOrderNr() { return orderNr; }
public void setOrderNr(int v) { this.orderNr = v; }
}

View File

@@ -0,0 +1,35 @@
package de.frigosped.dc.model;
public class OrdsQuestionCreateRequest {
private String questionText;
private String evaluationType;
private Integer threshold;
private String resultHandling;
private String example0Percent;
private String example100Percent;
private int orderNr;
public OrdsQuestionCreateRequest() {}
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 int getOrderNr() { return orderNr; }
public void setOrderNr(int v) { this.orderNr = v; }
}

View File

@@ -0,0 +1,138 @@
package de.frigosped.dc.resource;
import de.frigosped.dc.model.CatalogGenerateResponse;
import de.frigosped.dc.model.OrdsCatalogStatusResponse;
import de.frigosped.dc.service.CatalogGenerationService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import de.frigosped.dc.client.OrdsClient;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import java.nio.file.Files;
/**
* REST-Endpunkte zur automatischen Fragenkatalog-Generierung.
*
* POST /catalog/generate → 202 Accepted, Verarbeitung läuft asynchron
* GET /catalog/status/{catalogId} → Aktueller Status (GENERATING | READY | ERROR)
*
* curl-Beispiel:
* # Starten:
* curl -X POST http://localhost:8090/catalog/generate \
* -F "file=@ADSp-2017.pdf;type=application/pdf" \
* -F "catalog_name=ADSp 2017" \
* -F "document_type_id=1" \
* -F "description=Allgemeine Deutsche Spediteurbedingungen 2017"
*
* # Status prüfen:
* curl http://localhost:8090/catalog/status/42
*/
@Path("/catalog")
@Produces(MediaType.APPLICATION_JSON)
public class CatalogResource {
private static final Logger LOG = Logger.getLogger(CatalogResource.class);
@Inject
CatalogGenerationService catalogGenerationService;
@Inject
@RestClient
OrdsClient ordsClient;
/**
* POST /catalog/generate (multipart/form-data)
* Gibt sofort 202 Accepted zurück. Verarbeitung läuft im Hintergrund.
*
* Response: {"catalog_id": 42, "catalog_name": "ADSp 2017",
* "description": "...", "generation_status": "GENERATING"}
*/
@POST
@Path("/generate")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response generate(
@RestForm FileUpload file,
@RestForm("catalog_name") String catalogName,
@RestForm("document_type_id") Long documentTypeId,
@RestForm String description) {
if (file == null || file.uploadedFile() == null) {
return badRequest("file ist ein Pflichtfeld.");
}
if (catalogName == null || catalogName.isBlank()) {
return badRequest("catalog_name ist ein Pflichtfeld.");
}
if (documentTypeId == null) {
return badRequest("document_type_id ist ein Pflichtfeld.");
}
try {
// Bytes jetzt lesen Temp-Datei wird nach Request-Ende ggf. gelöscht
byte[] fileBytes = Files.readAllBytes(file.uploadedFile());
String mimeType = file.contentType();
String filename = file.fileName() != null ? file.fileName() : "dokument";
String desc = description != null ? description.trim() : null;
String name = catalogName.trim();
LOG.infof("Katalog-Generierung angefordert: name='%s', file='%s' (%d Bytes)",
name, filename, fileBytes.length);
long catalogId = catalogGenerationService.startGeneration(
fileBytes, mimeType, filename, name, desc, documentTypeId);
return Response.accepted(
new CatalogGenerateResponse(catalogId, name, desc, "GENERATING")).build();
} catch (Exception e) {
LOG.errorf(e, "Fehler beim Start der Katalog-Generierung für '%s'", catalogName);
return serverError(e.getMessage());
}
}
/**
* GET /catalog/status/{catalogId}
* Fragt den aktuellen Generierungsstatus aus der DB ab.
*
* Response: {"catalog_id": 42, "name": "ADSp 2017",
* "generation_status": "READY", "generation_error": null}
*/
@GET
@Path("/status/{catalogId}")
public Response status(@PathParam("catalogId") long catalogId) {
try {
OrdsCatalogStatusResponse statusResp = ordsClient.getCatalogStatus(catalogId);
if (statusResp == null || statusResp.getCatalogId() == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("{\"error\":\"Katalog nicht gefunden\",\"catalog_id\":"
+ catalogId + "}")
.build();
}
return Response.ok(statusResp).build();
} catch (Exception e) {
LOG.errorf(e, "Fehler beim Status-Abruf für Katalog %d", catalogId);
return serverError(e.getMessage());
}
}
private Response badRequest(String message) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("{\"error\":\"" + escapeJson(message) + "\"}")
.build();
}
private Response serverError(String message) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity("{\"error\":\"" + escapeJson(message) + "\"}")
.build();
}
private String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "'").replace("\n", " ");
}
}

View File

@@ -0,0 +1,517 @@
package de.frigosped.dc.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.frigosped.dc.client.OrdsClient;
import de.frigosped.dc.model.*;
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 dev.langchain4j.data.message.AiMessage;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.ws.rs.core.Response.Status;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Generiert einen Fragenkatalog aus einem Regelwerk-Dokument (z.B. ADSp, AGB).
*
* Chunked-Ansatz: Das Dokument wird in Abschnitte (~15k Zeichen) aufgeteilt.
* Pro Abschnitt wird ein KI-Aufruf gemacht (kürzerer Kontext → kein Timeout).
* Die Ergebnisse werden anschließend nach Kategoriename zusammengeführt.
*
* Asynchroner Ablauf:
* 1. startGeneration() → legt Katalog-Placeholder (GENERATING) in DB an, startet Background-Task
* 2. runGeneration() → OCR/Textextraktion → Chunking → KI pro Chunk → Merge → Persistierung
* 3. Status in DB → READY (Erfolg) oder ERROR (Fehler)
*/
@ApplicationScoped
public class CatalogGenerationService {
private static final Logger LOG = Logger.getLogger(CatalogGenerationService.class);
private static final int MAX_TEXT_CHARS = 120_000;
private static final int CHUNK_SIZE = 15_000;
private static final Pattern JSON_PATTERN =
Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```|([{][\\s\\S]*[}])",
Pattern.DOTALL);
@Inject
DocumentProcessingService documentProcessingService;
@Inject
@RestClient
OrdsClient ordsClient;
@Inject
@Named("catalog")
ChatLanguageModel catalogModel;
@Inject
ObjectMapper objectMapper;
// =========================================================================
// Öffentliche API sofortige Rückgabe (202-Muster)
// =========================================================================
public long startGeneration(byte[] fileBytes,
String mimeType,
String filename,
String catalogName,
String description,
long documentTypeId) {
long catalogId = createCatalogPlaceholder(catalogName, description, documentTypeId);
LOG.infof("Katalog-Placeholder angelegt (ID %d), starte Hintergrund-Generierung für '%s'",
catalogId, catalogName);
CompletableFuture.runAsync(
() -> runGeneration(catalogId, fileBytes, mimeType, filename, catalogName));
return catalogId;
}
// =========================================================================
// Hintergrund-Verarbeitung
// =========================================================================
private void runGeneration(long catalogId, byte[] fileBytes,
String mimeType, String filename, String catalogName) {
try {
LOG.infof("[Katalog %d] Starte Textextraktion aus '%s'...", catalogId, filename);
String rawText = documentProcessingService.extractText(fileBytes, mimeType, filename);
LOG.infof("[Katalog %d] Textextraktion abgeschlossen: %d Zeichen", catalogId, rawText.length());
String documentText = truncateIfNeeded(catalogId, rawText);
List<String> chunks = splitIntoChunks(documentText, CHUNK_SIZE);
LOG.infof("[Katalog %d] Dokument in %d Chunk(s) à max. %d Zeichen aufgeteilt",
catalogId, chunks.size(), CHUNK_SIZE);
// Pro Chunk: Kategorien+Fragen extrahieren, nach Kategoriename mergen
Map<String, GeneratedCatalogStructure.GeneratedCategory> categoryMap = new LinkedHashMap<>();
for (int i = 0; i < chunks.size(); i++) {
int chunkNr = i + 1;
int totalChunks = chunks.size();
LOG.infof("[Katalog %d] Chunk %d/%d an KI senden (%d Zeichen)...",
catalogId, chunkNr, totalChunks, chunks.get(i).length());
GeneratedCatalogStructure chunkResult =
generateStructureFromChunk(catalogName, chunks.get(i), chunkNr, totalChunks);
if (chunkResult == null || chunkResult.getCategories() == null) {
LOG.warnf("[Katalog %d] Chunk %d lieferte kein Ergebnis übersprungen",
catalogId, chunkNr);
continue;
}
mergeInto(categoryMap, chunkResult.getCategories());
LOG.infof("[Katalog %d] Chunk %d/%d fertig %d Kategorien bisher gesamt",
catalogId, chunkNr, totalChunks, categoryMap.size());
}
if (categoryMap.isEmpty()) {
throw new IllegalStateException("KI hat für keinen Chunk Kategorien generiert.");
}
GeneratedCatalogStructure merged = buildMergedStructure(categoryMap);
int totalQuestions = merged.getCategories().stream()
.mapToInt(c -> c.getQuestions() == null ? 0 : c.getQuestions().size())
.sum();
LOG.infof("[Katalog %d] Merge abgeschlossen: %d Kategorien, %d Fragen",
catalogId, merged.getCategories().size(), totalQuestions);
persistCategories(catalogId, merged);
updateStatus(catalogId, "READY", null);
} catch (Exception e) {
LOG.errorf(e, "[Katalog %d] Generierung fehlgeschlagen", catalogId);
updateStatus(catalogId, "ERROR",
abbreviate(e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), 3900));
}
}
// =========================================================================
// Chunk-Splitting
// =========================================================================
private List<String> splitIntoChunks(String text, int maxChunkSize) {
if (text.length() <= maxChunkSize) return List.of(text);
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < text.length()) {
int end = Math.min(start + maxChunkSize, text.length());
// Nicht mitten in einem Paragraph trennen rückwärts nach Doppel-Newline suchen
if (end < text.length()) {
int boundary = text.lastIndexOf("\n\n", end);
if (boundary > start + maxChunkSize / 2) {
end = boundary + 2;
} else {
// Fallback: Satzende suchen
int sentEnd = Math.max(
text.lastIndexOf(". ", end),
text.lastIndexOf(".\n", end));
if (sentEnd > start + maxChunkSize / 2) {
end = sentEnd + 2;
}
}
}
chunks.add(text.substring(start, end).strip());
start = end;
}
return chunks;
}
// =========================================================================
// Merge: Kategorien nach normalisiertem Namen zusammenführen
// =========================================================================
private void mergeInto(Map<String, GeneratedCatalogStructure.GeneratedCategory> categoryMap,
List<GeneratedCatalogStructure.GeneratedCategory> newCategories) {
for (var cat : newCategories) {
if (cat.getName() == null || cat.getName().isBlank()) continue;
String key = normalizeKey(cat.getName());
if (categoryMap.containsKey(key)) {
var existing = categoryMap.get(key);
List<GeneratedCatalogStructure.GeneratedQuestion> merged =
new ArrayList<>(existing.getQuestions() != null ? existing.getQuestions() : List.of());
if (cat.getQuestions() != null) merged.addAll(cat.getQuestions());
existing.setQuestions(merged);
} else {
// Mutable List sicherstellen
if (cat.getQuestions() != null) {
cat.setQuestions(new ArrayList<>(cat.getQuestions()));
}
categoryMap.put(key, cat);
}
}
}
private GeneratedCatalogStructure buildMergedStructure(
Map<String, GeneratedCatalogStructure.GeneratedCategory> categoryMap) {
List<GeneratedCatalogStructure.GeneratedCategory> cats = new ArrayList<>(categoryMap.values());
// Reihenfolge und Nummerierung normalisieren
for (int i = 0; i < cats.size(); i++) {
cats.get(i).setOrderNr(i + 1);
List<GeneratedCatalogStructure.GeneratedQuestion> qs = cats.get(i).getQuestions();
if (qs != null) {
for (int j = 0; j < qs.size(); j++) {
fillQuestionDefaults(qs.get(j), j + 1);
}
}
}
var structure = new GeneratedCatalogStructure();
structure.setCategories(cats);
return structure;
}
private String normalizeKey(String name) {
return name.toLowerCase(Locale.GERMAN)
.replaceAll("[^a-z0-9äöüß ]", "")
.trim()
.replaceAll("\\s+", " ");
}
// =========================================================================
// Status-Updates via ORDS
// =========================================================================
private void updateStatus(long catalogId, String status, String error) {
try {
ordsClient.updateCatalogStatus(catalogId,
new OrdsCatalogStatusUpdateRequest(status, error));
LOG.infof("[Katalog %d] Status → %s", catalogId, status);
} catch (Exception e) {
LOG.errorf(e, "[Katalog %d] Status-Update auf '%s' fehlgeschlagen", catalogId, status);
}
}
// =========================================================================
// ORDS: Katalog-Placeholder anlegen
// =========================================================================
private long createCatalogPlaceholder(String name, String description, long documentTypeId) {
var request = new OrdsCatalogCreateRequest(name, description, documentTypeId);
var response = ordsClient.createCatalog(request);
String body = response.readEntity(String.class);
if (response.getStatus() != Status.CREATED.getStatusCode()) {
throw new RuntimeException(
"ORDS Katalog-Erstellung fehlgeschlagen (HTTP "
+ response.getStatus() + "): " + body);
}
return extractId(body, "Katalog");
}
// =========================================================================
// ORDS: Kategorien und Fragen persistieren
// =========================================================================
private void persistCategories(long catalogId, GeneratedCatalogStructure structure) {
for (var category : structure.getCategories()) {
long categoryId = createCategory(catalogId, category);
if (category.getQuestions() == null) continue;
for (var question : category.getQuestions()) {
createQuestion(categoryId, question);
}
}
}
private long createCategory(long catalogId,
GeneratedCatalogStructure.GeneratedCategory cat) {
var request = new OrdsCategoryCreateRequest(cat.getName(), cat.getDescription(), cat.getOrderNr());
var response = ordsClient.createCategory(catalogId, request);
String body = response.readEntity(String.class);
if (response.getStatus() != Status.CREATED.getStatusCode()) {
throw new RuntimeException(
"ORDS Kategorie-Erstellung fehlgeschlagen (HTTP "
+ response.getStatus() + "): " + body);
}
return extractId(body, "Kategorie '" + cat.getName() + "'");
}
private void createQuestion(long categoryId,
GeneratedCatalogStructure.GeneratedQuestion q) {
var request = new OrdsQuestionCreateRequest();
request.setQuestionText(q.getQuestionText());
request.setEvaluationType(q.getEvaluationType());
request.setThreshold(q.getThreshold());
request.setResultHandling(q.getResultHandling());
request.setExample0Percent(q.getExample0Percent());
request.setExample100Percent(q.getExample100Percent());
request.setOrderNr(q.getOrderNr());
var response = ordsClient.createQuestion(categoryId, request);
if (response.getStatus() != Status.CREATED.getStatusCode()) {
String body = response.readEntity(String.class);
LOG.warnf("Frage konnte nicht angelegt werden (HTTP %d): %s",
response.getStatus(), body);
}
}
// =========================================================================
// KI-Aufruf pro Chunk
// =========================================================================
private GeneratedCatalogStructure generateStructureFromChunk(String catalogName,
String chunkText,
int chunkNr,
int totalChunks) {
List<ChatMessage> messages = List.of(
SystemMessage.from(buildChunkSystemPrompt(chunkNr, totalChunks)),
UserMessage.from(
"## Regelwerk: " + catalogName
+ " (Abschnitt " + chunkNr + " von " + totalChunks + ")\n\n"
+ chunkText
+ "\n\n---\n\nErstelle jetzt den Fragenkatalog-Ausschnitt als JSON:")
);
Response<AiMessage> response = catalogModel.generate(messages);
String rawJson = response.content().text();
return parseCatalogStructure(rawJson, catalogName + " Chunk " + chunkNr);
}
private String buildChunkSystemPrompt(int chunkNr, int totalChunks) {
return """
Du bist ein Experte für Transportrecht, AGB-Analyse und Dokumentenprüfung.
AUFGABE: Erstelle aus dem folgenden ABSCHNITT (%d von %d) eines Regelwerks
einen Fragenkatalog-Ausschnitt zur Dokumentenprüfung.
Der Katalog wird verwendet, um Transportdokumente auf Einhaltung des Regelwerks zu prüfen.
════════════════════════════════════════
STRUKTUR (PFLICHT)
════════════════════════════════════════
• Erstelle 25 THEMATISCHE Kategorien NUR für diesen Abschnitt.
Beispiele: "Haftung", "Informationspflichten", "Fristen und Termine".
NICHT eine Kategorie pro Paragraph.
• Pro Kategorie: 315 EINZELNE, KONKRETE Fragen.
Jede Frage prüft GENAU EINE Anforderung.
KEINE Mega-Fragen wie "Sind alle Anforderungen aus §X erfüllt?".
• Decke ALLE Klauseln/Paragraphen dieses Abschnitts ab.
Sehr ähnliche Klauseln dürfen zu einer Frage zusammengefasst werden.
════════════════════════════════════════
BEISPIEL einer korrekten Frage (dieses Muster IMMER einhalten):
════════════════════════════════════════
{
"question_text": "Ist der Haftungshöchstbetrag für Güterschäden auf 8,33 SZR/kg Rohgewicht begrenzt?",
"evaluation_type": "BINARY",
"threshold": 80,
"result_handling": "ABWEICHUNG",
"example_0_percent": "Dokument enthält keine Haftungsbegrenzung; der Spediteur haftet unbeschränkt nach allgemeinem Deliktsrecht ohne Mengenbezug.",
"example_100_percent": "Dokument regelt ausdrücklich: Haftung max. 8,33 SZR pro kg Rohgewicht gem. §431 HGB; bei Totalverlust Sendung max. 1,25 Mio. Euro."
}
════════════════════════════════════════
REGELN FÜR DIE FELDER
════════════════════════════════════════
evaluation_type:
"SCORE" → graduell erfüllbar (Vollständigkeit, Klarheit, Umfang)
"BINARY" → Ja/Nein (Klausel vorhanden oder nicht, Wert genannt oder nicht)
threshold (0100):
80 → haftungsrelevant oder rechtlich verbindlich
70 → wichtig, aber nicht haftungskritisch
60 → empfohlen
result_handling:
"ABWEICHUNG" → Fehlen hat rechtliche oder vertragliche Konsequenzen
"HINWEIS" → empfohlen, kein Pflichtverstoß
════════════════════════════════════════
PFLICHTREGELN FÜR BEISPIELE
════════════════════════════════════════
example_0_percent und example_100_percent sind IMMER auszufüllen.
Leere Felder oder Texte wie "nicht erfüllt" sind VERBOTEN.
example_0_percent: Was fehlt konkret?
"Dokument enthält keine Regelung zu [konkretem Merkmal]."
"Es fehlt jeder Hinweis auf [konkreten Wert/Klausel/Verweis]."
example_100_percent: Was ist konkret vorhanden?
"Dokument enthält: [Beispielformulierung, Zahlenwert, Paragraph]."
"Klar geregelt: [Merkmal] mit [konkretem Wert oder Beispieltext]."
════════════════════════════════════════
AUSGABE
════════════════════════════════════════
Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Objekt.
Kein Text, keine Erklärung davor oder danach.
{
"categories": [
{
"name": "Kategoriename (max. 80 Zeichen)",
"description": "Welche Paragraphen/Themen diese Kategorie abdeckt",
"order_nr": 1,
"questions": [
{
"question_text": "...",
"evaluation_type": "SCORE",
"threshold": 70,
"result_handling": "ABWEICHUNG",
"example_0_percent": "...",
"example_100_percent": "...",
"order_nr": 1
}
]
}
]
}
""".formatted(chunkNr, totalChunks);
}
// =========================================================================
// JSON-Parsing
// =========================================================================
private GeneratedCatalogStructure parseCatalogStructure(String rawJson, String context) {
try {
String json = extractJson(rawJson);
GeneratedCatalogStructure structure =
objectMapper.readValue(json, GeneratedCatalogStructure.class);
if (structure.getCategories() != null) {
for (int i = 0; i < structure.getCategories().size(); i++) {
var cat = structure.getCategories().get(i);
if (cat.getOrderNr() == 0) cat.setOrderNr(i + 1);
if (cat.getQuestions() != null) {
for (int j = 0; j < cat.getQuestions().size(); j++) {
fillQuestionDefaults(cat.getQuestions().get(j), j + 1);
}
}
}
}
return structure;
} catch (Exception e) {
LOG.errorf("JSON-Parsing fehlgeschlagen für '%s': %s", context, e.getMessage());
throw new RuntimeException("KI-Antwort konnte nicht geparst werden: " + e.getMessage(), e);
}
}
private void fillQuestionDefaults(GeneratedCatalogStructure.GeneratedQuestion q, int orderNr) {
if (q.getOrderNr() == 0) q.setOrderNr(orderNr);
if (q.getEvaluationType() == null) q.setEvaluationType("SCORE");
if (q.getThreshold() == null) q.setThreshold(70);
if (q.getResultHandling() == null) q.setResultHandling("ABWEICHUNG");
if (isBlank(q.getExample0Percent()))
q.setExample0Percent(
"Dokument enthält keine Regelung zur Anforderung: "
+ abbreviate(q.getQuestionText(), 120));
if (isBlank(q.getExample100Percent()))
q.setExample100Percent(
"Anforderung vollständig und klar erfüllt: "
+ abbreviate(q.getQuestionText(), 120));
}
private String extractJson(String text) {
if (text == null || text.isBlank())
throw new IllegalArgumentException("Leere KI-Antwort");
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();
}
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) return text.substring(start, end + 1);
throw new IllegalArgumentException("Kein JSON in KI-Antwort gefunden");
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
private String truncateIfNeeded(long catalogId, String text) {
if (text.length() <= MAX_TEXT_CHARS) return text;
LOG.warnf("[Katalog %d] Text (%d Zeichen) überschreitet Limit kürze auf %d",
catalogId, text.length(), MAX_TEXT_CHARS);
return text.substring(0, MAX_TEXT_CHARS)
+ "\n\n[... Dokument wurde aus Kapazitätsgründen gekürzt ...]";
}
@SuppressWarnings("unchecked")
private long extractId(String responseBody, String context) {
try {
Map<String, Object> map = objectMapper.readValue(responseBody, Map.class);
Object id = map.get("id");
if (id instanceof Number n) return n.longValue();
throw new IllegalStateException("Kein 'id'-Feld in ORDS-Antwort");
} catch (Exception e) {
throw new RuntimeException(
"ID-Parsing fehlgeschlagen für " + context + ": " + responseBody, e);
}
}
private String abbreviate(String s, int maxLen) {
if (s == null) return "";
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "";
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
}

View File

@@ -367,10 +367,14 @@ public class MarkdownExportService {
}
/**
* Bewegt y um n Punkte nach unten (fügt vertikalen Leerraum ein).
* Ruft kein ensureSpace() auf — bei Seitenende greift das folgende renderLine/flushText.
* Fügt n Punkte vertikalen Abstand ein.
* Passt nicht mehr auf die Seite → newPage() (Abstand wird nicht auf die neue Seite
* übertragen, damit Überschriften nicht mit riesigem Leerraum oben erscheinen).
*/
void skip(float n) { y -= n; }
void skip(float n) throws Exception {
if (y - n < MARGIN) newPage();
else y -= n;
}
// ── Emoji → Java2D-Shape ──────────────────────────────────────────────
/**
@@ -733,13 +737,13 @@ public class MarkdownExportService {
}
if (line.startsWith("#### ")) {
skip(4); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(2);
skip(10); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(4);
} else if (line.startsWith("### ")) {
skip(6); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(3);
skip(20); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(6);
} else if (line.startsWith("## ")) {
skip(8); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(4);
skip(28); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(8);
} else if (line.startsWith("# ")) {
skip(10); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(6);
skip(20); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(1);
} else if (line.equals("---")) {
skip(4);
ensureSpace(2);