diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index dd2464e..abe2250 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -2,7 +2,36 @@
"permissions": {
"allow": [
"Bash(quarkus dev *)",
- "Bash(bash *)"
+ "Bash(bash *)",
+ "Bash(mvn compile *)",
+ "Bash(quarkus version *)",
+ "Bash(export KUBECONFIG=\"/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml\")",
+ "Bash(kubectl get *)",
+ "Bash(sql -V)",
+ "Bash(sql *)",
+ "Bash(kubectl port-forward *)",
+ "Bash(curl -sf http://localhost:8090/check/health)",
+ "Bash(curl -v \"http://localhost:8081/ords/ai_dev/api/dc/documents/2/file\" -o /tmp/test_doc.pdf)",
+ "Bash(curl -s -w \"\\\\nHTTP_STATUS: %{http_code}\\\\n\" \"http://localhost:8081/ords/ai_dev/api/dc/documents/2/file\" -o /tmp/test_doc.pdf)",
+ "Read(//tmp/**)",
+ "Bash(curl -s \"http://localhost:8081/ords/ai_dev/api/dc/documents/2/file\")",
+ "Bash(curl -s -w '\\\\nHTTP_STATUS: %{http_code}\\\\nContent-Type: %{content_type}\\\\n' http://localhost:8081/ords/ai_dev/api/dc/documents/2/file -o /tmp/test_doc2.pdf)",
+ "Bash(curl *)",
+ "Bash(python3 -m json.tool)",
+ "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('original_text chars:', len\\(d.get\\('original_text',''\\) or ''\\)\\); print\\('has_original_text:', d.get\\('has_original_text'\\)\\)\")",
+ "Bash(pkill -f \"kubectl port-forward.*8090\")",
+ "Bash(pkill -f \"kubectl port-forward.*8081\")",
+ "Bash(fuser -k 8090/tcp)",
+ "Bash(KUBECONFIG=\"/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml\" kubectl logs -n ai-env -l app=dc-backend --since=10m --tail=100)",
+ "Bash(KUBECONFIG=\"/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml\" kubectl logs *)",
+ "Bash(mvn package *)",
+ "Bash(KUBECONFIG=\"/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml\" kubectl port-forward -n ai-env svc/dc-backend 8090:8090)",
+ "Bash(pkill -f \"kubectl port-forward\")",
+ "Bash(pkill -f \"port-forward\")",
+ "Bash(KUBECONFIG=\"/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml\" kubectl get pods *)",
+ "Bash(./mvnw compile *)",
+ "Bash(NLS_LANG=GERMAN_GERMANY.AL32UTF8 sql *)",
+ "Bash(KUBECONFIG=/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml kubectl logs *)"
]
}
}
diff --git a/.quarkus/cli/plugins/quarkus-cli-catalog.json b/.quarkus/cli/plugins/quarkus-cli-catalog.json
new file mode 100644
index 0000000..54679b8
--- /dev/null
+++ b/.quarkus/cli/plugins/quarkus-cli-catalog.json
@@ -0,0 +1,5 @@
+{
+ "version" : "v1",
+ "lastUpdate" : "27/04/2026 15:33:40",
+ "plugins" : { }
+}
\ No newline at end of file
diff --git a/Doku/Ablaufplan.svg b/Doku/Ablaufplan.svg
new file mode 100644
index 0000000..b67bf84
--- /dev/null
+++ b/Doku/Ablaufplan.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Doku/Dokumenten-Check.md b/Doku/Dokumenten-Check.md
index 10734de..363e0d7 100644
--- a/Doku/Dokumenten-Check.md
+++ b/Doku/Dokumenten-Check.md
@@ -223,3 +223,172 @@ graph TB
+## Source
+
+```mermaid
+sequenceDiagram
+ autonumber
+
+ participant Client as APEX / Client
+ participant AKF as ApiKeyFilter
+ participant CR as CheckResource
+ participant COS as CheckOrchestrationService
+ participant OLF as OrdsLoggingFilter
+ participant OC as OrdsClient
+ participant DPS as DocumentProcessingService
+ participant ES as EvaluationService
+ participant OCR_MDL as OllamaChatModel [ocr]
+ participant MAIN_MDL as OllamaChatModel [main]
+ participant ORDS as ORDS / Oracle DB
+ participant OLLAMA as ollama.aquantico.de
+
+ %% ─── Eingehende Anfrage ──────────────────────────────────────────────────
+ Client->>AKF: POST /check/{projectId} (Header: X-API-KEY)
+ AKF->>AKF: filter(ContainerRequestContext)
+ Note over AKF: Prüft X-API-KEY gegen dc.api.key
→ 401 wenn ungültig, sonst weiter
+ AKF->>CR: weiterleiten (Key gültig)
+
+ CR->>CR: startCheck(projectId)
+ CR-->>Client: 202 Accepted { message, project_id, hint }
+
+ Note over CR,COS: CompletableFuture.runAsync() – Fire & Forget
+ CR-)COS: process(projectId)
+
+ %% ─── Alle ORDS-Aufrufe laufen durch OrdsLoggingFilter ───────────────────
+ Note over OLF,ORDS: OrdsLoggingFilter.filter() loggt jeden Request/Response
des REST-Clients (ClientRequestFilter + ClientResponseFilter)
+
+ %% ─── Initialisierung ────────────────────────────────────────────────────
+ rect rgb(235, 245, 255)
+ Note over COS,ORDS: Initialisierung
+
+ COS->>OC: getProject(projectId)
+ OC->>OLF: filter(ClientRequestContext)
+ OLF->>ORDS: GET /api/dc/projects/{id}
+ ORDS-->>OLF: OrdsProject (JSON)
+ OLF->>OLF: filter(ClientRequestContext, ClientResponseContext)
+ OLF-->>COS: OrdsProject
+
+ Note over COS: Status-Prüfung: nur PENDING wird verarbeitet
+
+ COS->>OC: startProject(projectId)
+ OC->>ORDS: POST /api/dc/projects/{id}/start
+ ORDS-->>COS: 200 OK | 409 Conflict
+
+ COS->>OC: getDocuments(projectId)
+ OC->>ORDS: GET /api/dc/projects/{id}/documents/
+ ORDS-->>COS: OrdsDocumentList
+
+ COS->>OC: getQuestions(catalogId)
+ OC->>ORDS: GET /api/dc/catalogs/{id}/questions/
+ ORDS-->>COS: OrdsQuestionList
+
+ COS->>OC: deleteResults(projectId)
+ OC->>ORDS: DELETE /api/dc/projects/{id}/results
+ Note over ORDS: Alte Ergebnisse löschen → Re-Processing möglich
+ end
+
+ %% ─── Phase A: OCR + Übersetzung ─────────────────────────────────────────
+ rect rgb(220, 255, 225)
+ Note over COS,OLLAMA: Phase A – OCR + Übersetzung (pro Dokument)
+
+ loop für jedes OrdsDocument
+
+ COS->>OC: downloadFile(docId)
+ OC->>ORDS: GET /api/dc/documents/{id}/file
+ ORDS-->>COS: byte[] (BLOB)
+
+ COS->>DPS: extractText(fileBytes, mimeType, filename)
+ DPS->>DPS: resolveMimeType(mimeType, filename)
+
+ alt MIME = application/pdf
+ DPS->>DPS: extractFromPdf(fileBytes)
+ DPS->>DPS: Loader.loadPDF(fileBytes) → PDDocument
+ DPS->>DPS: new PDFRenderer(pdf)
+
+ loop für jede PDF-Seite
+ DPS->>DPS: renderer.renderImageWithDPI(i, 200f, RGB)
+ DPS->>DPS: ImageIO.write(image, "PNG", baos) → Base64
+ DPS->>DPS: ocrPage(base64Image, pageNum, totalPages)
+ DPS->>OCR_MDL: generate(List)
+ Note over DPS,OCR_MDL: UserMessage: ImageContent(PNG) + TextContent(OCR-Prompt)
+ OCR_MDL->>OLLAMA: POST /api/chat [Vision, X-API-KEY]
+ OLLAMA-->>OCR_MDL: Markdown-Seitentext
+ OCR_MDL-->>DPS: String (Seite i)
+ end
+
+ else MIME = application/vnd...wordprocessingml / msword
+ DPS->>DPS: extractFromDocx(fileBytes)
+ DPS->>DPS: new XWPFDocument(stream)
+ loop für jedes Body-Element
+ DPS->>DPS: paragraphToMarkdown(XWPFParagraph)
+ DPS->>DPS: tableToMarkdown(XWPFTable)
+ end
+
+ else MIME = application/vnd.oasis.opendocument.text
+ DPS->>DPS: extractFromOdt(fileBytes)
+ DPS->>DPS: OdfTextDocument.loadDocument(stream)
+ DPS->>DPS: getElementsByTagName("text:p") → Markdown
+
+ else text/plain | text/markdown
+ Note over DPS: direkt als UTF-8 String zurück
+ end
+
+ DPS-->>COS: originalText (Markdown)
+
+ COS->>ES: translate(originalText)
+ ES->>MAIN_MDL: generate(List)
+ Note over ES,MAIN_MDL: SystemMessage: Übersetzer-Prompt
UserMessage: originalText
+ MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
+ OLLAMA-->>MAIN_MDL: Übersetzung (Deutsch)
+ MAIN_MDL-->>ES: Response
+ ES-->>COS: translatedText
+
+ COS->>OC: updateTexts(docId, TextsRequest(original, translated))
+ OC->>ORDS: PUT /api/dc/documents/{id}/texts
+
+ COS->>COS: pushProgress(projectId, step, total)
+ COS->>OC: updateProgress(projectId, ProgressRequest(pct))
+ OC->>ORDS: PUT /api/dc/projects/{id}/progress
+ end
+ end
+
+ %% ─── Phase B: Fragen auswerten ───────────────────────────────────────────
+ rect rgb(255, 245, 220)
+ Note over COS,OLLAMA: Phase B – Fragenauswertung (pro Dokument × Frage)
+
+ loop für jedes OrdsDocument × jede OrdsQuestion
+
+ COS->>ES: evaluate(question, originalText)
+ ES->>ES: buildEvaluationSystem()
+ ES->>ES: buildEvaluationUser(question, documentText)
+ Note over ES: Prompt: Dokument + Frage + Schwellwert
+ Beispiele 0% / 100%
+ ES->>MAIN_MDL: generate(List)
+ MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
+ OLLAMA-->>MAIN_MDL: JSON { answer, score, result_type, the_comment }
+ MAIN_MDL-->>ES: Response
+
+ ES->>ES: parseEvaluationResult(rawText, question)
+ ES->>ES: extractJson(text)
+ Note over ES: Regex: ```json...``` oder erstes {...} im Text
+ ES->>ES: objectMapper.readValue(json, EvaluationResult)
+ ES->>ES: deriveResultType(score, threshold) [Fallback]
+ ES-->>COS: EvaluationResult { answer, score, resultType, theComment }
+
+ Note over COS: score < threshold?
result_handling=ABWEICHUNG → deviation=1
result_handling=HINWEIS → warning=1
+
+ COS->>OC: saveResult(projectId, ResultRequest)
+ OC->>ORDS: POST /api/dc/projects/{id}/results → 201 Created
+
+ COS->>COS: pushProgress(projectId, step, total)
+ COS->>OC: updateProgress(projectId, ProgressRequest(pct))
+ OC->>ORDS: PUT /api/dc/projects/{id}/progress
+ end
+ end
+
+ %% ─── Abschluss ───────────────────────────────────────────────────────────
+ COS->>OC: completeProject(projectId)
+ OC->>ORDS: POST /api/dc/projects/{id}/complete
+ Note over ORDS: status → COMPLETED, completed_at = SYSDATE
+
+```
+
diff --git a/Doku/Prozessablauf.mmd b/Doku/Prozessablauf.mmd
new file mode 100644
index 0000000..e1ae54b
--- /dev/null
+++ b/Doku/Prozessablauf.mmd
@@ -0,0 +1,163 @@
+sequenceDiagram
+ autonumber
+
+ participant Client as APEX / Client
+ participant AKF as ApiKeyFilter
+ participant CR as CheckResource
+ participant COS as CheckOrchestrationService
+ participant OLF as OrdsLoggingFilter
+ participant OC as OrdsClient
+ participant DPS as DocumentProcessingService
+ participant ES as EvaluationService
+ participant OCR_MDL as OllamaChatModel [ocr]
+ participant MAIN_MDL as OllamaChatModel [main]
+ participant ORDS as ORDS / Oracle DB
+ participant OLLAMA as ollama.aquantico.de
+
+ %% ─── Eingehende Anfrage ──────────────────────────────────────────────────
+ Client->>AKF: POST /check/{projectId} (Header: X-API-KEY)
+ AKF->>AKF: filter(ContainerRequestContext)
+ Note over AKF: Prüft X-API-KEY gegen dc.api.key
→ 401 wenn ungültig, sonst weiter
+ AKF->>CR: weiterleiten (Key gültig)
+
+ CR->>CR: startCheck(projectId)
+ CR-->>Client: 202 Accepted { message, project_id, hint }
+
+ Note over CR,COS: CompletableFuture.runAsync() – Fire & Forget
+ CR-)COS: process(projectId)
+
+ %% ─── Alle ORDS-Aufrufe laufen durch OrdsLoggingFilter ───────────────────
+ Note over OLF,ORDS: OrdsLoggingFilter.filter() loggt jeden Request/Response
des REST-Clients (ClientRequestFilter + ClientResponseFilter)
+
+ %% ─── Initialisierung ────────────────────────────────────────────────────
+ rect rgb(235, 245, 255)
+ Note over COS,ORDS: Initialisierung
+
+ COS->>OC: getProject(projectId)
+ OC->>OLF: filter(ClientRequestContext)
+ OLF->>ORDS: GET /api/dc/projects/{id}
+ ORDS-->>OLF: OrdsProject (JSON)
+ OLF->>OLF: filter(ClientRequestContext, ClientResponseContext)
+ OLF-->>COS: OrdsProject
+
+ Note over COS: Status-Prüfung: nur PENDING wird verarbeitet
+
+ COS->>OC: startProject(projectId)
+ OC->>ORDS: POST /api/dc/projects/{id}/start
+ ORDS-->>COS: 200 OK | 409 Conflict
+
+ COS->>OC: getDocuments(projectId)
+ OC->>ORDS: GET /api/dc/projects/{id}/documents/
+ ORDS-->>COS: OrdsDocumentList
+
+ COS->>OC: getQuestions(catalogId)
+ OC->>ORDS: GET /api/dc/catalogs/{id}/questions/
+ ORDS-->>COS: OrdsQuestionList
+
+ COS->>OC: deleteResults(projectId)
+ OC->>ORDS: DELETE /api/dc/projects/{id}/results
+ Note over ORDS: Alte Ergebnisse löschen → Re-Processing möglich
+ end
+
+ %% ─── Phase A: OCR + Übersetzung ─────────────────────────────────────────
+ rect rgb(220, 255, 225)
+ Note over COS,OLLAMA: Phase A – OCR + Übersetzung (pro Dokument)
+
+ loop für jedes OrdsDocument
+
+ COS->>OC: downloadFile(docId)
+ OC->>ORDS: GET /api/dc/documents/{id}/file
+ ORDS-->>COS: byte[] (BLOB)
+
+ COS->>DPS: extractText(fileBytes, mimeType, filename)
+ DPS->>DPS: resolveMimeType(mimeType, filename)
+
+ alt MIME = application/pdf
+ DPS->>DPS: extractFromPdf(fileBytes)
+ DPS->>DPS: Loader.loadPDF(fileBytes) → PDDocument
+ DPS->>DPS: new PDFRenderer(pdf)
+
+ loop für jede PDF-Seite
+ DPS->>DPS: renderer.renderImageWithDPI(i, 200f, RGB)
+ DPS->>DPS: ImageIO.write(image, "PNG", baos) → Base64
+ DPS->>DPS: ocrPage(base64Image, pageNum, totalPages)
+ DPS->>OCR_MDL: generate(List)
+ Note over DPS,OCR_MDL: UserMessage: ImageContent(PNG) + TextContent(OCR-Prompt)
+ OCR_MDL->>OLLAMA: POST /api/chat [Vision, X-API-KEY]
+ OLLAMA-->>OCR_MDL: Markdown-Seitentext
+ OCR_MDL-->>DPS: String (Seite i)
+ end
+
+ else MIME = application/vnd...wordprocessingml / msword
+ DPS->>DPS: extractFromDocx(fileBytes)
+ DPS->>DPS: new XWPFDocument(stream)
+ loop für jedes Body-Element
+ DPS->>DPS: paragraphToMarkdown(XWPFParagraph)
+ DPS->>DPS: tableToMarkdown(XWPFTable)
+ end
+
+ else MIME = application/vnd.oasis.opendocument.text
+ DPS->>DPS: extractFromOdt(fileBytes)
+ DPS->>DPS: OdfTextDocument.loadDocument(stream)
+ DPS->>DPS: getElementsByTagName("text:p") → Markdown
+
+ else text/plain | text/markdown
+ Note over DPS: direkt als UTF-8 String zurück
+ end
+
+ DPS-->>COS: originalText (Markdown)
+
+ COS->>ES: translate(originalText)
+ ES->>MAIN_MDL: generate(List)
+ Note over ES,MAIN_MDL: SystemMessage: Übersetzer-Prompt
UserMessage: originalText
+ MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
+ OLLAMA-->>MAIN_MDL: Übersetzung (Deutsch)
+ MAIN_MDL-->>ES: Response
+ ES-->>COS: translatedText
+
+ COS->>OC: updateTexts(docId, TextsRequest(original, translated))
+ OC->>ORDS: PUT /api/dc/documents/{id}/texts
+
+ COS->>COS: pushProgress(projectId, step, total)
+ COS->>OC: updateProgress(projectId, ProgressRequest(pct))
+ OC->>ORDS: PUT /api/dc/projects/{id}/progress
+ end
+ end
+
+ %% ─── Phase B: Fragen auswerten ───────────────────────────────────────────
+ rect rgb(255, 245, 220)
+ Note over COS,OLLAMA: Phase B – Fragenauswertung (pro Dokument × Frage)
+
+ loop für jedes OrdsDocument × jede OrdsQuestion
+
+ COS->>ES: evaluate(question, originalText)
+ ES->>ES: buildEvaluationSystem()
+ ES->>ES: buildEvaluationUser(question, documentText)
+ Note over ES: Prompt: Dokument + Frage + Schwellwert
+ Beispiele 0% / 100%
+ ES->>MAIN_MDL: generate(List)
+ MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
+ OLLAMA-->>MAIN_MDL: JSON { answer, score, result_type, the_comment }
+ MAIN_MDL-->>ES: Response
+
+ ES->>ES: parseEvaluationResult(rawText, question)
+ ES->>ES: extractJson(text)
+ Note over ES: Regex: ```json...``` oder erstes {...} im Text
+ ES->>ES: objectMapper.readValue(json, EvaluationResult)
+ ES->>ES: deriveResultType(score, threshold) [Fallback]
+ ES-->>COS: EvaluationResult { answer, score, resultType, theComment }
+
+ Note over COS: score < threshold?
result_handling=ABWEICHUNG → deviation=1
result_handling=HINWEIS → warning=1
+
+ COS->>OC: saveResult(projectId, ResultRequest)
+ OC->>ORDS: POST /api/dc/projects/{id}/results → 201 Created
+
+ COS->>COS: pushProgress(projectId, step, total)
+ COS->>OC: updateProgress(projectId, ProgressRequest(pct))
+ OC->>ORDS: PUT /api/dc/projects/{id}/progress
+ end
+ end
+
+ %% ─── Abschluss ───────────────────────────────────────────────────────────
+ COS->>OC: completeProject(projectId)
+ OC->>ORDS: POST /api/dc/projects/{id}/complete
+ Note over ORDS: status → COMPLETED, completed_at = SYSDATE
diff --git a/Scripts/DC Generate Report.sql b/Scripts/DC Generate Report.sql
new file mode 100644
index 0000000..1eca510
--- /dev/null
+++ b/Scripts/DC Generate Report.sql
@@ -0,0 +1,193 @@
+-- =============================================================================
+-- dc_generate_report – Erstellt einen Markdown-Prüfbericht für ein Projekt
+--
+-- Aufruf:
+-- BEGIN dc_generate_report(p_project_id => 1); COMMIT; END;
+-- /
+--
+-- Ergebnis: dc_projects.report_markdown wird mit dem Markdown-Text befüllt.
+-- Wird automatisch beim POST /api/dc/projects/:id/complete aufgerufen.
+-- Kann manuell via POST /api/dc/projects/:id/report neu generiert werden.
+--
+-- Aufbau des Berichts:
+-- 1. Kopf (Projektname, Abschlussdatum)
+-- 2. Zusammenfassung (OK / Hinweise / Abweichungen)
+-- 3. Ergebnisse: je Dokument → Kategorie (order_nr) → Frage (order_nr)
+-- Icons: ✅ OK | ⚠️ Hinweis | ❌ Abweichung | ❓ Unklar
+-- =============================================================================
+
+CREATE OR REPLACE PROCEDURE dc_generate_report(p_project_id IN NUMBER) IS
+
+ v_md CLOB;
+ v_name dc_projects.name%TYPE;
+ v_status dc_projects.status%TYPE;
+ v_done dc_projects.completed_at%TYPE;
+
+ v_ok_cnt NUMBER := 0;
+ v_warn_cnt NUMBER := 0;
+ v_dev_cnt NUMBER := 0;
+ v_unkl_cnt NUMBER := 0;
+ v_total NUMBER := 0;
+
+ -- Emoji-Konstanten via UNISTR (vermeidet Quellcode-Encoding-Probleme)
+ C_OK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2705'); -- ✅
+ C_NOK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\274C'); -- ❌
+ C_WARN CONSTANT VARCHAR2(10 CHAR) := UNISTR('\26A0\FE0F'); -- ⚠️
+ C_UNKL CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2753'); -- ❓
+ C_DOC CONSTANT VARCHAR2(10 CHAR) := UNISTR('\D83D\DCC4'); -- 📄
+
+ CURSOR c_docs IS
+ SELECT d.id, d.filename
+ FROM dc_project_documents d
+ WHERE d.project_id = p_project_id
+ ORDER BY d.uploaded_at, d.id;
+
+ CURSOR c_cats(p_doc_id NUMBER) IS
+ SELECT DISTINCT qc.id, qc.name, qc.order_nr
+ FROM dc_question_categories qc
+ JOIN dc_questions q ON q.category_id = qc.id
+ JOIN dc_results r ON r.question_id = q.id
+ WHERE r.project_id = p_project_id
+ AND r.doc_id = p_doc_id
+ ORDER BY qc.order_nr, qc.name;
+
+ CURSOR c_qs(p_doc_id NUMBER, p_cat_id NUMBER) IS
+ SELECT q.question_text,
+ r.result_type,
+ r.score,
+ r.deviation,
+ r.warning,
+ r.answer,
+ r.the_comment
+ FROM dc_questions q
+ JOIN dc_results r ON r.question_id = q.id
+ WHERE r.project_id = p_project_id
+ AND r.doc_id = p_doc_id
+ AND q.category_id = p_cat_id
+ ORDER BY q.order_nr, q.id;
+
+ v_icon VARCHAR2(20 CHAR);
+
+ -- Fügt eine Zeile + Newline an den CLOB an. Akzeptiert CLOB damit kein
+ -- implizites CLOB→VARCHAR2-Cast nötig ist (question_text etc. sind CLOBs).
+ PROCEDURE ln(p_text IN CLOB DEFAULT NULL) IS
+ BEGIN
+ IF p_text IS NOT NULL AND DBMS_LOB.GETLENGTH(p_text) > 0 THEN
+ DBMS_LOB.APPEND(v_md, p_text);
+ END IF;
+ DBMS_LOB.APPEND(v_md, TO_CLOB(CHR(10)));
+ END ln;
+
+BEGIN
+ DBMS_LOB.CREATETEMPORARY(v_md, TRUE);
+
+ SELECT name, status, completed_at
+ INTO v_name, v_status, v_done
+ FROM dc_projects
+ WHERE id = p_project_id;
+
+ SELECT SUM(CASE WHEN deviation = 1 THEN 1 ELSE 0 END),
+ SUM(CASE WHEN warning = 1 THEN 1 ELSE 0 END),
+ SUM(CASE WHEN result_type = 'UNKLAR'
+ AND deviation = 0
+ AND warning = 0 THEN 1 ELSE 0 END),
+ COUNT(*)
+ INTO v_dev_cnt, v_warn_cnt, v_unkl_cnt, v_total
+ FROM dc_results
+ WHERE project_id = p_project_id;
+
+ v_ok_cnt := v_total - v_dev_cnt - v_warn_cnt - v_unkl_cnt;
+
+ -- =========================================================================
+ -- Kopf
+ -- =========================================================================
+ ln('# ' || C_OK || ' Prüfbericht: ' || v_name);
+ ln();
+ IF v_done IS NOT NULL THEN
+ ln('**Abgeschlossen:** ' || TO_CHAR(v_done, 'DD.MM.YYYY HH24:MI'));
+ ELSE
+ ln('**Erstellt:** ' || TO_CHAR(SYSDATE, 'DD.MM.YYYY HH24:MI'));
+ END IF;
+ ln();
+ ln('---');
+ ln();
+
+ -- =========================================================================
+ -- Zusammenfassung (keine Tabelle – Plaintext für maximale Kompatibilität)
+ -- =========================================================================
+ ln('## Zusammenfassung');
+ ln();
+ ln(C_OK || ' OK: ' || v_ok_cnt);
+ ln(C_WARN || ' Hinweise: ' || v_warn_cnt);
+ ln(C_NOK || ' Abweichungen: ' || v_dev_cnt);
+ IF v_unkl_cnt > 0 THEN
+ ln(C_UNKL || ' Unklar: ' || v_unkl_cnt);
+ END IF;
+ ln('**Gesamt: ' || v_total || '**');
+ ln();
+ ln('---');
+ ln();
+
+ -- =========================================================================
+ -- Ergebnisse je Dokument → Kategorie (order_nr) → Frage (order_nr)
+ -- =========================================================================
+ ln('## Ergebnisse');
+ ln();
+
+ FOR doc IN c_docs LOOP
+ ln('## ' || C_DOC || ' ' || doc.filename);
+ ln();
+
+ FOR cat IN c_cats(doc.id) LOOP
+ ln();
+ ln('### ' || cat.name);
+ ln();
+
+ FOR q IN c_qs(doc.id, cat.id) LOOP
+
+ IF q.deviation = 1 THEN v_icon := C_NOK;
+ ELSIF q.warning = 1 THEN v_icon := C_WARN;
+ ELSIF q.result_type = 'UNKLAR' THEN v_icon := C_UNKL;
+ ELSE v_icon := C_OK;
+ END IF;
+
+ ln();
+ ln(v_icon || ' **' || q.question_text || '**');
+ ln();
+
+ IF q.answer IS NOT NULL AND DBMS_LOB.GETLENGTH(q.answer) > 0 THEN
+ ln(q.answer);
+ ln();
+ END IF;
+
+ IF q.score IS NOT NULL THEN
+ ln('*Score: ' || q.score || '%*');
+ END IF;
+
+ IF q.the_comment IS NOT NULL
+ AND DBMS_LOB.GETLENGTH(q.the_comment) > 0 THEN
+ ln();
+ ln('> ' || q.the_comment);
+ END IF;
+
+ ln();
+ END LOOP;
+
+ END LOOP;
+
+ ln('---');
+ ln();
+ END LOOP;
+
+ UPDATE dc_projects
+ SET report_markdown = v_md
+ WHERE id = p_project_id;
+
+ DBMS_LOB.FREETEMPORARY(v_md);
+
+EXCEPTION
+ WHEN OTHERS THEN
+ DBMS_LOB.FREETEMPORARY(v_md);
+ RAISE;
+END dc_generate_report;
+/
diff --git a/Scripts/DC_BACKEND_PKG.sql b/Scripts/DC_BACKEND_PKG.sql
index 4af1fb3..390ada2 100644
--- a/Scripts/DC_BACKEND_PKG.sql
+++ b/Scripts/DC_BACKEND_PKG.sql
@@ -96,6 +96,41 @@ CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS
p_interval_sec IN NUMBER DEFAULT 5
);
+ -- -------------------------------------------------------------------------
+ -- Konvertiert einen Markdown-CLOB zu DOCX oder PDF.
+ -- POST /export/markdown → BLOB (application/pdf oder .docx)
+ --
+ -- p_markdown Markdown-Text (z.B. dc_projects.report_markdown)
+ -- p_format 'PDF' (Standard) oder 'DOCX'
+ -- Rückgabe BLOB mit dem fertigen Dokument
+ -- -------------------------------------------------------------------------
+ FUNCTION convert_markdown(
+ p_markdown IN CLOB,
+ p_format IN VARCHAR2 DEFAULT 'PDF'
+ ) RETURN BLOB;
+
+ -- -------------------------------------------------------------------------
+ -- Convenience: liest report_markdown des Projekts und konvertiert zu PDF/DOCX.
+ --
+ -- Beispiel:
+ -- v_blob := DC_BACKEND_PKG.project_report(1, 'PDF');
+ -- -------------------------------------------------------------------------
+ FUNCTION project_report(
+ p_project_id IN NUMBER,
+ p_format IN VARCHAR2 DEFAULT 'PDF'
+ ) RETURN BLOB;
+
+ -- -------------------------------------------------------------------------
+ -- Generiert den Markdown-Prüfbericht für ein Projekt neu und speichert ihn
+ -- in dc_projects.report_markdown. Ruft intern dc_generate_report auf.
+ --
+ -- Beispiel:
+ -- DC_BACKEND_PKG.regenerate_report(1);
+ -- -------------------------------------------------------------------------
+ PROCEDURE regenerate_report(
+ p_project_id IN NUMBER
+ );
+
END DC_BACKEND_PKG;
/
@@ -262,6 +297,93 @@ CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS
END wait_for_completion;
+ -- -------------------------------------------------------------------------
+ FUNCTION convert_markdown(
+ p_markdown IN CLOB,
+ p_format IN VARCHAR2 DEFAULT 'PDF'
+ ) RETURN BLOB IS
+ l_url VARCHAR2(500);
+ l_body CLOB;
+ l_result BLOB;
+ l_status NUMBER;
+ BEGIN
+ l_url := c_base_url || '/export/markdown';
+
+ -- JSON-Body mit CLOB-Inhalt: APEX_JSON unterstützt CLOBs beliebiger Größe
+ APEX_JSON.INITIALIZE_CLOB_OUTPUT;
+ APEX_JSON.OPEN_OBJECT;
+ APEX_JSON.WRITE('content', p_markdown);
+ APEX_JSON.WRITE('format', UPPER(p_format));
+ APEX_JSON.CLOSE_OBJECT;
+ l_body := APEX_JSON.GET_CLOB_OUTPUT;
+ APEX_JSON.FREE_OUTPUT;
+
+ set_headers();
+
+ l_result := APEX_WEB_SERVICE.MAKE_REST_REQUEST_B(
+ p_url => l_url,
+ p_http_method => 'POST',
+ p_body => l_body
+ );
+
+ l_status := APEX_WEB_SERVICE.G_STATUS_CODE;
+
+ IF l_status NOT IN (200, 201) THEN
+ RAISE_APPLICATION_ERROR(-20200,
+ 'Export-Service Fehler: HTTP ' || l_status ||
+ ' (format=' || p_format || ')');
+ END IF;
+
+ RETURN l_result;
+
+ EXCEPTION
+ WHEN OTHERS THEN
+ IF SQLCODE = -20200 THEN RAISE; END IF;
+ RAISE_APPLICATION_ERROR(-20201,
+ 'Verbindungsfehler beim Export: ' || SQLERRM);
+ END convert_markdown;
+
+
+ -- -------------------------------------------------------------------------
+ FUNCTION project_report(
+ p_project_id IN NUMBER,
+ p_format IN VARCHAR2 DEFAULT 'PDF'
+ ) RETURN BLOB IS
+ l_markdown CLOB;
+ BEGIN
+ SELECT report_markdown
+ INTO l_markdown
+ FROM dc_projects
+ WHERE id = p_project_id;
+
+ IF l_markdown IS NULL OR DBMS_LOB.GETLENGTH(l_markdown) = 0 THEN
+ RAISE_APPLICATION_ERROR(-20202,
+ 'Projekt ' || p_project_id || ' hat noch keinen Markdown-Bericht. '
+ || 'Bitte zuerst POST /api/dc/projects/' || p_project_id || '/report aufrufen.');
+ END IF;
+
+ RETURN convert_markdown(l_markdown, p_format);
+
+ EXCEPTION
+ WHEN NO_DATA_FOUND THEN
+ RAISE_APPLICATION_ERROR(-20110,
+ 'Projekt ' || p_project_id || ' nicht gefunden.');
+ END project_report;
+
+
+ -- -------------------------------------------------------------------------
+ PROCEDURE regenerate_report(
+ p_project_id IN NUMBER
+ ) IS
+ BEGIN
+ dc_generate_report(p_project_id => p_project_id);
+ EXCEPTION
+ WHEN NO_DATA_FOUND THEN
+ RAISE_APPLICATION_ERROR(-20110,
+ 'Projekt ' || p_project_id || ' nicht gefunden.');
+ END regenerate_report;
+
+
END DC_BACKEND_PKG;
/
@@ -294,4 +416,24 @@ BEGIN
DBMS_OUTPUT.PUT_LINE('Response: ' || l_response);
END;
/
+
+-- Bericht als PDF exportieren
+DECLARE
+ l_blob BLOB;
+BEGIN
+ DC_BACKEND_PKG.g_api_key := 'mein-api-key';
+ l_blob := DC_BACKEND_PKG.project_report(p_project_id => 1, p_format => 'PDF');
+ DBMS_OUTPUT.PUT_LINE('PDF-Größe: ' || DBMS_LOB.GETLENGTH(l_blob) || ' Bytes');
+END;
+/
+
+-- Bericht als DOCX exportieren
+DECLARE
+ l_blob BLOB;
+BEGIN
+ DC_BACKEND_PKG.g_api_key := 'mein-api-key';
+ l_blob := DC_BACKEND_PKG.project_report(p_project_id => 1, p_format => 'DOCX');
+ DBMS_OUTPUT.PUT_LINE('DOCX-Größe: ' || DBMS_LOB.GETLENGTH(l_blob) || ' Bytes');
+END;
+/
*/
diff --git a/Scripts/ORDS REST Services.sql b/Scripts/ORDS REST Services.sql
index 11d29f0..f9b9cd4 100644
--- a/Scripts/ORDS REST Services.sql
+++ b/Scripts/ORDS REST Services.sql
@@ -16,14 +16,15 @@
--
-- Endpunkt-Übersicht:
-- 1. GET /api/dc/catalogs/ Alle Fragenkataloge
--- 2. GET /api/dc/catalogs/:id/questions/ Fragen eines Katalogs (flach)
--- 3. GET /api/dc/projects/:id Projektdetail
--- 4. POST /api/dc/projects/:id/start → IN_PROGRESS
--- 5. PUT /api/dc/projects/:id/progress Fortschritt aktualisieren
--- 6. POST /api/dc/projects/:id/complete → COMPLETED
+-- 2. GET /api/dc/catalogs/:id/questions/ Fragen eines Katalogs (flach, nach order_nr sortiert)
+-- 3. GET /api/dc/projects/:id Projektdetail (inkl. Detailstatus + ETA)
+-- 4. POST /api/dc/projects/:id/start → IN_PROGRESS (setzt processing_started_at)
+-- 5. PUT /api/dc/projects/:id/progress Fortschritt + Phase/Doc/Question/ETA
+-- 6. POST /api/dc/projects/:id/complete → COMPLETED (setzt current_phase=COMPLETED)
-- 7. GET /api/dc/projects/:id/documents/ Dokument-Liste (kein BLOB)
--- 8. GET /api/dc/documents/:id/file BLOB-Download
--- 9. PUT /api/dc/documents/:id/texts OCR-Text + Übersetzung schreiben
+-- 8. GET /api/dc/documents/:id/file BLOB-Download (PLSQL-Workaround)
+-- 9. GET /api/dc/documents/:id/texts OCR-Text + Übersetzung lesen (für Resume)
+-- 9b. PUT /api/dc/documents/:id/texts OCR-Text + Übersetzung schreiben
-- 10. POST /api/dc/projects/:id/results Prüfergebnis einfügen
-- 11. DELETE /api/dc/projects/:id/results Alle Ergebnisse löschen
-- =============================================================================
@@ -115,17 +116,19 @@ BEGIN
cat.id AS category_id,
cat.name AS category_name,
cat.description AS category_description,
+ cat.order_nr AS category_order_nr,
q.question_text,
q.evaluation_type,
q.threshold,
q.result_handling,
q.example_0_percent,
q.example_100_percent,
+ q.order_nr,
q.row_version
FROM dc_questions q
JOIN dc_question_categories cat ON cat.id = q.category_id
WHERE cat.catalog_id = :catalog_id
- ORDER BY cat.name, q.id
+ ORDER BY cat.order_nr, q.order_nr, q.id
]'
);
@@ -148,7 +151,7 @@ BEGIN
p_method => 'GET',
p_source_type => ORDS.SOURCE_TYPE_COLLECTION_ITEM,
p_items_per_page => 1,
- p_comments => 'Projektdetail als einzelnes JSON-Objekt; 404 wenn nicht gefunden',
+ p_comments => 'Projektdetail mit Status-Details und ETA; 404 wenn nicht gefunden',
p_source => q'[
SELECT
p.id,
@@ -162,7 +165,12 @@ BEGIN
p.notification_email,
p.row_version,
p.created,
- p.updated
+ p.updated,
+ p.processing_started_at,
+ p.current_phase,
+ p.current_doc_id,
+ p.current_question_id,
+ p.estimated_completion_at
FROM dc_projects p
WHERE p.id = :project_id
]'
@@ -185,7 +193,7 @@ BEGIN
p_pattern => 'projects/:project_id/start',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
- p_comments => '200 OK; 409 wenn Status nicht PENDING; 404 wenn Projekt fehlt',
+ p_comments => '200 OK; 409 wenn IN_PROGRESS/COMPLETED; 404 wenn Projekt fehlt',
p_source => q'[
DECLARE
v_status dc_projects.status%TYPE;
@@ -196,16 +204,21 @@ BEGIN
WHERE id = :project_id
FOR UPDATE NOWAIT;
- IF v_status != 'PENDING' THEN
+ IF v_status IN ('IN_PROGRESS', 'COMPLETED') THEN
:status_code := 409;
- HTP.P('{"error":"Projekt ist nicht im Status PENDING",'
+ HTP.P('{"error":"Projekt ist bereits ' || v_status || '",'
|| '"current_status":"' || v_status || '"}');
RETURN;
END IF;
UPDATE dc_projects
- SET status = 'IN_PROGRESS',
- progress = 0
+ SET status = 'IN_PROGRESS',
+ progress = 0,
+ processing_started_at = SYSTIMESTAMP,
+ current_phase = 'OCR',
+ current_doc_id = NULL,
+ current_question_id = NULL,
+ estimated_completion_at = NULL
WHERE id = :project_id;
:status_code := 200;
@@ -236,20 +249,30 @@ BEGIN
);
-- 5. PUT /api/dc/projects/:project_id/progress
- -- Body: {"progress": 45}
+ -- Body: {"progress":45,"current_phase":"QUESTIONS","current_doc_id":2,
+ -- "current_question_id":15,"estimated_completion_at":"2026-04-27T16:30:00"}
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/progress',
p_method => 'PUT',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
- p_comments => 'Setzt progress (0-100); 400 bei ungueltigem Wert',
+ p_comments => 'Setzt progress (0-100) + Phase/Dokument/Frage/ETA; 400 bei ungueltigem Wert',
p_source => q'[
DECLARE
- v_progress NUMBER;
- v_body CLOB;
+ v_body CLOB;
+ v_progress NUMBER;
+ v_phase VARCHAR2(30);
+ v_doc_id NUMBER;
+ v_question_id NUMBER;
+ v_eta_str VARCHAR2(30);
+ v_eta TIMESTAMP;
BEGIN
- v_body := :body_text;
- v_progress := TO_NUMBER(JSON_VALUE(v_body, '$.progress'));
+ v_body := :body_text;
+ v_progress := TO_NUMBER(JSON_VALUE(v_body, '$.progress'));
+ v_phase := JSON_VALUE(v_body, '$.current_phase');
+ v_doc_id := TO_NUMBER(JSON_VALUE(v_body, '$.current_doc_id'));
+ v_question_id := TO_NUMBER(JSON_VALUE(v_body, '$.current_question_id'));
+ v_eta_str := JSON_VALUE(v_body, '$.estimated_completion_at');
IF v_progress IS NULL OR v_progress < 0 OR v_progress > 100 THEN
:status_code := 400;
@@ -257,9 +280,21 @@ BEGIN
RETURN;
END IF;
+ IF v_eta_str IS NOT NULL THEN
+ BEGIN
+ v_eta := TO_TIMESTAMP(v_eta_str, 'YYYY-MM-DD"T"HH24:MI:SS');
+ EXCEPTION WHEN OTHERS THEN
+ v_eta := NULL;
+ END;
+ END IF;
+
UPDATE dc_projects
- SET progress = v_progress
- WHERE id = :project_id;
+ SET progress = v_progress,
+ current_phase = NVL(v_phase, current_phase),
+ current_doc_id = v_doc_id,
+ current_question_id = v_question_id,
+ estimated_completion_at = NVL(v_eta, estimated_completion_at)
+ WHERE id = :project_id;
IF SQL%ROWCOUNT = 0 THEN
:status_code := 404;
@@ -300,7 +335,7 @@ BEGIN
p_pattern => 'projects/:project_id/complete',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
- p_comments => 'Setzt status=COMPLETED, completed_at=SYSDATE, progress=100',
+ p_comments => 'Setzt status=COMPLETED und erstellt Markdown-Bericht',
p_source => q'[
DECLARE
v_status dc_projects.status%TYPE;
@@ -319,11 +354,21 @@ BEGIN
END IF;
UPDATE dc_projects
- SET status = 'COMPLETED',
- completed_at = v_now,
- progress = 100
+ SET status = 'COMPLETED',
+ completed_at = v_now,
+ progress = 100,
+ current_phase = 'COMPLETED',
+ current_doc_id = NULL,
+ current_question_id = NULL,
+ estimated_completion_at = NULL
WHERE id = :project_id;
+ -- Markdown-Bericht generieren (Fehler ignoriert, Projekt bleibt COMPLETED)
+ BEGIN
+ dc_generate_report(:project_id);
+ EXCEPTION WHEN OTHERS THEN NULL;
+ END;
+
:status_code := 200;
HTP.P('{"project_id":' || :project_id
|| ',"status":"COMPLETED"'
@@ -342,6 +387,37 @@ BEGIN
]'
);
+ -- ===========================================================================
+ -- TEMPLATE 12: projects/:project_id/report → Bericht generieren / abrufen
+ -- ===========================================================================
+ ORDS.DEFINE_TEMPLATE(
+ p_module_name => 'frigosped.dc',
+ p_pattern => 'projects/:project_id/report',
+ p_priority => 0,
+ p_comments => 'Markdown-Bericht eines Projekts'
+ );
+
+ -- 12. POST /api/dc/projects/:project_id/report – (neu) generieren
+ ORDS.DEFINE_HANDLER(
+ p_module_name => 'frigosped.dc',
+ p_pattern => 'projects/:project_id/report',
+ p_method => 'POST',
+ p_source_type => ORDS.SOURCE_TYPE_PLSQL,
+ p_comments => 'Generiert report_markdown neu (z.B. nach Konfigurationsänderung)',
+ p_source => q'[
+ BEGIN
+ dc_generate_report(:project_id);
+ :status_code := 200;
+ HTP.P('{"project_id":' || :project_id || ',"report_generated":true}');
+ EXCEPTION
+ WHEN OTHERS THEN
+ ROLLBACK;
+ :status_code := 500;
+ HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
+ END;
+ ]'
+ );
+
-- ===========================================================================
-- TEMPLATE 7: projects/:project_id/documents/
@@ -393,36 +469,78 @@ BEGIN
);
-- 8. GET /api/dc/documents/:doc_id/file
- -- SOURCE_TYPE_MEDIA: Pflicht-Aliase: content, content_type, filename
+ -- PLSQL-Workaround: SOURCE_TYPE_MEDIA ruft getString() auf BLOB auf → ORA-17004 in dieser ORDS-Version
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/file',
p_method => 'GET',
- p_source_type => ORDS.SOURCE_TYPE_MEDIA,
- p_comments => 'Streamt original_file BLOB; ORDS setzt Content-Type aus mime_type-Spalte',
+ p_source_type => ORDS.SOURCE_TYPE_PLSQL,
+ p_comments => 'Streamt original_file BLOB mit korrektem Content-Type (PLSQL-Workaround fuer ORDS-Bug)',
p_source => q'[
- SELECT
- d.original_file AS content,
- d.mime_type AS content_type,
- d.filename AS filename,
- d.updated AS last_modified
- FROM dc_project_documents d
- WHERE d.id = :doc_id
+ DECLARE
+ v_blob BLOB;
+ v_mime VARCHAR2(200);
+ v_filename VARCHAR2(500);
+ BEGIN
+ SELECT original_file, mime_type, filename
+ INTO v_blob, v_mime, v_filename
+ FROM dc_project_documents
+ WHERE id = :doc_id;
+
+ IF v_blob IS NULL THEN
+ :status_code := 404;
+ HTP.P('{"error":"Datei nicht vorhanden","doc_id":' || :doc_id || '}');
+ RETURN;
+ END IF;
+
+ OWA_UTIL.MIME_HEADER(NVL(v_mime, 'application/octet-stream'), FALSE);
+ HTP.P('Content-Disposition: attachment; filename="' || v_filename || '"');
+ OWA_UTIL.HTTP_HEADER_CLOSE;
+ WPG_DOCLOAD.DOWNLOAD_FILE(v_blob);
+ :status_code := 200;
+ EXCEPTION
+ WHEN NO_DATA_FOUND THEN
+ :status_code := 404;
+ HTP.P('{"error":"Dokument nicht gefunden","doc_id":' || :doc_id || '}');
+ WHEN OTHERS THEN
+ :status_code := 500;
+ HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
+ END;
]'
);
-- ===========================================================================
- -- TEMPLATE 9: documents/:doc_id/texts
+ -- TEMPLATE 9: documents/:doc_id/texts (GET + PUT)
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/texts',
p_priority => 0,
- p_comments => 'OCR-Ergebnis und Uebersetzung zurueckschreiben'
+ p_comments => 'OCR-Ergebnis und Uebersetzung lesen/schreiben'
);
- -- 9. PUT /api/dc/documents/:doc_id/texts
+ -- 9a. GET /api/dc/documents/:doc_id/texts (fuer Resume nach Phase A)
+ ORDS.DEFINE_HANDLER(
+ p_module_name => 'frigosped.dc',
+ p_pattern => 'documents/:doc_id/texts',
+ p_method => 'GET',
+ p_source_type => ORDS.SOURCE_TYPE_COLLECTION_ITEM,
+ p_items_per_page => 1,
+ p_comments => 'Gibt original_text und translated_text zurueck',
+ p_source => q'[
+ SELECT
+ d.id,
+ d.original_text,
+ d.translated_text,
+ CASE WHEN d.original_text IS NOT NULL THEN 1 ELSE 0 END AS has_original_text,
+ CASE WHEN d.translated_text IS NOT NULL THEN 1 ELSE 0 END AS has_translated_text
+ FROM dc_project_documents d
+ WHERE d.id = :doc_id
+ ]'
+ );
+
+ -- 9b. PUT /api/dc/documents/:doc_id/texts
-- Body: {"original_text": "## ...", "translated_text": "## ..."}
-- Beide Felder sind optional: NULL-Wert ueberschreibt nicht (nur wenn im Body vorhanden)
ORDS.DEFINE_HANDLER(
@@ -566,12 +684,6 @@ BEGIN
)
RETURNING id INTO v_new_id;
- -- Location-Header gemaess REST-Konvention
- ORDS.SET_RESPONSE_HEADER(
- 'Location',
- '/api/dc/projects/' || :project_id || '/results/' || v_new_id
- );
-
:status_code := 201;
HTP.P('{"id":' || v_new_id
|| ',"project_id":' || :project_id
diff --git a/claude_code.sh b/claude_code.sh
index a263a11..570376e 100644
--- a/claude_code.sh
+++ b/claude_code.sh
@@ -1,5 +1,6 @@
-#!/bin/bash
+#!/usr/bin/bash
export ANTHROPIC_AUTH_TOKEN="ollama"
export ANTHROPIC_API_KEY=""
export ANTHROPIC_BASE_URL="http://gx10.aquantico.lan:11434"
-claude --verbose --model qwen3.6:35b-a3b-q4_K_M
\ No newline at end of file
+claude --verbose --resume b9fed00a-f168-403e-aaca-df35c94338ce --model qwen3.6:35b-a3b-q4_K_M
+#claude --verbose --model qwen3.6:35b-a3b-q4_K_M
\ No newline at end of file
diff --git a/claude_codei_anthropic.sh b/claude_codei_anthropic.sh
new file mode 100644
index 0000000..154ece7
--- /dev/null
+++ b/claude_codei_anthropic.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/bash
+#export ANTHROPIC_AUTH_TOKEN="ollama"
+#export ANTHROPIC_API_KEY=""
+#export ANTHROPIC_BASE_URL="http://gx10.aquantico.lan:11434"
+claude --verbose --resume b9fed00a-f168-403e-aaca-df35c94338ce --model qwen3.6:35b-a3b-q4_K_M
+#claude --verbose --model qwen3.6:35b-a3b-q4_K_M
\ No newline at end of file
diff --git a/dc-backend/Dockerfile b/dc-backend/Dockerfile
index 2765b6b..e8777a0 100644
--- a/dc-backend/Dockerfile
+++ b/dc-backend/Dockerfile
@@ -19,6 +19,12 @@ FROM eclipse-temurin:21-jre
WORKDIR /app
+# Emoji-Schriftart für PDF-Export (Java AWT nutzt fontconfig-Fallback automatisch)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ fonts-noto-color-emoji fontconfig \
+ && fc-cache -f \
+ && rm -rf /var/lib/apt/lists/*
+
# Quarkus Fast-JAR Layout kopieren
COPY --from=build /build/target/quarkus-app/lib/ ./lib/
COPY --from=build /build/target/quarkus-app/*.jar ./
@@ -36,7 +42,7 @@ EXPOSE 8090
# 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"
+ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager -Djava.awt.headless=true"
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -sf http://localhost:8090/check/health || exit 1
diff --git a/dc-backend/history.log b/dc-backend/history.log
new file mode 100644
index 0000000..e69de29
diff --git a/dc-backend/k8s/deployment.yaml b/dc-backend/k8s/deployment.yaml
index 01b766a..3bc67f2 100644
--- a/dc-backend/k8s/deployment.yaml
+++ b/dc-backend/k8s/deployment.yaml
@@ -24,7 +24,7 @@ spec:
env:
# ORDS-Verbindung (Service im selben Namespace)
- name: QUARKUS_REST_CLIENT_ORDS_API_URL
- value: "http://ords:8080/ords/FRIGOSPED_APP"
+ value: "http://ords:8080/ords/ai_dev"
# API-Key für eingehende Anfragen (DC_API_KEY)
- name: DC_API_KEY
valueFrom:
diff --git a/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java b/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
index c0e2ad1..3556dcd 100644
--- a/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
+++ b/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
@@ -7,6 +7,8 @@ import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
+
/**
* MicroProfile REST Client für die ORDS /api/dc/-Endpunkte.
*
@@ -18,6 +20,7 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
* → @ClientHeaderParam(name = "Authorization", value = "${dc.ords.bearer-token}")
*/
@RegisterRestClient(configKey = "ords-api")
+@RegisterProvider(OrdsLoggingFilter.class)
@Path("/api/dc")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@@ -94,6 +97,14 @@ public interface OrdsClient {
@Produces(MediaType.WILDCARD)
Response downloadFile(@PathParam("docId") long docId);
+ /**
+ * GET /api/dc/documents/:docId/texts
+ * Liest original_text und translated_text (für Resume nach Phase A).
+ */
+ @GET
+ @Path("/documents/{docId}/texts")
+ OrdsDocumentTexts getTexts(@PathParam("docId") long docId);
+
/**
* PUT /api/dc/documents/:docId/texts
* Schreibt original_text und/oder translated_text zurück.
diff --git a/dc-backend/src/main/java/de/frigosped/dc/client/OrdsLoggingFilter.java b/dc-backend/src/main/java/de/frigosped/dc/client/OrdsLoggingFilter.java
index f9f2d7f..2969cd1 100644
--- a/dc-backend/src/main/java/de/frigosped/dc/client/OrdsLoggingFilter.java
+++ b/dc-backend/src/main/java/de/frigosped/dc/client/OrdsLoggingFilter.java
@@ -3,8 +3,8 @@ 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.ClientResponseContext;
import jakarta.ws.rs.client.ClientResponseFilter;
-import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
@@ -12,6 +12,7 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.List;
/**
* Loggt jede HTTP-Anfrage und -Antwort des ORDS REST Clients:
@@ -20,7 +21,7 @@ import java.util.Arrays;
* - Response-Status + Body
*/
@Provider
-@Priority(Javax.ws.rs.Priorities.AUTHENTICATION + 1)
+@Priority(jakarta.ws.rs.Priorities.AUTHENTICATION + 1)
public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFilter {
private static final Logger LOG = Logger.getLogger(OrdsLoggingFilter.class);
@@ -62,13 +63,7 @@ public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFil
} 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;
+ return body.isBlank() ? null : body;
}
private String readResponse(ClientResponseContext ctx) throws IOException {
@@ -81,7 +76,7 @@ public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFil
ctx.setEntityStream(new ByteArrayInputStream(raw));
if (raw.length > 4000) {
- return String.format("[%.4k bytes, trunc. zu 4000]", raw.length);
+ return String.format("[%d bytes, trunc. zu 4000]", raw.length);
}
String mediaType = ctx.getMediaType() != null ? ctx.getMediaType().toString() : "";
diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/ExportRequest.java b/dc-backend/src/main/java/de/frigosped/dc/model/ExportRequest.java
new file mode 100644
index 0000000..8cacac6
--- /dev/null
+++ b/dc-backend/src/main/java/de/frigosped/dc/model/ExportRequest.java
@@ -0,0 +1,15 @@
+package de.frigosped.dc.model;
+
+public class ExportRequest {
+
+ private String content;
+ private String format; // DOCX | PDF
+
+ public ExportRequest() {}
+
+ public String getContent() { return content; }
+ public void setContent(String v) { this.content = v; }
+
+ public String getFormat() { return format; }
+ public void setFormat(String v) { this.format = v; }
+}
diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentTexts.java b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentTexts.java
new file mode 100644
index 0000000..978715d
--- /dev/null
+++ b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentTexts.java
@@ -0,0 +1,27 @@
+package de.frigosped.dc.model;
+
+public class OrdsDocumentTexts {
+
+ private Long id;
+ private String originalText;
+ private String translatedText;
+ private Integer hasOriginalText;
+ private Integer hasTranslatedText;
+
+ public OrdsDocumentTexts() {}
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ 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; }
+
+ 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; }
+}
diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
index 7e0ae80..7b99a16 100644
--- a/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
+++ b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
@@ -10,12 +10,14 @@ public class OrdsQuestion {
private Long categoryId;
private String categoryName;
private String categoryDescription;
+ private Integer categoryOrderNr;
private String questionText;
private String evaluationType; // z.B. SCORE, BINARY
private Integer threshold; // Schwellwert 0–100
private String resultHandling; // ABWEICHUNG | HINWEIS
private String example0Percent; // Beispiel: Anforderung nicht erfüllt
private String example100Percent; // Beispiel: Anforderung voll erfüllt
+ private Integer orderNr;
private Integer rowVersion;
public OrdsQuestion() {}
@@ -50,6 +52,12 @@ public class OrdsQuestion {
public String getExample100Percent() { return example100Percent; }
public void setExample100Percent(String v) { this.example100Percent = v; }
+ public Integer getCategoryOrderNr() { return categoryOrderNr; }
+ public void setCategoryOrderNr(Integer v) { this.categoryOrderNr = v; }
+
+ public Integer getOrderNr() { return orderNr; }
+ public void setOrderNr(Integer v) { this.orderNr = v; }
+
public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; }
}
diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java b/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
index 6e975c0..d00f78c 100644
--- a/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
+++ b/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
@@ -2,11 +2,23 @@ package de.frigosped.dc.model;
/**
* Request-Body für PUT /api/dc/projects/:id/progress
- * {"progress": 45}
+ *
+ * Felder (alle optional – null = nicht aktualisieren):
+ * progress 0–100 Prozent
+ * currentPhase OCR | QUESTIONS | COMPLETED
+ * currentDocId ID des aktuell verarbeiteten Dokuments
+ * currentQuestionId ID der aktuell ausgewerteten Frage (null bei OCR)
+ * estimatedCompletionAt ISO-8601-Zeitstempel (Schätzung Fertigstellung)
+ *
+ * Jackson serialisiert camelCase → snake_case (quarkus.jackson.property-naming-strategy=SNAKE_CASE).
*/
public class ProgressRequest {
private Integer progress;
+ private String currentPhase;
+ private Long currentDocId;
+ private Long currentQuestionId;
+ private String estimatedCompletionAt;
public ProgressRequest() {}
@@ -14,6 +26,27 @@ public class ProgressRequest {
this.progress = progress;
}
- public Integer getProgress() { return progress; }
- public void setProgress(Integer v) { this.progress = v; }
+ public ProgressRequest(int progress, String currentPhase, Long currentDocId,
+ Long currentQuestionId, String estimatedCompletionAt) {
+ this.progress = progress;
+ this.currentPhase = currentPhase;
+ this.currentDocId = currentDocId;
+ this.currentQuestionId = currentQuestionId;
+ this.estimatedCompletionAt = estimatedCompletionAt;
+ }
+
+ public Integer getProgress() { return progress; }
+ public void setProgress(Integer v) { this.progress = v; }
+
+ public String getCurrentPhase() { return currentPhase; }
+ public void setCurrentPhase(String v) { this.currentPhase = v; }
+
+ public Long getCurrentDocId() { return currentDocId; }
+ public void setCurrentDocId(Long v) { this.currentDocId = v; }
+
+ public Long getCurrentQuestionId() { return currentQuestionId; }
+ public void setCurrentQuestionId(Long v) { this.currentQuestionId = v; }
+
+ public String getEstimatedCompletionAt() { return estimatedCompletionAt; }
+ public void setEstimatedCompletionAt(String v) { this.estimatedCompletionAt = v; }
}
diff --git a/dc-backend/src/main/java/de/frigosped/dc/resource/ExportResource.java b/dc-backend/src/main/java/de/frigosped/dc/resource/ExportResource.java
new file mode 100644
index 0000000..d07b1cc
--- /dev/null
+++ b/dc-backend/src/main/java/de/frigosped/dc/resource/ExportResource.java
@@ -0,0 +1,59 @@
+package de.frigosped.dc.resource;
+
+import de.frigosped.dc.model.ExportRequest;
+import de.frigosped.dc.service.MarkdownExportService;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.*;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+
+@Path("/export")
+public class ExportResource {
+
+ private static final Logger LOG = Logger.getLogger(ExportResource.class);
+
+ @Inject
+ MarkdownExportService exportService;
+
+ /**
+ * POST /export/markdown
+ * Body: { "content": "...", "format": "DOCX" | "PDF" }
+ * Returns the binary file with appropriate Content-Type.
+ */
+ @POST
+ @Path("/markdown")
+ @Consumes(MediaType.APPLICATION_JSON)
+ public Response convert(ExportRequest req) {
+ if (req == null || req.getContent() == null || req.getContent().isBlank()) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity("content must not be empty").build();
+ }
+ String format = req.getFormat() == null ? "PDF" : req.getFormat().toUpperCase();
+
+ try {
+ if ("DOCX".equals(format)) {
+ byte[] bytes = exportService.toDocx(req.getContent());
+ return Response.ok(bytes)
+ .header("Content-Type",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
+ .header("Content-Disposition", "attachment; filename=\"report.docx\"")
+ .build();
+
+ } else if ("PDF".equals(format)) {
+ byte[] bytes = exportService.toPdf(req.getContent());
+ return Response.ok(bytes)
+ .header("Content-Type", "application/pdf")
+ .header("Content-Disposition", "attachment; filename=\"report.pdf\"")
+ .build();
+
+ } else {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity("format must be DOCX or PDF").build();
+ }
+ } catch (Exception e) {
+ LOG.errorf(e, "Export fehlgeschlagen (format=%s)", format);
+ return Response.serverError().entity("Export failed: " + e.getMessage()).build();
+ }
+ }
+}
diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java b/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
index 3d963ed..e6e5e8e 100644
--- a/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
+++ b/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
@@ -8,9 +8,16 @@ import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
/**
* Orchestriert den kompletten Prüf-Ablauf für ein Projekt:
@@ -18,7 +25,7 @@ import java.util.Map;
* 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
+ * 4. Fragenkatalog für jeden Dokument-Text auswerten (Reihenfolge: question_id aufsteigend)
* 5. Ergebnisse Frage × Dokument in DB speichern
* 6. Projekt auf COMPLETED setzen
*
@@ -28,6 +35,8 @@ import java.util.Map;
public class CheckOrchestrationService {
private static final Logger LOG = Logger.getLogger(CheckOrchestrationService.class);
+ private static final DateTimeFormatter ISO_FMT =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
@RestClient
OrdsClient ordsClient;
@@ -48,30 +57,27 @@ public class CheckOrchestrationService {
try {
// 1. Projekt laden
OrdsProject project = ordsClient.getProject(projectId);
- LOG.infof("Projekt: '%s', Status: %s, Katalog: %d",
- project.getName(), project.getStatus(), project.getCatalogId());
+ LOG.infof("Projekt: '%s', Status: %s", project.getName(), project.getStatus());
- // Absicherung: nur PENDING starten
- if (!"PENDING".equals(project.getStatus())) {
- LOG.warnf("Projekt %d ist nicht PENDING (ist: %s) – Abbruch",
- projectId, project.getStatus());
+ // Nur abbrechen wenn bereits aktiv oder fertig (null = neues Projekt)
+ String status = project.getStatus();
+ if ("IN_PROGRESS".equals(status) || "COMPLETED".equals(status)) {
+ LOG.warnf("Projekt %d ist bereits %s – Abbruch", projectId, status);
return;
}
- // 2. Status → IN_PROGRESS
+ // 2. Status → IN_PROGRESS (setzt auch processing_started_at in ORDS)
Response startResp = ordsClient.startProject(projectId);
if (startResp.getStatus() == 409) {
LOG.warnf("Projekt %d konnte nicht gestartet werden (409) – Abbruch", projectId);
return;
}
+ Instant startedAt = Instant.now();
- // 3. Dokumente + Fragen laden
+ // 3. Dokumente laden
List documents = ordsClient.getDocuments(projectId).getItems();
- List questions = ordsClient
- .getQuestions(project.getCatalogId()).getItems();
- LOG.infof("Projekt %d: %d Dokument(e), %d Frage(n)",
- projectId, documents.size(), questions.size());
+ LOG.infof("Projekt %d: %d Dokument(e)", projectId, documents.size());
if (documents.isEmpty()) {
LOG.warnf("Projekt %d hat keine Dokumente – markiere als abgeschlossen", projectId);
@@ -79,79 +85,127 @@ public class CheckOrchestrationService {
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
+ // 4. Fragen pro Katalog laden (Katalog ist am Dokument, nicht am Projekt)
+ // Fragen werden nach question_id aufsteigend sortiert.
+ Map> questionsByCatalog = new HashMap<>();
+ for (OrdsDocument doc : documents) {
+ Long catId = doc.getCatalogId();
+ if (catId != null && !questionsByCatalog.containsKey(catId)) {
+ List qs = ordsClient.getQuestions(catId).getItems()
+ .stream()
+ .sorted(Comparator
+ .comparingInt((OrdsQuestion q) ->
+ q.getCategoryOrderNr() != null ? q.getCategoryOrderNr() : Integer.MAX_VALUE)
+ .thenComparingInt(q ->
+ q.getOrderNr() != null ? q.getOrderNr() : Integer.MAX_VALUE))
+ .collect(Collectors.toList());
+ questionsByCatalog.put(catId, qs);
+ LOG.infof("Katalog %d: %d Frage(n) geladen", catId, qs.size());
+ }
+ }
- // 5. Alte Ergebnisse löschen (ermöglicht Re-Processing)
+ // 5. Fortschrittsberechnung: 1 Schritt OCR + Anzahl Fragen des Dokument-Katalogs
+ int totalSteps = 0;
+ for (OrdsDocument doc : documents) {
+ Long catId = doc.getCatalogId();
+ int qCount = catId != null && questionsByCatalog.containsKey(catId)
+ ? questionsByCatalog.get(catId).size() : 0;
+ totalSteps += 1 + qCount;
+ }
+ int[] stepHolder = {0};
+
+ // 6. Alte Ergebnisse löschen (ermöglicht Re-Processing)
Response delResp = ordsClient.deleteResults(projectId);
- LOG.debugf("Alte Ergebnisse gelöscht: %s", delResp.getStatus());
+ LOG.debugf("Alte Ergebnisse gelöscht: HTTP %d", delResp.getStatus());
- // 6. Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS)
+ // Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS)
Map originalTexts = new HashMap<>();
- // ─── PHASE A: OCR + Übersetzung ───────────────────────────────
+ // ─── PHASE A: OCR + Übersetzung (wird übersprungen wenn Text bereits vorhanden) ──
for (OrdsDocument doc : documents) {
- LOG.infof("Verarbeite Dokument %d: %s", doc.getId(), doc.getFilename());
+ // Vor dem Schritt: Status auf "OCR für dieses Dokument" setzen
+ pushProgress(projectId, stepHolder[0], totalSteps, "OCR",
+ doc.getId(), null, startedAt);
- String originalText = "";
- String translatedText = "";
+ String originalText = "";
- 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));
+ if (Integer.valueOf(1).equals(doc.getHasOriginalText())) {
+ // Resume: Text aus vorherigem Lauf wiederverwenden
+ LOG.infof("Dokument %d: Text bereits vorhanden – überspringe OCR/Übersetzung",
+ doc.getId());
+ try {
+ OrdsDocumentTexts texts = ordsClient.getTexts(doc.getId());
+ originalText = texts.getOriginalText() != null ? texts.getOriginalText() : "";
+ } catch (Exception e) {
+ LOG.warnf("Text-Laden für Dokument %d fehlgeschlagen: %s", doc.getId(), e.getMessage());
+ }
+ } else {
+ LOG.infof("Verarbeite Dokument %d: %s", doc.getId(), doc.getFilename());
+ String translatedText = "";
+ try {
+ 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);
+
+ originalText = documentProcessingService.extractText(
+ fileBytes, doc.getMimeType(), doc.getFilename());
+ LOG.debugf("Dokument %d: %d Zeichen extrahiert",
+ (long) doc.getId(), (long) originalText.length());
+
+ translatedText = evaluationService.translate(originalText);
+ LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
+ (long) doc.getId(), (long) translatedText.length());
+
+ ordsClient.updateTexts(doc.getId(),
+ new TextsRequest(originalText, translatedText));
+ }
+ } catch (Exception e) {
+ LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
}
- } catch (Exception e) {
- LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
}
originalTexts.put(doc.getId(), originalText);
- // Fortschritt aktualisieren
+ // Nach dem Schritt: Fortschritt hochzählen + ETA aktualisieren
stepHolder[0]++;
- pushProgress(projectId, stepHolder[0], totalSteps);
+ pushProgress(projectId, stepHolder[0], totalSteps, "OCR",
+ doc.getId(), null, startedAt);
}
// ─── PHASE B: Fragen auswerten ────────────────────────────────
for (OrdsDocument doc : documents) {
String originalText = originalTexts.getOrDefault(doc.getId(), "");
+ Long catId = doc.getCatalogId();
+
+ if (catId == null || !questionsByCatalog.containsKey(catId)) {
+ LOG.warnf("Dokument %d hat keinen Katalog – überspringe Auswertung", doc.getId());
+ continue;
+ }
+
+ List questions = questionsByCatalog.get(catId);
if (originalText.isBlank()) {
LOG.warnf("Kein Text für Dokument %d – überspringe Auswertung", doc.getId());
stepHolder[0] += questions.size();
- pushProgress(projectId, stepHolder[0], totalSteps);
+ pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
+ doc.getId(), null, startedAt);
continue;
}
for (OrdsQuestion question : questions) {
+ // Vor dem Schritt: Status auf "wertet Frage X in Dokument Y aus"
+ pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
+ doc.getId(), question.getQuestionId(), startedAt);
+
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
@@ -185,8 +239,10 @@ public class CheckOrchestrationService {
question.getQuestionId(), doc.getId());
}
+ // Nach dem Schritt: Fortschritt + ETA aktualisieren
stepHolder[0]++;
- pushProgress(projectId, stepHolder[0], totalSteps);
+ pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
+ doc.getId(), question.getQuestionId(), startedAt);
}
}
@@ -195,8 +251,6 @@ public class CheckOrchestrationService {
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);
}
@@ -207,15 +261,33 @@ public class CheckOrchestrationService {
// =========================================================================
/**
- * Sendet den aktuellen Fortschritt an ORDS (0–100%).
- * Fehler werden nur geloggt, kein Abbruch.
+ * Sendet Fortschritt + Schrittdetails + ETA an ORDS.
+ * Wird vor (step = bisherige Schritte) und nach (step = bisherige + 1) jedem Schritt aufgerufen.
*/
- private void pushProgress(long projectId, int step, int total) {
- int pct = total > 0 ? Math.min(step * 100 / total, 99) : 0;
+ private void pushProgress(long projectId, int stepsCompleted, int total,
+ String phase, Long docId, Long questionId, Instant startedAt) {
+ int pct = total > 0 ? Math.min(stepsCompleted * 100 / total, 99) : 0;
+ String eta = (stepsCompleted > 0 && startedAt != null)
+ ? calcEta(startedAt, stepsCompleted, total) : null;
try {
- ordsClient.updateProgress(projectId, new ProgressRequest(pct));
+ ordsClient.updateProgress(projectId,
+ new ProgressRequest(pct, phase, docId, questionId, eta));
} catch (Exception e) {
LOG.debugf("Fortschritt konnte nicht gesetzt werden (%d%%): %s", pct, e.getMessage());
}
}
+
+ /**
+ * Berechnet die geschätzte Fertigstellung auf Basis der bisher verstrichenen Zeit.
+ * ETA = jetzt + (verbleibende Schritte × Ø Dauer pro Schritt)
+ */
+ private String calcEta(Instant startedAt, int stepsCompleted, int totalSteps) {
+ if (stepsCompleted <= 0) return null;
+ long elapsedMs = Duration.between(startedAt, Instant.now()).toMillis();
+ long avgMs = elapsedMs / stepsCompleted;
+ int remaining = totalSteps - stepsCompleted;
+ if (remaining <= 0) return null;
+ Instant eta = Instant.now().plusMillis(avgMs * remaining).truncatedTo(ChronoUnit.SECONDS);
+ return ISO_FMT.format(eta.atZone(ZoneId.systemDefault()));
+ }
}
diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java b/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java
new file mode 100644
index 0000000..2a9e543
--- /dev/null
+++ b/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java
@@ -0,0 +1,771 @@
+package de.frigosped.dc.service;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
+import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
+import org.apache.poi.xwpf.usermodel.*;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
+import org.jboss.logging.Logger;
+
+import java.awt.*;
+import java.awt.geom.Ellipse2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Konvertiert Markdown-Text zu DOCX (Apache POI) oder PDF (PDFBox).
+ *
+ * Unterstützte Markdown-Elemente:
+ * # ## ### #### Überschriften (4 Ebenen)
+ * **text** Fett
+ * *text* Kursiv
+ * --- Trennlinie
+ * > text Zitat / Blockquote
+ * | a | b | Tabelle
+ * ✅ ❌ ⚠️ ❓ 📄 Fünf Report-Emoji → werden als farbige Icons gerendert
+ * Leerzeile Vertikaler Abstand
+ *
+ * Einstiegspunkte:
+ * toDocx(markdown) → byte[] DOCX-Binärdaten
+ * toPdf(markdown) → byte[] PDF-Binärdaten
+ */
+@ApplicationScoped
+public class MarkdownExportService {
+
+ private static final Logger LOG = Logger.getLogger(MarkdownExportService.class);
+
+ // =========================================================================
+ // Inline-Segment-Modell (gemeinsam für DOCX und PDF)
+ // =========================================================================
+
+ /** Typ eines Inline-Abschnitts innerhalb einer Zeile. */
+ enum SegType { PLAIN, BOLD, ITALIC, EMOJI }
+
+ /** Ein Inline-Abschnitt mit Typ und Rohtext. */
+ record Seg(SegType type, String text) {}
+
+ /**
+ * Whitelist der fünf Report-Emoji-Codepoints.
+ * Nur diese werden als Icon-Bild gerendert. Alle anderen Unicode-Sonderzeichen
+ * (z.B. ● U+25CF, ™ U+2122) bleiben als PLAIN-Text und werden nicht angefasst.
+ */
+ private static boolean isReportEmoji(int cp) {
+ return cp == 0x2705 // ✅
+ || cp == 0x274C // ❌
+ || cp == 0x26A0 // ⚠
+ || cp == 0x2753 // ❓
+ || cp == 0x1F4C4; // 📄
+ }
+
+ /**
+ * Zerlegt eine Zeile in typisierte Inline-Segmente.
+ *
+ * Erkennungsreihenfolge pro Position:
+ * 1. **text** → BOLD
+ * 2. *text* → ITALIC (nur wenn nicht Teil von **)
+ * 3. isReportEmoji() → EMOJI (inkl. nachfolgende Variation-Selektoren U+FE0F / ZWJ U+200D)
+ * 4. alles andere → PLAIN (gesammelt bis zum nächsten Marker oder Emoji)
+ *
+ * Beispiel: "✅ **Frage** ist *kursiv*"
+ * → [EMOJI "✅"], [PLAIN " "], [BOLD "Frage"], [PLAIN " ist "], [ITALIC "kursiv"]
+ */
+ static List parseSegments(String text) {
+ List out = new ArrayList<>();
+ if (text == null || text.isEmpty()) return out;
+ int pos = 0;
+ while (pos < text.length()) {
+ // **bold**
+ if (text.startsWith("**", pos)) {
+ int end = text.indexOf("**", pos + 2);
+ if (end > pos + 1) {
+ out.add(new Seg(SegType.BOLD, text.substring(pos + 2, end)));
+ pos = end + 2;
+ continue;
+ }
+ }
+ // *italic* (not part of **)
+ if (text.charAt(pos) == '*'
+ && (pos + 1 >= text.length() || text.charAt(pos + 1) != '*')) {
+ int end = text.indexOf('*', pos + 1);
+ if (end > pos && (end + 1 >= text.length() || text.charAt(end + 1) != '*')) {
+ out.add(new Seg(SegType.ITALIC, text.substring(pos + 1, end)));
+ pos = end + 1;
+ continue;
+ }
+ }
+ // Bekanntes Report-Emoji
+ int cp = text.codePointAt(pos);
+ if (isReportEmoji(cp)) {
+ StringBuilder eb = new StringBuilder();
+ while (pos < text.length()) {
+ int c = text.codePointAt(pos);
+ if (!isReportEmoji(c) && c != 0xFE0F && c != 0x200D) break;
+ eb.appendCodePoint(c);
+ pos += Character.charCount(c);
+ }
+ out.add(new Seg(SegType.EMOJI, eb.toString()));
+ continue;
+ }
+ // Klartext bis zum nächsten Marker
+ int start = pos;
+ while (pos < text.length()) {
+ int c = text.codePointAt(pos);
+ if (isReportEmoji(c) || text.charAt(pos) == '*') break;
+ pos += Character.charCount(c);
+ }
+ if (pos > start) out.add(new Seg(SegType.PLAIN, text.substring(start, pos)));
+ }
+ return out;
+ }
+
+ // =========================================================================
+ // DOCX-Export via Apache POI
+ // =========================================================================
+ //
+ // Aufbau:
+ // toDocx()
+ // └── Hauptschleife über Zeilen
+ // ├── addHeading() → XWPFParagraph mit Heading-Style + Run
+ // ├── addParagraph() → renderInline() → ein Run pro Segment
+ // ├── addHorizontalRule()→ Paragraph mit Strich-Zeichen
+ // ├── addBlockquote() → eingerückter Paragraph + grauer Hintergrund
+ // └── addTable() → XWPFTable; Zellen via renderInline()
+ //
+ // Inline-Formatierung (renderInline):
+ // parseSegments() liefert die Segmente; für jedes wird ein eigener XWPFRun
+ // erzeugt und run.setBold() / run.setItalic() gesetzt.
+ // EMOJI-Segmente landen als Rohtext im Run (Word kann die Codepoints direkt anzeigen).
+ // =========================================================================
+
+ /**
+ * Erzeugt ein DOCX-Dokument aus dem Markdown-String.
+ * \r wird vorab entfernt (Oracle CLOBs liefern \r\n-Zeilenenden).
+ */
+ public byte[] toDocx(String markdown) throws Exception {
+ try (XWPFDocument doc = new XWPFDocument()) {
+ String[] lines = markdown.replace("\r", "").split("\n", -1);
+ int i = 0;
+ while (i < lines.length) {
+ String line = lines[i];
+
+ if (line.startsWith("#### ")) {
+ addHeading(doc, line.substring(5).trim(), 4);
+ } else if (line.startsWith("### ")) {
+ addHeading(doc, line.substring(4).trim(), 3);
+ } else if (line.startsWith("## ")) {
+ addHeading(doc, line.substring(3).trim(), 2);
+ } else if (line.startsWith("# ")) {
+ addHeading(doc, line.substring(2).trim(), 1);
+ } else if (line.equals("---")) {
+ addHorizontalRule(doc);
+ } else if (line.startsWith("> ")) {
+ addBlockquote(doc, line.substring(2).trim());
+ } else if (line.startsWith("|")) {
+ // Tabellenzeilen sammeln bis die Pipe-Sequenz endet
+ List rows = new ArrayList<>();
+ while (i < lines.length && lines[i].startsWith("|")) {
+ if (!isTableSeparator(lines[i])) {
+ String[] parts = lines[i].split("\\|", -1);
+ List cells = new ArrayList<>();
+ for (int p = 1; p < parts.length - 1; p++)
+ cells.add(parts[p].trim());
+ if (!cells.isEmpty()) rows.add(cells.toArray(new String[0]));
+ }
+ i++;
+ }
+ if (!rows.isEmpty()) addTable(doc, rows);
+ continue; // i wurde schon inkrementiert
+ } else if (!line.isBlank()) {
+ addParagraph(doc, line);
+ }
+ // Leerzeile erzeugt implizit Abstand durch fehlenden Paragraph-Inhalt
+ i++;
+ }
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ doc.write(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ /** Trennzeilen wie |:---|:---| überspringen; müssen Bindestriche enthalten. */
+ private static boolean isTableSeparator(String line) {
+ return line.matches("\\|[-: |]+\\|") && line.contains("-");
+ }
+
+ /**
+ * Fügt eine Überschrift hinzu.
+ * Setzt den Word-Style "Heading1"–"Heading4" und wählt die Schriftgröße:
+ * Level 1 = 22pt, 2 = 18pt, 3 = 14pt, 4 = 12pt.
+ */
+ private void addHeading(XWPFDocument doc, String text, int level) {
+ XWPFParagraph p = doc.createParagraph();
+ p.setStyle("Heading" + level);
+ XWPFRun run = p.createRun();
+ run.setText(text);
+ run.setBold(true);
+ run.setFontSize(switch (level) { case 1 -> 22; case 2 -> 18; case 3 -> 14; default -> 12; });
+ }
+
+ /**
+ * Fügt einen normalen Absatz mit Inline-Formatierung hinzu.
+ * Delegiert an renderInline(), das Bold/Italic/Emoji korrekt als separate Runs setzt.
+ */
+ private void addParagraph(XWPFDocument doc, String text) {
+ renderInline(doc.createParagraph(), text);
+ }
+
+ /**
+ * Rendert Inline-Formatierung in einen vorhandenen Paragraph.
+ * parseSegments() liefert die Segmente; jedes bekommt einen eigenen XWPFRun
+ * mit setBold()/setItalic(). Emoji-Codepoints landen als Rohtext (Word rendert sie nativ).
+ */
+ private void renderInline(XWPFParagraph p, String text) {
+ for (Seg seg : parseSegments(text)) {
+ XWPFRun run = p.createRun();
+ run.setText(seg.text());
+ if (seg.type() == SegType.BOLD) run.setBold(true);
+ if (seg.type() == SegType.ITALIC) run.setItalic(true);
+ }
+ }
+
+ /**
+ * Fügt eine visuelle Trennlinie ein (Dashes-Zeichen, kein echtes OOXML-Border).
+ */
+ private void addHorizontalRule(XWPFDocument doc) {
+ doc.createParagraph().createRun()
+ .setText("──────────────────────────────────────────────");
+ }
+
+ /**
+ * Fügt einen eingerückten Blockquote-Absatz ein.
+ * Einrückung: 720 Twips (= 1,27 cm), Hintergrund: #F0F0F0, Text kursiv.
+ */
+ private void addBlockquote(XWPFDocument doc, String text) {
+ XWPFParagraph p = doc.createParagraph();
+ p.setIndentationLeft(720);
+ try {
+ CTShd shd = p.getCTP().addNewPPr().addNewShd();
+ shd.setVal(STShd.CLEAR); shd.setColor("auto"); shd.setFill("F0F0F0");
+ } catch (Exception ignored) {}
+ XWPFRun run = p.createRun();
+ run.setItalic(true);
+ run.setText(text);
+ }
+
+ /**
+ * Fügt eine Tabelle ein.
+ * Wichtig: Zellen werden über renderInline() befüllt, nicht über cell.setText(),
+ * damit Bold/Italic innerhalb von Tabellenzellen korrekt angewandt werden.
+ */
+ private void addTable(XWPFDocument doc, List rows) {
+ if (rows.isEmpty()) return;
+ int cols = rows.get(0).length;
+ XWPFTable table = doc.createTable(rows.size(), cols);
+ table.setWidth("100%");
+ for (int r = 0; r < rows.size(); r++) {
+ XWPFTableRow row = table.getRow(r);
+ String[] cells = rows.get(r);
+ for (int c = 0; c < cols; c++) {
+ XWPFTableCell cell = c < row.getTableCells().size()
+ ? row.getCell(c) : row.addNewTableCell();
+ List paras = cell.getParagraphs();
+ XWPFParagraph para = paras.isEmpty() ? cell.addParagraph() : paras.get(0);
+ // Vorhandene (leere) Runs entfernen bevor renderInline neue anlegt
+ while (!para.getRuns().isEmpty()) para.removeRun(0);
+ if (c < cells.length) renderInline(para, cells[c]);
+ }
+ }
+ }
+
+ // =========================================================================
+ // PDF-Export via PDFBox
+ // =========================================================================
+ //
+ // Koordinatensystem: PDFBox nutzt PDF-Koordinaten (0,0 = unten links).
+ // y startet bei PAGE_HEIGHT - MARGIN (oben) und läuft nach unten.
+ // newPage() setzt y zurück auf PAGE_HEIGHT - MARGIN.
+ //
+ // Aufrufkette für eine normale Zeile:
+ // write()
+ // └── renderLine() zerlegt und bricht Zeile um
+ // ├── writeWrappedText() reiner Text ohne Formatierung
+ // │ └── flushText() schreibt eine Textzeile auf den Stream
+ // └── splitWords() + flushInlineLine() für gemischte Segmente
+ // └── renderSegsAt() schreibt Segmente inline nebeneinander
+ // ├── cs.showText() für PLAIN/BOLD/ITALIC
+ // └── cs.drawImage() für EMOJI (aus emojiImage())
+ //
+ // Emoji-Rendering:
+ // Java AWT im headless-Modus kann NotoColorEmoji (COLR v1) nicht rendern.
+ // Lösung: emojiImage() zeichnet die Icons als Java2D-Shapes in ein
+ // BufferedImage und gibt es als PDImageXObject zurück.
+ // =========================================================================
+
+ private static final float MARGIN = 50f;
+ private static final float LINE_HEIGHT = 14f;
+ private static final float PAGE_WIDTH = PDRectangle.A4.getWidth();
+ private static final float PAGE_HEIGHT = PDRectangle.A4.getHeight();
+ private static final float TEXT_WIDTH = PAGE_WIDTH - 2 * MARGIN;
+
+ /**
+ * Erzeugt ein PDF aus dem Markdown-String.
+ * \r wird vorab entfernt (Oracle CLOBs liefern \r\n; PDType1Font akzeptiert
+ * nur Latin-1 0x20–0xFF, \r würde eine Exception werfen).
+ */
+ public byte[] toPdf(String markdown) throws Exception {
+ try (PDDocument doc = new PDDocument()) {
+ new PdfWriter(doc).write(markdown.replace("\r", ""));
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ doc.save(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ /** Zustandsbehafteter Renderer für ein einzelnes PDF-Dokument. */
+ private class PdfWriter {
+
+ final PDDocument doc;
+ PDPageContentStream cs; // aktiver Content-Stream der aktuellen Seite
+ float y; // aktuelle y-Schreibposition (oben nach unten)
+ final PDType1Font fNorm, fBold, fItalic;
+
+ PdfWriter(PDDocument doc) throws Exception {
+ this.doc = doc;
+ this.fNorm = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
+ this.fBold = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
+ this.fItalic= new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE);
+ newPage();
+ }
+
+ /**
+ * Schließt den aktuellen Content-Stream, fügt eine neue A4-Seite ein
+ * und setzt y auf den oberen Rand.
+ */
+ void newPage() throws Exception {
+ if (cs != null) cs.close();
+ PDPage p = new PDPage(PDRectangle.A4);
+ doc.addPage(p);
+ cs = new PDPageContentStream(doc, p);
+ y = PAGE_HEIGHT - MARGIN;
+ }
+
+ /**
+ * Prüft ob noch mindestens h Punkte bis zum unteren Rand verbleiben.
+ * Wenn nicht → newPage(). Muss vor jedem cs.showText() / cs.drawImage() gerufen werden.
+ */
+ void ensureSpace(float h) throws Exception {
+ if (y - h < MARGIN) newPage();
+ }
+
+ /**
+ * Bewegt y um n Punkte nach unten (fügt vertikalen Leerraum ein).
+ * Ruft kein ensureSpace() auf — bei Seitenende greift das folgende renderLine/flushText.
+ */
+ void skip(float n) { y -= n; }
+
+ // ── Emoji → Java2D-Shape ──────────────────────────────────────────────
+ /**
+ * Erzeugt ein PDImageXObject für eines der fünf Report-Emoji.
+ *
+ * Hintergrund: Java AWT headless kann NotoColorEmoji (COLR v1-Format) nicht
+ * rendern — drawString() erzeugt ein transparentes Bild. Lösung: jedes Icon
+ * wird per Java2D-Shapes direkt in ein BufferedImage gezeichnet.
+ *
+ * Pixel-Größe = max(24, sizePt * 3) für ausreichende Auflösung bei Antialiasing.
+ * Das Ergebnis wird via LosslessFactory (PNG-Kompression) in das PDF eingebettet.
+ */
+ PDImageXObject emojiImage(String emoji, float sizePt) throws Exception {
+ int px = Math.max(24, (int)(sizePt * 3));
+ int pad = Math.max(1, px / 16);
+ BufferedImage img = new BufferedImage(px + pad * 2, px + pad * 2,
+ BufferedImage.TYPE_INT_ARGB);
+ Graphics2D g = img.createGraphics();
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
+ RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL,
+ RenderingHints.VALUE_STROKE_PURE);
+
+ float cx = pad + px / 2f;
+ float cy = pad + px / 2f;
+ float r = px / 2f - 0.5f;
+ int cp = emoji.isEmpty() ? 0 : emoji.codePointAt(0);
+
+ switch (cp) {
+ case 0x2705 -> { // ✅ grüner Kreis + weißes Häkchen
+ g.setColor(new Color(52, 168, 83));
+ g.fill(new Ellipse2D.Float(pad, pad, px, px));
+ g.setColor(Color.WHITE);
+ g.setStroke(new BasicStroke(px / 6f,
+ BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+ g.drawPolyline(
+ new int[]{(int)(cx - r*.42f), (int)(cx - r*.08f), (int)(cx + r*.42f)},
+ new int[]{(int)(cy + r*.05f), (int)(cy + r*.42f), (int)(cy - r*.32f)},
+ 3);
+ }
+ case 0x274C -> { // ❌ roter Kreis + weißes X
+ g.setColor(new Color(234, 67, 53));
+ g.fill(new Ellipse2D.Float(pad, pad, px, px));
+ g.setColor(Color.WHITE);
+ g.setStroke(new BasicStroke(px / 5.5f,
+ BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
+ float d = r * 0.34f;
+ g.drawLine((int)(cx-d),(int)(cy-d),(int)(cx+d),(int)(cy+d));
+ g.drawLine((int)(cx+d),(int)(cy-d),(int)(cx-d),(int)(cy+d));
+ }
+ case 0x26A0 -> { // ⚠️ gelbes Dreieck + !
+ g.setColor(new Color(251, 188, 4));
+ g.fillPolygon(
+ new int[]{(int)cx, pad + px, pad},
+ new int[]{pad, pad + px, pad + px}, 3);
+ g.setColor(new Color(50, 50, 50));
+ g.setFont(new java.awt.Font("Dialog", java.awt.Font.BOLD, (int)(px * .42f)));
+ FontMetrics fm = g.getFontMetrics();
+ g.drawString("!", (int)(cx - fm.stringWidth("!") / 2f),
+ (int)(pad + px * .82f));
+ }
+ case 0x2753 -> { // ❓ roter Kreis + weißes ?
+ g.setColor(new Color(234, 67, 53));
+ g.fill(new Ellipse2D.Float(pad, pad, px, px));
+ g.setColor(Color.WHITE);
+ g.setFont(new java.awt.Font("Dialog", java.awt.Font.BOLD, (int)(px * .55f)));
+ FontMetrics fm = g.getFontMetrics();
+ g.drawString("?", (int)(cx - fm.stringWidth("?") / 2f),
+ (int)(cy + px * .20f));
+ }
+ case 0x1F4C4 -> { // 📄 blaues Dokument
+ int dw = (int)(px * .70f), dh = (int)(px * .90f);
+ int dx = (int)(cx - dw / 2f), dy = pad + (int)(px * .05f);
+ int fold = (int)(px * .22f);
+ g.setColor(new Color(66, 133, 244));
+ g.fillPolygon(
+ new int[]{dx, dx + dw - fold, dx + dw, dx + dw, dx},
+ new int[]{dy, dy, dy + fold, dy + dh, dy + dh}, 5);
+ g.setColor(new Color(30, 90, 200)); // gefaltete Ecke dunkler
+ g.fillPolygon(
+ new int[]{dx + dw - fold, dx + dw - fold, dx + dw},
+ new int[]{dy, dy + fold, dy + fold}, 3);
+ g.setColor(Color.WHITE); // drei weiße Textzeilen
+ int lw = (int)(dw * .56f), lx = dx + (int)(dw * .18f);
+ int lh = Math.max(1, (int)(dh * .08f)), gap = (int)(dh * .13f);
+ int ly = dy + (int)(dh * .38f);
+ for (int l = 0; l < 3; l++) g.fillRect(lx, ly + l * gap, lw, lh);
+ }
+ default -> { // Unbekannter Codepoint: grauer Kreis (Fallback)
+ g.setColor(new Color(158, 158, 158));
+ g.fill(new Ellipse2D.Float(pad, pad, px, px));
+ }
+ }
+ g.dispose();
+ // LosslessFactory = verlustfreie PNG-Einbettung ins PDF
+ return LosslessFactory.createFromImage(doc, img);
+ }
+
+ /**
+ * Filtert einen String auf PDType1Font-sichere Zeichen (Latin-1, 0x20–0xFF).
+ * WinAnsiEncoding akzeptiert keine Zeichen außerhalb dieses Bereichs;
+ * unbekannte Codepoints werden durch Leerzeichen ersetzt.
+ */
+ private String safe(String s) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < s.length(); ) {
+ int cp = s.codePointAt(i);
+ sb.append((cp >= 0x20 && cp <= 0xFF) ? (char) cp : ' ');
+ i += Character.charCount(cp);
+ }
+ return sb.toString();
+ }
+
+ /** Gibt die passende PDType1Font-Instanz für einen Segment-Typ zurück. */
+ private PDType1Font fontFor(SegType t) {
+ return t == SegType.BOLD ? fBold : t == SegType.ITALIC ? fItalic : fNorm;
+ }
+
+ /**
+ * Berechnet die Breite eines Segments in PDF-Punkten.
+ * Emoji: geschätzt als 1,5 × Schriftgröße pro Codepoint.
+ * Text: exakt über getStringWidth() der jeweiligen PDType1Font.
+ */
+ float segWidth(Seg seg, float size) throws Exception {
+ if (seg.type() == SegType.EMOJI)
+ return size * 1.5f * seg.text().codePointCount(0, seg.text().length());
+ return fontFor(seg.type()).getStringWidth(safe(seg.text())) / 1000f * size;
+ }
+
+ /**
+ * Rendert eine Liste von Segmenten inline (nebeneinander) ab x-Position startX
+ * auf der Basislinie baseline.
+ *
+ * EMOJI-Segmente: emojiImage() → cs.drawImage(); vertikal zentriert zur Baseline.
+ * PLAIN/BOLD/ITALIC: beginText / setFont / newLineAtOffset / showText / endText.
+ *
+ * Gibt die neue x-Position nach dem letzten Segment zurück.
+ */
+ float renderSegsAt(List segs, float startX, float baseline, float size,
+ float emojiSize) throws Exception {
+ float x = startX;
+ for (Seg seg : segs) {
+ if (seg.type() == SegType.EMOJI) {
+ PDImageXObject img = emojiImage(seg.text(), emojiSize);
+ float imgH = emojiSize;
+ float imgW = imgH * img.getWidth() / (float) img.getHeight();
+ // 0.40f: Bild leicht über die Baseline heben (vertikal zentriert zum Text)
+ cs.drawImage(img, x, baseline - imgH * 0.40f, imgW, imgH);
+ x += imgW + 1;
+ } else {
+ String txt = safe(seg.text());
+ if (!txt.isEmpty()) {
+ PDType1Font f = fontFor(seg.type());
+ cs.beginText();
+ cs.setFont(f, size);
+ cs.newLineAtOffset(x, baseline);
+ cs.showText(txt);
+ cs.endText();
+ x += f.getStringWidth(txt) / 1000f * size;
+ }
+ }
+ }
+ return x;
+ }
+
+ /**
+ * Rendert eine Markdown-Zeile mit Zeilenumbruch.
+ *
+ * Zwei Pfade:
+ * a) Nur PLAIN-Segmente → writeWrappedText() (einfacher, schneller Pfad)
+ * b) Gemischt (Bold/Italic/Emoji) → splitWords() zerlegt in Wort-Einheiten,
+ * dann werden diese zu Zeilen gepackt und mit flushInlineLine() geschrieben.
+ *
+ * indent: horizontaler Einzug in Punkten (z.B. 20 für Blockquotes).
+ */
+ void renderLine(String text, PDType1Font defaultFont, float size, float indent)
+ throws Exception {
+ List segs = parseSegments(text);
+ boolean onlyPlain = segs.stream().allMatch(s -> s.type() == SegType.PLAIN);
+ if (onlyPlain) {
+ writeWrappedText(defaultFont, size, indent, text);
+ return;
+ }
+
+ float emojiPt = size * 1.5f;
+ float maxW = TEXT_WIDTH - indent;
+
+ List> words = splitWords(segs);
+ List> line = new ArrayList<>();
+ float lineW = 0;
+
+ for (List word : words) {
+ float ww = 0;
+ for (Seg s : word) ww += segWidth(s, s.type() == SegType.EMOJI ? emojiPt : size);
+
+ if (!line.isEmpty() && lineW + ww > maxW) {
+ flushInlineLine(line, size, emojiPt, indent);
+ line = new ArrayList<>();
+ lineW = 0;
+ // Führendes Leerzeichen am Zeilenanfang überspringen
+ if (word.size() == 1 && word.get(0).type() == SegType.PLAIN
+ && word.get(0).text().isBlank()) continue;
+ }
+ line.add(word);
+ lineW += ww;
+ }
+ if (!line.isEmpty()) flushInlineLine(line, size, emojiPt, indent);
+ }
+
+ /**
+ * Schreibt eine fertig zusammengestellte Inline-Zeile auf den PDF-Stream.
+ * Berechnet die Zeilenhöhe als Maximum aus LINE_HEIGHT und Emoji-Größe,
+ * ruft ensureSpace() für den Seitenumbruch und delegiert an renderSegsAt().
+ */
+ private void flushInlineLine(List> words, float size, float emojiPt,
+ float indent) throws Exception {
+ float lineH = Math.max(LINE_HEIGHT, emojiPt * 1.05f);
+ ensureSpace(lineH);
+ List flat = new ArrayList<>();
+ for (List w : words) flat.addAll(w);
+ renderSegsAt(flat, MARGIN + indent, y, size, emojiPt);
+ y -= lineH;
+ }
+
+ /**
+ * Zerlegt eine Liste von Segmenten in "Wörter" (für den Zeilenumbruch).
+ *
+ * Regeln:
+ * - EMOJI: jedes Emoji ist ein eigenes Wort (kein Umbruch innerhalb)
+ * - PLAIN/BOLD/ITALIC: an Leerzeichen aufteilen; jedes Leerzeichen
+ * wird als eigenes PLAIN-" "-Wort eingefügt (Trennstelle beim Umbrechen)
+ */
+ private List> splitWords(List segs) {
+ List> words = new ArrayList<>();
+ List cur = new ArrayList<>();
+ for (Seg seg : segs) {
+ if (seg.type() == SegType.EMOJI) {
+ if (!cur.isEmpty()) { words.add(cur); cur = new ArrayList<>(); }
+ words.add(List.of(seg));
+ continue;
+ }
+ String txt = seg.text();
+ int start = 0;
+ for (int i = 0; i <= txt.length(); i++) {
+ if (i == txt.length() || txt.charAt(i) == ' ') {
+ if (i > start)
+ cur.add(new Seg(seg.type(), txt.substring(start, i)));
+ if (i < txt.length()) {
+ if (!cur.isEmpty()) { words.add(cur); cur = new ArrayList<>(); }
+ words.add(List.of(new Seg(SegType.PLAIN, " ")));
+ }
+ start = i + 1;
+ }
+ }
+ if (start < txt.length())
+ cur.add(new Seg(seg.type(), txt.substring(start)));
+ }
+ if (!cur.isEmpty()) words.add(cur);
+ return words;
+ }
+
+ /**
+ * Wortumbruch für reinen Text (keine Inline-Formatierung).
+ * Misst jedes Wort mit getStringWidth(); bricht um wenn die Zeile voll ist.
+ * Schreibt jede fertige Zeile via flushText().
+ */
+ void writeWrappedText(PDType1Font font, float size, float indent, String text)
+ throws Exception {
+ float maxW = TEXT_WIDTH - indent;
+ String[] words = text.split(" ", -1);
+ StringBuilder cur = new StringBuilder();
+ for (String w : words) {
+ String candidate = cur.isEmpty() ? w : cur + " " + w;
+ float width;
+ try { width = font.getStringWidth(safe(candidate)) / 1000f * size; }
+ catch (Exception e) { width = maxW + 1; }
+ if (width > maxW && !cur.isEmpty()) {
+ flushText(font, size, indent, cur.toString());
+ cur = new StringBuilder(w);
+ } else {
+ cur = new StringBuilder(candidate);
+ }
+ }
+ if (!cur.isEmpty()) flushText(font, size, indent, cur.toString());
+ }
+
+ /**
+ * Schreibt eine einzelne Textzeile auf den PDF-Content-Stream.
+ * Ruft ensureSpace() → ggf. newPage() vor dem Schreiben.
+ * Dekrementiert y um LINE_HEIGHT danach.
+ */
+ void flushText(PDType1Font font, float size, float indent, String text) throws Exception {
+ ensureSpace(LINE_HEIGHT);
+ cs.beginText();
+ cs.setFont(font, size);
+ cs.newLineAtOffset(MARGIN + indent, y);
+ cs.showText(safe(text));
+ cs.endText();
+ y -= LINE_HEIGHT;
+ }
+
+ /**
+ * Rendert eine Tabelle ins PDF.
+ * Spaltenbreite = TEXT_WIDTH / Anzahl Spalten (gleichmäßig verteilt).
+ * Zellen werden inline via renderSegsAt() geschrieben — Emoji erscheinen als Icons.
+ * Kein Rahmen (PDFBox-Rahmen wären aufwendiger und nicht nötig für Report-Tabellen).
+ */
+ void renderTable(List rows) throws Exception {
+ if (rows.isEmpty()) return;
+ int maxCols = rows.stream().mapToInt(r -> r.length).max().orElse(1);
+ float colW = TEXT_WIDTH / maxCols;
+ float rowH = LINE_HEIGHT + 8;
+
+ for (String[] row : rows) {
+ ensureSpace(rowH);
+ float cx = MARGIN;
+ for (int c = 0; c < maxCols; c++) {
+ String cell = c < row.length ? row[c].trim() : "";
+ List segs = parseSegments(cell);
+ float emojiPt = rowH * 0.85f;
+ renderSegsAt(segs, cx + 2, y, 10, emojiPt);
+ cx += colW;
+ }
+ y -= rowH;
+ }
+ skip(2);
+ }
+
+ // ── Hauptschleife ─────────────────────────────────────────────────────
+ /**
+ * Verarbeitet den Markdown-String Zeile für Zeile und baut das PDF auf.
+ *
+ * Dispatching:
+ * "#### " / "### " / "## " / "# "
+ * → skip() (Abstand davor) + renderLine() mit Bold + größerer Schrift
+ * + skip() (Abstand danach)
+ * "---"
+ * → skip(4) + horizontale Linie direkt auf Content-Stream (moveTo/lineTo/stroke)
+ * + y -= 6
+ * "> text"
+ * → renderLine() mit Italic-Font, 20pt Einzug
+ * "|..."
+ * → Zeilen in tableAccum sammeln; sobald keine Pipe-Zeile mehr kommt
+ * → renderTable(); Trenner-Zeilen (|---|) werden übersprungen
+ * Leerzeile
+ * → skip(4): 4pt vertikaler Abstand
+ * Sonstiger Text
+ * → renderLine() mit Normal-Font, 11pt
+ */
+ void write(String markdown) throws Exception {
+ String[] lines = markdown.split("\n", -1);
+ List tableAccum = new ArrayList<>();
+
+ for (String line : lines) {
+ // Tabellenpuffer flushen sobald eine Nicht-Pipe-Zeile kommt
+ if (!line.startsWith("|") && !tableAccum.isEmpty()) {
+ renderTable(tableAccum);
+ tableAccum.clear();
+ }
+
+ if (line.startsWith("#### ")) {
+ skip(4); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(2);
+ } else if (line.startsWith("### ")) {
+ skip(6); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(3);
+ } else if (line.startsWith("## ")) {
+ skip(8); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(4);
+ } else if (line.startsWith("# ")) {
+ skip(10); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(6);
+ } else if (line.equals("---")) {
+ skip(4);
+ ensureSpace(2);
+ cs.setLineWidth(0.5f);
+ cs.moveTo(MARGIN, y);
+ cs.lineTo(PAGE_WIDTH - MARGIN, y);
+ cs.stroke();
+ y -= 6;
+ } else if (line.startsWith("> ")) {
+ renderLine(line.substring(2).trim(), fItalic, 10, 20);
+ } else if (line.startsWith("|")) {
+ if (!isTableSeparator(line)) {
+ String[] parts = line.split("\\|", -1);
+ List cells = new ArrayList<>();
+ for (int p = 1; p < parts.length - 1; p++)
+ cells.add(parts[p].trim());
+ if (!cells.isEmpty()) tableAccum.add(cells.toArray(new String[0]));
+ }
+ } else if (line.isBlank()) {
+ skip(4);
+ } else {
+ renderLine(line, fNorm, 11, 0);
+ }
+ }
+ if (!tableAccum.isEmpty()) renderTable(tableAccum);
+ cs.close();
+ }
+ }
+}
diff --git a/dc-backend/src/main/resources/application.properties b/dc-backend/src/main/resources/application.properties
index e63e8da..097bdd4 100644
--- a/dc-backend/src/main/resources/application.properties
+++ b/dc-backend/src/main/resources/application.properties
@@ -49,8 +49,8 @@ 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
+%prod.dc.ai.log-requests=true
+%prod.dc.ai.log-responses=true
# =============================================================================
# Jackson: snake_case für ORDS-Responses
diff --git a/dc-backend/test-run.sh b/dc-backend/test-run.sh
new file mode 100644
index 0000000..fa05baf
--- /dev/null
+++ b/dc-backend/test-run.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Setzt Projekt auf Ausgangszustand (status=NULL) und triggert POST /check/{id}
+# Verwendung: bash test-run.sh [projectId]
+
+set -euo pipefail
+
+PROJECT_ID="${1:-1}"
+BACKEND="http://localhost:8090"
+DB_USER="wksp_ai"
+DB_PASS='s!)löyx209aasgtrasdasiudkash5235FDSXljLJ'
+DB_HOST="10.75.10.171"
+DB_PORT="32710"
+DB_SID="freepdb1"
+KUBECONFIG_FILE="$(cd "$(dirname "$0")/.." && pwd)/env/frigo-dev.yaml"
+
+echo "=== Test-Run: Projekt $PROJECT_ID ==="
+
+# 1. Projekt zurücksetzen (status=NULL, progress=0, completed_at=NULL)
+# Ergebnisse löschen, aber original_text/translated_text BLEIBEN erhalten
+# → Phase A (OCR) wird beim nächsten Lauf automatisch übersprungen
+echo ""
+echo "→ DB-Reset: status=NULL, Ergebnisse löschen (Texte bleiben) ..."
+sql -S "$DB_USER/$DB_PASS@$DB_HOST:$DB_PORT/$DB_SID" < /dev/null 2>&1; then
+ echo ""
+ echo "→ Starte Port-Forward localhost:8090 → dc-backend:8090 ..."
+ KUBECONFIG="$KUBECONFIG_FILE" kubectl port-forward \
+ -n ai-env svc/dc-backend 8090:8090 &
+ PF_PID=$!
+ echo " Port-Forward PID: $PF_PID"
+ sleep 3
+else
+ PF_PID=""
+ echo "→ Port-Forward bereits aktiv."
+fi
+
+# 3. Trigger
+echo ""
+echo "→ POST $BACKEND/check/$PROJECT_ID ..."
+RESPONSE=$(curl -sf -X POST "$BACKEND/check/$PROJECT_ID" \
+ -H "Content-Type: application/json" || echo "FEHLER: curl fehlgeschlagen")
+echo " Response: $RESPONSE"
+
+# 4. Logs streamen
+echo ""
+echo "→ Logs (Ctrl+C zum Beenden):"
+POD=$(KUBECONFIG="$KUBECONFIG_FILE" kubectl get pod -n ai-env -l app=dc-backend \
+ --field-selector=status.phase=Running \
+ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
+KUBECONFIG="$KUBECONFIG_FILE" kubectl logs -f "$POD" -n ai-env --since=10s
+
+# Cleanup Port-Forward
+if [ -n "${PF_PID:-}" ]; then
+ kill "$PF_PID" 2>/dev/null || true
+fi