feat: Add Markdown export service for DOCX and PDF formats

- Implemented MarkdownExportService to convert Markdown text into DOCX and PDF formats.
- Supported Markdown elements include headings, bold, italic, blockquotes, tables, and specific emojis.
- Added methods for generating DOCX and PDF documents with proper formatting and rendering.
- Enhanced application properties to enable logging for production environment.
- Introduced a test-run script to reset project state and trigger processing via API.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Wolf G. Beckmann
2026-04-28 14:32:45 +02:00
parent 1816168d1f
commit 522bc21439
24 changed files with 2011 additions and 127 deletions

View File

@@ -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 *)"
]
}
}

View File

@@ -0,0 +1,5 @@
{
"version" : "v1",
"lastUpdate" : "27/04/2026 15:33:40",
"plugins" : { }
}

1
Doku/Ablaufplan.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -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<br/>→ 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<br/>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<ChatMessage>)
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<ChatMessage>)
Note over ES,MAIN_MDL: SystemMessage: Übersetzer-Prompt<br/>UserMessage: originalText
MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
OLLAMA-->>MAIN_MDL: Übersetzung (Deutsch)
MAIN_MDL-->>ES: Response<AiMessage>
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<br/>+ Beispiele 0% / 100%
ES->>MAIN_MDL: generate(List<ChatMessage>)
MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
OLLAMA-->>MAIN_MDL: JSON { answer, score, result_type, the_comment }
MAIN_MDL-->>ES: Response<AiMessage>
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?<br/>result_handling=ABWEICHUNG → deviation=1<br/>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
```

163
Doku/Prozessablauf.mmd Normal file
View File

@@ -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<br/>→ 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<br/>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<ChatMessage>)
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<ChatMessage>)
Note over ES,MAIN_MDL: SystemMessage: Übersetzer-Prompt<br/>UserMessage: originalText
MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
OLLAMA-->>MAIN_MDL: Übersetzung (Deutsch)
MAIN_MDL-->>ES: Response<AiMessage>
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<br/>+ Beispiele 0% / 100%
ES->>MAIN_MDL: generate(List<ChatMessage>)
MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
OLLAMA-->>MAIN_MDL: JSON { answer, score, result_type, the_comment }
MAIN_MDL-->>ES: Response<AiMessage>
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?<br/>result_handling=ABWEICHUNG → deviation=1<br/>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

View File

@@ -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;
/

View File

@@ -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;
/
*/

View File

@@ -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
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_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_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,8 +280,20 @@ 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
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
@@ -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;
@@ -321,9 +356,19 @@ BEGIN
UPDATE dc_projects
SET status = 'COMPLETED',
completed_at = v_now,
progress = 100
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

View File

@@ -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
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

View File

@@ -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

View File

@@ -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

0
dc-backend/history.log Normal file
View File

View File

@@ -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:

View File

@@ -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.

View File

@@ -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() : "";

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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 0100
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; }
}

View File

@@ -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 0100 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 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; }
}

View File

@@ -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();
}
}
}

View File

@@ -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<OrdsDocument> documents = ordsClient.getDocuments(projectId).getItems();
List<OrdsQuestion> questions = ordsClient
.getQuestions(project.getCatalogId()).getItems();
LOG.infof("Projekt %d: %d Dokument(e), %d Frage(n)",
projectId, documents.size(), questions.size());
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,27 +85,64 @@ 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<Long, List<OrdsQuestion>> questionsByCatalog = new HashMap<>();
for (OrdsDocument doc : documents) {
Long catId = doc.getCatalogId();
if (catId != null && !questionsByCatalog.containsKey(catId)) {
List<OrdsQuestion> 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<Long, String> 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 = "";
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 {
// Datei herunterladen
Response fileResp = ordsClient.downloadFile(doc.getId());
if (fileResp.getStatus() != 200) {
LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)",
@@ -107,51 +150,62 @@ public class CheckOrchestrationService {
} else {
byte[] fileBytes = fileResp.readEntity(byte[].class);
// OCR / Konvertierung → Markdown
originalText = documentProcessingService.extractText(
fileBytes, doc.getMimeType(), doc.getFilename());
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
(long) doc.getId(), (long) originalText.length());
// Übersetzung ins Deutsche
translatedText = evaluationService.translate(originalText);
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
(long) doc.getId(), (long) translatedText.length());
// Texte in ORDS zurückschreiben
ordsClient.updateTexts(doc.getId(),
new TextsRequest(originalText, translatedText));
}
} catch (Exception e) {
LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
}
}
originalTexts.put(doc.getId(), originalText);
// Fortschritt aktualisieren
// 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<OrdsQuestion> 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 (0100%).
* 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()));
}
}

View File

@@ -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<Seg> parseSegments(String text) {
List<Seg> 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<String[]> rows = new ArrayList<>();
while (i < lines.length && lines[i].startsWith("|")) {
if (!isTableSeparator(lines[i])) {
String[] parts = lines[i].split("\\|", -1);
List<String> 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<String[]> 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<XWPFParagraph> 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 0x200xFF, \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, 0x200xFF).
* 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<Seg> 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<Seg> 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<List<Seg>> words = splitWords(segs);
List<List<Seg>> line = new ArrayList<>();
float lineW = 0;
for (List<Seg> 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<List<Seg>> words, float size, float emojiPt,
float indent) throws Exception {
float lineH = Math.max(LINE_HEIGHT, emojiPt * 1.05f);
ensureSpace(lineH);
List<Seg> flat = new ArrayList<>();
for (List<Seg> 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<List<Seg>> splitWords(List<Seg> segs) {
List<List<Seg>> words = new ArrayList<>();
List<Seg> 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<String[]> 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<Seg> 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<String[]> 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<String> 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();
}
}
}

View File

@@ -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

66
dc-backend/test-run.sh Normal file
View File

@@ -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" <<SQL
DELETE FROM dc_results WHERE project_id = $PROJECT_ID;
UPDATE dc_projects
SET status = NULL,
progress = 0,
completed_at = NULL
WHERE id = $PROJECT_ID;
COMMIT;
SELECT id, name, status, catalog_id, progress FROM dc_projects WHERE id = $PROJECT_ID;
SQL
# 2. Port-Forward sicherstellen (im Hintergrund, falls noch nicht aktiv)
if ! curl -sf "$BACKEND/check/health" > /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