From 522bc21439601ad6162604d45a61f080d4fa93ce Mon Sep 17 00:00:00 2001 From: "Wolf G. Beckmann" Date: Tue, 28 Apr 2026 14:32:45 +0200 Subject: [PATCH] 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 --- .claude/settings.local.json | 31 +- .quarkus/cli/plugins/quarkus-cli-catalog.json | 5 + Doku/Ablaufplan.svg | 1 + Doku/Dokumenten-Check.md | 169 ++++ Doku/Prozessablauf.mmd | 163 ++++ Scripts/DC Generate Report.sql | 193 +++++ Scripts/DC_BACKEND_PKG.sql | 142 ++++ Scripts/ORDS REST Services.sql | 204 +++-- claude_code.sh | 5 +- claude_codei_anthropic.sh | 6 + dc-backend/Dockerfile | 8 +- dc-backend/history.log | 0 dc-backend/k8s/deployment.yaml | 2 +- .../de/frigosped/dc/client/OrdsClient.java | 11 + .../dc/client/OrdsLoggingFilter.java | 15 +- .../de/frigosped/dc/model/ExportRequest.java | 15 + .../frigosped/dc/model/OrdsDocumentTexts.java | 27 + .../de/frigosped/dc/model/OrdsQuestion.java | 8 + .../frigosped/dc/model/ProgressRequest.java | 39 +- .../frigosped/dc/resource/ExportResource.java | 59 ++ .../dc/service/CheckOrchestrationService.java | 194 +++-- .../dc/service/MarkdownExportService.java | 771 ++++++++++++++++++ .../src/main/resources/application.properties | 4 +- dc-backend/test-run.sh | 66 ++ 24 files changed, 2011 insertions(+), 127 deletions(-) create mode 100644 .quarkus/cli/plugins/quarkus-cli-catalog.json create mode 100644 Doku/Ablaufplan.svg create mode 100644 Doku/Prozessablauf.mmd create mode 100644 Scripts/DC Generate Report.sql create mode 100644 claude_codei_anthropic.sh create mode 100644 dc-backend/history.log create mode 100644 dc-backend/src/main/java/de/frigosped/dc/model/ExportRequest.java create mode 100644 dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentTexts.java create mode 100644 dc-backend/src/main/java/de/frigosped/dc/resource/ExportResource.java create mode 100644 dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java create mode 100644 dc-backend/test-run.sh 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 @@ +ollama.aquantico.deORDS / Oracle DBOllamaChatModel [main]OllamaChatModel [ocr]EvaluationServiceDocumentProcessingServiceOrdsClientOrdsLoggingFilterCheckOrchestrationServiceCheckResourceApiKeyFilterAPEX / Clientollama.aquantico.deORDS / Oracle DBOllamaChatModel [main]OllamaChatModel [ocr]EvaluationServiceDocumentProcessingServiceOrdsClientOrdsLoggingFilterCheckOrchestrationServiceCheckResourceApiKeyFilterAPEX / ClientPrüft X-API-KEY gegen dc.api.key→ 401 wenn ungültig, sonst weiterCompletableFuture.runAsync() – Fire & ForgetOrdsLoggingFilter.filter() loggt jeden Request/Responsedes REST-Clients (ClientRequestFilter + ClientResponseFilter)InitialisierungStatus-Prüfung: nur PENDING wird verarbeitetAlte Ergebnisse löschen → Re-Processing möglichPhase A – OCR + Übersetzung (pro Dokument)UserMessage: ImageContent(PNG) + TextContent(OCR-Prompt)loop[für jede PDF-Seite]loop[für jedes Body-Element]direkt als UTF-8 String zurückalt[MIME = application/pdf][MIME = application/vnd...wordprocessingml / msword][MIME = application/vnd.oasis.opendocument.text][text/plain | text/markdown]SystemMessage: Übersetzer-PromptUserMessage: originalTextloop[für jedes OrdsDocument]Phase B – Fragenauswertung (pro Dokument × Frage)Prompt: Dokument + Frage + Schwellwert+ Beispiele 0% / 100%Regex: ```json...``` oder erstes {...} im Textscore < threshold?result_handling=ABWEICHUNG → deviation=1result_handling=HINWEIS → warning=1loop[für jedes OrdsDocument × jede OrdsQuestion]status → COMPLETED, completed_at = SYSDATEPOST /check/{projectId} (Header: X-API-KEY)1filter(ContainerRequestContext)2weiterleiten (Key gültig)3startCheck(projectId)4202 Accepted { message, project_id, hint }5process(projectId)6getProject(projectId)7filter(ClientRequestContext)8GET /api/dc/projects/{id}9OrdsProject (JSON)10filter(ClientRequestContext, ClientResponseContext)11OrdsProject12startProject(projectId)13POST /api/dc/projects/{id}/start14200 OK | 409 Conflict15getDocuments(projectId)16GET /api/dc/projects/{id}/documents/17OrdsDocumentList18getQuestions(catalogId)19GET /api/dc/catalogs/{id}/questions/20OrdsQuestionList21deleteResults(projectId)22DELETE /api/dc/projects/{id}/results23downloadFile(docId)24GET /api/dc/documents/{id}/file25byte[] (BLOB)26extractText(fileBytes, mimeType, filename)27resolveMimeType(mimeType, filename)28extractFromPdf(fileBytes)29Loader.loadPDF(fileBytes) → PDDocument30new PDFRenderer(pdf)31renderer.renderImageWithDPI(i, 200f, RGB)32ImageIO.write(image, "PNG", baos) → Base6433ocrPage(base64Image, pageNum, totalPages)34generate(List<ChatMessage>)35POST /api/chat [Vision, X-API-KEY]36Markdown-Seitentext37String (Seite i)38extractFromDocx(fileBytes)39new XWPFDocument(stream)40paragraphToMarkdown(XWPFParagraph)41tableToMarkdown(XWPFTable)42extractFromOdt(fileBytes)43OdfTextDocument.loadDocument(stream)44getElementsByTagName("text:p") → Markdown45originalText (Markdown)46translate(originalText)47generate(List<ChatMessage>)48POST /api/chat [X-API-KEY]49Übersetzung (Deutsch)50Response<AiMessage>51translatedText52updateTexts(docId, TextsRequest(original, translated))53PUT /api/dc/documents/{id}/texts54pushProgress(projectId, step, total)55updateProgress(projectId, ProgressRequest(pct))56PUT /api/dc/projects/{id}/progress57evaluate(question, originalText)58buildEvaluationSystem()59buildEvaluationUser(question, documentText)60generate(List<ChatMessage>)61POST /api/chat [X-API-KEY]62JSON { answer, score, result_type, the_comment }63Response<AiMessage>64parseEvaluationResult(rawText, question)65extractJson(text)66objectMapper.readValue(json, EvaluationResult)67deriveResultType(score, threshold) [Fallback]68EvaluationResult { answer, score, resultType, theComment }69saveResult(projectId, ResultRequest)70POST /api/dc/projects/{id}/results → 201 Created71pushProgress(projectId, step, total)72updateProgress(projectId, ProgressRequest(pct))73PUT /api/dc/projects/{id}/progress74completeProject(projectId)75POST /api/dc/projects/{id}/complete76 \ 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