feat: Add Markdown export service for DOCX and PDF formats
- Implemented MarkdownExportService to convert Markdown text into DOCX and PDF formats. - Supported Markdown elements include headings, bold, italic, blockquotes, tables, and specific emojis. - Added methods for generating DOCX and PDF documents with proper formatting and rendering. - Enhanced application properties to enable logging for production environment. - Introduced a test-run script to reset project state and trigger processing via API. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
5
.quarkus/cli/plugins/quarkus-cli-catalog.json
Normal file
5
.quarkus/cli/plugins/quarkus-cli-catalog.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version" : "v1",
|
||||
"lastUpdate" : "27/04/2026 15:33:40",
|
||||
"plugins" : { }
|
||||
}
|
||||
1
Doku/Ablaufplan.svg
Normal file
1
Doku/Ablaufplan.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 225 KiB |
@@ -223,3 +223,172 @@ graph TB
|
||||
|
||||
|
||||
|
||||
## Source
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
|
||||
participant Client as APEX / Client
|
||||
participant AKF as ApiKeyFilter
|
||||
participant CR as CheckResource
|
||||
participant COS as CheckOrchestrationService
|
||||
participant OLF as OrdsLoggingFilter
|
||||
participant OC as OrdsClient
|
||||
participant DPS as DocumentProcessingService
|
||||
participant ES as EvaluationService
|
||||
participant OCR_MDL as OllamaChatModel [ocr]
|
||||
participant MAIN_MDL as OllamaChatModel [main]
|
||||
participant ORDS as ORDS / Oracle DB
|
||||
participant OLLAMA as ollama.aquantico.de
|
||||
|
||||
%% ─── Eingehende Anfrage ──────────────────────────────────────────────────
|
||||
Client->>AKF: POST /check/{projectId} (Header: X-API-KEY)
|
||||
AKF->>AKF: filter(ContainerRequestContext)
|
||||
Note over AKF: Prüft X-API-KEY gegen dc.api.key<br/>→ 401 wenn ungültig, sonst weiter
|
||||
AKF->>CR: weiterleiten (Key gültig)
|
||||
|
||||
CR->>CR: startCheck(projectId)
|
||||
CR-->>Client: 202 Accepted { message, project_id, hint }
|
||||
|
||||
Note over CR,COS: CompletableFuture.runAsync() – Fire & Forget
|
||||
CR-)COS: process(projectId)
|
||||
|
||||
%% ─── Alle ORDS-Aufrufe laufen durch OrdsLoggingFilter ───────────────────
|
||||
Note over OLF,ORDS: OrdsLoggingFilter.filter() loggt jeden Request/Response<br/>des REST-Clients (ClientRequestFilter + ClientResponseFilter)
|
||||
|
||||
%% ─── Initialisierung ────────────────────────────────────────────────────
|
||||
rect rgb(235, 245, 255)
|
||||
Note over COS,ORDS: Initialisierung
|
||||
|
||||
COS->>OC: getProject(projectId)
|
||||
OC->>OLF: filter(ClientRequestContext)
|
||||
OLF->>ORDS: GET /api/dc/projects/{id}
|
||||
ORDS-->>OLF: OrdsProject (JSON)
|
||||
OLF->>OLF: filter(ClientRequestContext, ClientResponseContext)
|
||||
OLF-->>COS: OrdsProject
|
||||
|
||||
Note over COS: Status-Prüfung: nur PENDING wird verarbeitet
|
||||
|
||||
COS->>OC: startProject(projectId)
|
||||
OC->>ORDS: POST /api/dc/projects/{id}/start
|
||||
ORDS-->>COS: 200 OK | 409 Conflict
|
||||
|
||||
COS->>OC: getDocuments(projectId)
|
||||
OC->>ORDS: GET /api/dc/projects/{id}/documents/
|
||||
ORDS-->>COS: OrdsDocumentList
|
||||
|
||||
COS->>OC: getQuestions(catalogId)
|
||||
OC->>ORDS: GET /api/dc/catalogs/{id}/questions/
|
||||
ORDS-->>COS: OrdsQuestionList
|
||||
|
||||
COS->>OC: deleteResults(projectId)
|
||||
OC->>ORDS: DELETE /api/dc/projects/{id}/results
|
||||
Note over ORDS: Alte Ergebnisse löschen → Re-Processing möglich
|
||||
end
|
||||
|
||||
%% ─── Phase A: OCR + Übersetzung ─────────────────────────────────────────
|
||||
rect rgb(220, 255, 225)
|
||||
Note over COS,OLLAMA: Phase A – OCR + Übersetzung (pro Dokument)
|
||||
|
||||
loop für jedes OrdsDocument
|
||||
|
||||
COS->>OC: downloadFile(docId)
|
||||
OC->>ORDS: GET /api/dc/documents/{id}/file
|
||||
ORDS-->>COS: byte[] (BLOB)
|
||||
|
||||
COS->>DPS: extractText(fileBytes, mimeType, filename)
|
||||
DPS->>DPS: resolveMimeType(mimeType, filename)
|
||||
|
||||
alt MIME = application/pdf
|
||||
DPS->>DPS: extractFromPdf(fileBytes)
|
||||
DPS->>DPS: Loader.loadPDF(fileBytes) → PDDocument
|
||||
DPS->>DPS: new PDFRenderer(pdf)
|
||||
|
||||
loop für jede PDF-Seite
|
||||
DPS->>DPS: renderer.renderImageWithDPI(i, 200f, RGB)
|
||||
DPS->>DPS: ImageIO.write(image, "PNG", baos) → Base64
|
||||
DPS->>DPS: ocrPage(base64Image, pageNum, totalPages)
|
||||
DPS->>OCR_MDL: generate(List<ChatMessage>)
|
||||
Note over DPS,OCR_MDL: UserMessage: ImageContent(PNG) + TextContent(OCR-Prompt)
|
||||
OCR_MDL->>OLLAMA: POST /api/chat [Vision, X-API-KEY]
|
||||
OLLAMA-->>OCR_MDL: Markdown-Seitentext
|
||||
OCR_MDL-->>DPS: String (Seite i)
|
||||
end
|
||||
|
||||
else MIME = application/vnd...wordprocessingml / msword
|
||||
DPS->>DPS: extractFromDocx(fileBytes)
|
||||
DPS->>DPS: new XWPFDocument(stream)
|
||||
loop für jedes Body-Element
|
||||
DPS->>DPS: paragraphToMarkdown(XWPFParagraph)
|
||||
DPS->>DPS: tableToMarkdown(XWPFTable)
|
||||
end
|
||||
|
||||
else MIME = application/vnd.oasis.opendocument.text
|
||||
DPS->>DPS: extractFromOdt(fileBytes)
|
||||
DPS->>DPS: OdfTextDocument.loadDocument(stream)
|
||||
DPS->>DPS: getElementsByTagName("text:p") → Markdown
|
||||
|
||||
else text/plain | text/markdown
|
||||
Note over DPS: direkt als UTF-8 String zurück
|
||||
end
|
||||
|
||||
DPS-->>COS: originalText (Markdown)
|
||||
|
||||
COS->>ES: translate(originalText)
|
||||
ES->>MAIN_MDL: generate(List<ChatMessage>)
|
||||
Note over ES,MAIN_MDL: SystemMessage: Übersetzer-Prompt<br/>UserMessage: originalText
|
||||
MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
|
||||
OLLAMA-->>MAIN_MDL: Übersetzung (Deutsch)
|
||||
MAIN_MDL-->>ES: Response<AiMessage>
|
||||
ES-->>COS: translatedText
|
||||
|
||||
COS->>OC: updateTexts(docId, TextsRequest(original, translated))
|
||||
OC->>ORDS: PUT /api/dc/documents/{id}/texts
|
||||
|
||||
COS->>COS: pushProgress(projectId, step, total)
|
||||
COS->>OC: updateProgress(projectId, ProgressRequest(pct))
|
||||
OC->>ORDS: PUT /api/dc/projects/{id}/progress
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── Phase B: Fragen auswerten ───────────────────────────────────────────
|
||||
rect rgb(255, 245, 220)
|
||||
Note over COS,OLLAMA: Phase B – Fragenauswertung (pro Dokument × Frage)
|
||||
|
||||
loop für jedes OrdsDocument × jede OrdsQuestion
|
||||
|
||||
COS->>ES: evaluate(question, originalText)
|
||||
ES->>ES: buildEvaluationSystem()
|
||||
ES->>ES: buildEvaluationUser(question, documentText)
|
||||
Note over ES: Prompt: Dokument + Frage + Schwellwert<br/>+ Beispiele 0% / 100%
|
||||
ES->>MAIN_MDL: generate(List<ChatMessage>)
|
||||
MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
|
||||
OLLAMA-->>MAIN_MDL: JSON { answer, score, result_type, the_comment }
|
||||
MAIN_MDL-->>ES: Response<AiMessage>
|
||||
|
||||
ES->>ES: parseEvaluationResult(rawText, question)
|
||||
ES->>ES: extractJson(text)
|
||||
Note over ES: Regex: ```json...``` oder erstes {...} im Text
|
||||
ES->>ES: objectMapper.readValue(json, EvaluationResult)
|
||||
ES->>ES: deriveResultType(score, threshold) [Fallback]
|
||||
ES-->>COS: EvaluationResult { answer, score, resultType, theComment }
|
||||
|
||||
Note over COS: score < threshold?<br/>result_handling=ABWEICHUNG → deviation=1<br/>result_handling=HINWEIS → warning=1
|
||||
|
||||
COS->>OC: saveResult(projectId, ResultRequest)
|
||||
OC->>ORDS: POST /api/dc/projects/{id}/results → 201 Created
|
||||
|
||||
COS->>COS: pushProgress(projectId, step, total)
|
||||
COS->>OC: updateProgress(projectId, ProgressRequest(pct))
|
||||
OC->>ORDS: PUT /api/dc/projects/{id}/progress
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── Abschluss ───────────────────────────────────────────────────────────
|
||||
COS->>OC: completeProject(projectId)
|
||||
OC->>ORDS: POST /api/dc/projects/{id}/complete
|
||||
Note over ORDS: status → COMPLETED, completed_at = SYSDATE
|
||||
|
||||
```
|
||||
|
||||
|
||||
163
Doku/Prozessablauf.mmd
Normal file
163
Doku/Prozessablauf.mmd
Normal file
@@ -0,0 +1,163 @@
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
|
||||
participant Client as APEX / Client
|
||||
participant AKF as ApiKeyFilter
|
||||
participant CR as CheckResource
|
||||
participant COS as CheckOrchestrationService
|
||||
participant OLF as OrdsLoggingFilter
|
||||
participant OC as OrdsClient
|
||||
participant DPS as DocumentProcessingService
|
||||
participant ES as EvaluationService
|
||||
participant OCR_MDL as OllamaChatModel [ocr]
|
||||
participant MAIN_MDL as OllamaChatModel [main]
|
||||
participant ORDS as ORDS / Oracle DB
|
||||
participant OLLAMA as ollama.aquantico.de
|
||||
|
||||
%% ─── Eingehende Anfrage ──────────────────────────────────────────────────
|
||||
Client->>AKF: POST /check/{projectId} (Header: X-API-KEY)
|
||||
AKF->>AKF: filter(ContainerRequestContext)
|
||||
Note over AKF: Prüft X-API-KEY gegen dc.api.key<br/>→ 401 wenn ungültig, sonst weiter
|
||||
AKF->>CR: weiterleiten (Key gültig)
|
||||
|
||||
CR->>CR: startCheck(projectId)
|
||||
CR-->>Client: 202 Accepted { message, project_id, hint }
|
||||
|
||||
Note over CR,COS: CompletableFuture.runAsync() – Fire & Forget
|
||||
CR-)COS: process(projectId)
|
||||
|
||||
%% ─── Alle ORDS-Aufrufe laufen durch OrdsLoggingFilter ───────────────────
|
||||
Note over OLF,ORDS: OrdsLoggingFilter.filter() loggt jeden Request/Response<br/>des REST-Clients (ClientRequestFilter + ClientResponseFilter)
|
||||
|
||||
%% ─── Initialisierung ────────────────────────────────────────────────────
|
||||
rect rgb(235, 245, 255)
|
||||
Note over COS,ORDS: Initialisierung
|
||||
|
||||
COS->>OC: getProject(projectId)
|
||||
OC->>OLF: filter(ClientRequestContext)
|
||||
OLF->>ORDS: GET /api/dc/projects/{id}
|
||||
ORDS-->>OLF: OrdsProject (JSON)
|
||||
OLF->>OLF: filter(ClientRequestContext, ClientResponseContext)
|
||||
OLF-->>COS: OrdsProject
|
||||
|
||||
Note over COS: Status-Prüfung: nur PENDING wird verarbeitet
|
||||
|
||||
COS->>OC: startProject(projectId)
|
||||
OC->>ORDS: POST /api/dc/projects/{id}/start
|
||||
ORDS-->>COS: 200 OK | 409 Conflict
|
||||
|
||||
COS->>OC: getDocuments(projectId)
|
||||
OC->>ORDS: GET /api/dc/projects/{id}/documents/
|
||||
ORDS-->>COS: OrdsDocumentList
|
||||
|
||||
COS->>OC: getQuestions(catalogId)
|
||||
OC->>ORDS: GET /api/dc/catalogs/{id}/questions/
|
||||
ORDS-->>COS: OrdsQuestionList
|
||||
|
||||
COS->>OC: deleteResults(projectId)
|
||||
OC->>ORDS: DELETE /api/dc/projects/{id}/results
|
||||
Note over ORDS: Alte Ergebnisse löschen → Re-Processing möglich
|
||||
end
|
||||
|
||||
%% ─── Phase A: OCR + Übersetzung ─────────────────────────────────────────
|
||||
rect rgb(220, 255, 225)
|
||||
Note over COS,OLLAMA: Phase A – OCR + Übersetzung (pro Dokument)
|
||||
|
||||
loop für jedes OrdsDocument
|
||||
|
||||
COS->>OC: downloadFile(docId)
|
||||
OC->>ORDS: GET /api/dc/documents/{id}/file
|
||||
ORDS-->>COS: byte[] (BLOB)
|
||||
|
||||
COS->>DPS: extractText(fileBytes, mimeType, filename)
|
||||
DPS->>DPS: resolveMimeType(mimeType, filename)
|
||||
|
||||
alt MIME = application/pdf
|
||||
DPS->>DPS: extractFromPdf(fileBytes)
|
||||
DPS->>DPS: Loader.loadPDF(fileBytes) → PDDocument
|
||||
DPS->>DPS: new PDFRenderer(pdf)
|
||||
|
||||
loop für jede PDF-Seite
|
||||
DPS->>DPS: renderer.renderImageWithDPI(i, 200f, RGB)
|
||||
DPS->>DPS: ImageIO.write(image, "PNG", baos) → Base64
|
||||
DPS->>DPS: ocrPage(base64Image, pageNum, totalPages)
|
||||
DPS->>OCR_MDL: generate(List<ChatMessage>)
|
||||
Note over DPS,OCR_MDL: UserMessage: ImageContent(PNG) + TextContent(OCR-Prompt)
|
||||
OCR_MDL->>OLLAMA: POST /api/chat [Vision, X-API-KEY]
|
||||
OLLAMA-->>OCR_MDL: Markdown-Seitentext
|
||||
OCR_MDL-->>DPS: String (Seite i)
|
||||
end
|
||||
|
||||
else MIME = application/vnd...wordprocessingml / msword
|
||||
DPS->>DPS: extractFromDocx(fileBytes)
|
||||
DPS->>DPS: new XWPFDocument(stream)
|
||||
loop für jedes Body-Element
|
||||
DPS->>DPS: paragraphToMarkdown(XWPFParagraph)
|
||||
DPS->>DPS: tableToMarkdown(XWPFTable)
|
||||
end
|
||||
|
||||
else MIME = application/vnd.oasis.opendocument.text
|
||||
DPS->>DPS: extractFromOdt(fileBytes)
|
||||
DPS->>DPS: OdfTextDocument.loadDocument(stream)
|
||||
DPS->>DPS: getElementsByTagName("text:p") → Markdown
|
||||
|
||||
else text/plain | text/markdown
|
||||
Note over DPS: direkt als UTF-8 String zurück
|
||||
end
|
||||
|
||||
DPS-->>COS: originalText (Markdown)
|
||||
|
||||
COS->>ES: translate(originalText)
|
||||
ES->>MAIN_MDL: generate(List<ChatMessage>)
|
||||
Note over ES,MAIN_MDL: SystemMessage: Übersetzer-Prompt<br/>UserMessage: originalText
|
||||
MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
|
||||
OLLAMA-->>MAIN_MDL: Übersetzung (Deutsch)
|
||||
MAIN_MDL-->>ES: Response<AiMessage>
|
||||
ES-->>COS: translatedText
|
||||
|
||||
COS->>OC: updateTexts(docId, TextsRequest(original, translated))
|
||||
OC->>ORDS: PUT /api/dc/documents/{id}/texts
|
||||
|
||||
COS->>COS: pushProgress(projectId, step, total)
|
||||
COS->>OC: updateProgress(projectId, ProgressRequest(pct))
|
||||
OC->>ORDS: PUT /api/dc/projects/{id}/progress
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── Phase B: Fragen auswerten ───────────────────────────────────────────
|
||||
rect rgb(255, 245, 220)
|
||||
Note over COS,OLLAMA: Phase B – Fragenauswertung (pro Dokument × Frage)
|
||||
|
||||
loop für jedes OrdsDocument × jede OrdsQuestion
|
||||
|
||||
COS->>ES: evaluate(question, originalText)
|
||||
ES->>ES: buildEvaluationSystem()
|
||||
ES->>ES: buildEvaluationUser(question, documentText)
|
||||
Note over ES: Prompt: Dokument + Frage + Schwellwert<br/>+ Beispiele 0% / 100%
|
||||
ES->>MAIN_MDL: generate(List<ChatMessage>)
|
||||
MAIN_MDL->>OLLAMA: POST /api/chat [X-API-KEY]
|
||||
OLLAMA-->>MAIN_MDL: JSON { answer, score, result_type, the_comment }
|
||||
MAIN_MDL-->>ES: Response<AiMessage>
|
||||
|
||||
ES->>ES: parseEvaluationResult(rawText, question)
|
||||
ES->>ES: extractJson(text)
|
||||
Note over ES: Regex: ```json...``` oder erstes {...} im Text
|
||||
ES->>ES: objectMapper.readValue(json, EvaluationResult)
|
||||
ES->>ES: deriveResultType(score, threshold) [Fallback]
|
||||
ES-->>COS: EvaluationResult { answer, score, resultType, theComment }
|
||||
|
||||
Note over COS: score < threshold?<br/>result_handling=ABWEICHUNG → deviation=1<br/>result_handling=HINWEIS → warning=1
|
||||
|
||||
COS->>OC: saveResult(projectId, ResultRequest)
|
||||
OC->>ORDS: POST /api/dc/projects/{id}/results → 201 Created
|
||||
|
||||
COS->>COS: pushProgress(projectId, step, total)
|
||||
COS->>OC: updateProgress(projectId, ProgressRequest(pct))
|
||||
OC->>ORDS: PUT /api/dc/projects/{id}/progress
|
||||
end
|
||||
end
|
||||
|
||||
%% ─── Abschluss ───────────────────────────────────────────────────────────
|
||||
COS->>OC: completeProject(projectId)
|
||||
OC->>ORDS: POST /api/dc/projects/{id}/complete
|
||||
Note over ORDS: status → COMPLETED, completed_at = SYSDATE
|
||||
193
Scripts/DC Generate Report.sql
Normal file
193
Scripts/DC Generate Report.sql
Normal file
@@ -0,0 +1,193 @@
|
||||
-- =============================================================================
|
||||
-- dc_generate_report – Erstellt einen Markdown-Prüfbericht für ein Projekt
|
||||
--
|
||||
-- Aufruf:
|
||||
-- BEGIN dc_generate_report(p_project_id => 1); COMMIT; END;
|
||||
-- /
|
||||
--
|
||||
-- Ergebnis: dc_projects.report_markdown wird mit dem Markdown-Text befüllt.
|
||||
-- Wird automatisch beim POST /api/dc/projects/:id/complete aufgerufen.
|
||||
-- Kann manuell via POST /api/dc/projects/:id/report neu generiert werden.
|
||||
--
|
||||
-- Aufbau des Berichts:
|
||||
-- 1. Kopf (Projektname, Abschlussdatum)
|
||||
-- 2. Zusammenfassung (OK / Hinweise / Abweichungen)
|
||||
-- 3. Ergebnisse: je Dokument → Kategorie (order_nr) → Frage (order_nr)
|
||||
-- Icons: ✅ OK | ⚠️ Hinweis | ❌ Abweichung | ❓ Unklar
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE PROCEDURE dc_generate_report(p_project_id IN NUMBER) IS
|
||||
|
||||
v_md CLOB;
|
||||
v_name dc_projects.name%TYPE;
|
||||
v_status dc_projects.status%TYPE;
|
||||
v_done dc_projects.completed_at%TYPE;
|
||||
|
||||
v_ok_cnt NUMBER := 0;
|
||||
v_warn_cnt NUMBER := 0;
|
||||
v_dev_cnt NUMBER := 0;
|
||||
v_unkl_cnt NUMBER := 0;
|
||||
v_total NUMBER := 0;
|
||||
|
||||
-- Emoji-Konstanten via UNISTR (vermeidet Quellcode-Encoding-Probleme)
|
||||
C_OK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2705'); -- ✅
|
||||
C_NOK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\274C'); -- ❌
|
||||
C_WARN CONSTANT VARCHAR2(10 CHAR) := UNISTR('\26A0\FE0F'); -- ⚠️
|
||||
C_UNKL CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2753'); -- ❓
|
||||
C_DOC CONSTANT VARCHAR2(10 CHAR) := UNISTR('\D83D\DCC4'); -- 📄
|
||||
|
||||
CURSOR c_docs IS
|
||||
SELECT d.id, d.filename
|
||||
FROM dc_project_documents d
|
||||
WHERE d.project_id = p_project_id
|
||||
ORDER BY d.uploaded_at, d.id;
|
||||
|
||||
CURSOR c_cats(p_doc_id NUMBER) IS
|
||||
SELECT DISTINCT qc.id, qc.name, qc.order_nr
|
||||
FROM dc_question_categories qc
|
||||
JOIN dc_questions q ON q.category_id = qc.id
|
||||
JOIN dc_results r ON r.question_id = q.id
|
||||
WHERE r.project_id = p_project_id
|
||||
AND r.doc_id = p_doc_id
|
||||
ORDER BY qc.order_nr, qc.name;
|
||||
|
||||
CURSOR c_qs(p_doc_id NUMBER, p_cat_id NUMBER) IS
|
||||
SELECT q.question_text,
|
||||
r.result_type,
|
||||
r.score,
|
||||
r.deviation,
|
||||
r.warning,
|
||||
r.answer,
|
||||
r.the_comment
|
||||
FROM dc_questions q
|
||||
JOIN dc_results r ON r.question_id = q.id
|
||||
WHERE r.project_id = p_project_id
|
||||
AND r.doc_id = p_doc_id
|
||||
AND q.category_id = p_cat_id
|
||||
ORDER BY q.order_nr, q.id;
|
||||
|
||||
v_icon VARCHAR2(20 CHAR);
|
||||
|
||||
-- Fügt eine Zeile + Newline an den CLOB an. Akzeptiert CLOB damit kein
|
||||
-- implizites CLOB→VARCHAR2-Cast nötig ist (question_text etc. sind CLOBs).
|
||||
PROCEDURE ln(p_text IN CLOB DEFAULT NULL) IS
|
||||
BEGIN
|
||||
IF p_text IS NOT NULL AND DBMS_LOB.GETLENGTH(p_text) > 0 THEN
|
||||
DBMS_LOB.APPEND(v_md, p_text);
|
||||
END IF;
|
||||
DBMS_LOB.APPEND(v_md, TO_CLOB(CHR(10)));
|
||||
END ln;
|
||||
|
||||
BEGIN
|
||||
DBMS_LOB.CREATETEMPORARY(v_md, TRUE);
|
||||
|
||||
SELECT name, status, completed_at
|
||||
INTO v_name, v_status, v_done
|
||||
FROM dc_projects
|
||||
WHERE id = p_project_id;
|
||||
|
||||
SELECT SUM(CASE WHEN deviation = 1 THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN warning = 1 THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN result_type = 'UNKLAR'
|
||||
AND deviation = 0
|
||||
AND warning = 0 THEN 1 ELSE 0 END),
|
||||
COUNT(*)
|
||||
INTO v_dev_cnt, v_warn_cnt, v_unkl_cnt, v_total
|
||||
FROM dc_results
|
||||
WHERE project_id = p_project_id;
|
||||
|
||||
v_ok_cnt := v_total - v_dev_cnt - v_warn_cnt - v_unkl_cnt;
|
||||
|
||||
-- =========================================================================
|
||||
-- Kopf
|
||||
-- =========================================================================
|
||||
ln('# ' || C_OK || ' Prüfbericht: ' || v_name);
|
||||
ln();
|
||||
IF v_done IS NOT NULL THEN
|
||||
ln('**Abgeschlossen:** ' || TO_CHAR(v_done, 'DD.MM.YYYY HH24:MI'));
|
||||
ELSE
|
||||
ln('**Erstellt:** ' || TO_CHAR(SYSDATE, 'DD.MM.YYYY HH24:MI'));
|
||||
END IF;
|
||||
ln();
|
||||
ln('---');
|
||||
ln();
|
||||
|
||||
-- =========================================================================
|
||||
-- Zusammenfassung (keine Tabelle – Plaintext für maximale Kompatibilität)
|
||||
-- =========================================================================
|
||||
ln('## Zusammenfassung');
|
||||
ln();
|
||||
ln(C_OK || ' OK: ' || v_ok_cnt);
|
||||
ln(C_WARN || ' Hinweise: ' || v_warn_cnt);
|
||||
ln(C_NOK || ' Abweichungen: ' || v_dev_cnt);
|
||||
IF v_unkl_cnt > 0 THEN
|
||||
ln(C_UNKL || ' Unklar: ' || v_unkl_cnt);
|
||||
END IF;
|
||||
ln('**Gesamt: ' || v_total || '**');
|
||||
ln();
|
||||
ln('---');
|
||||
ln();
|
||||
|
||||
-- =========================================================================
|
||||
-- Ergebnisse je Dokument → Kategorie (order_nr) → Frage (order_nr)
|
||||
-- =========================================================================
|
||||
ln('## Ergebnisse');
|
||||
ln();
|
||||
|
||||
FOR doc IN c_docs LOOP
|
||||
ln('## ' || C_DOC || ' ' || doc.filename);
|
||||
ln();
|
||||
|
||||
FOR cat IN c_cats(doc.id) LOOP
|
||||
ln();
|
||||
ln('### ' || cat.name);
|
||||
ln();
|
||||
|
||||
FOR q IN c_qs(doc.id, cat.id) LOOP
|
||||
|
||||
IF q.deviation = 1 THEN v_icon := C_NOK;
|
||||
ELSIF q.warning = 1 THEN v_icon := C_WARN;
|
||||
ELSIF q.result_type = 'UNKLAR' THEN v_icon := C_UNKL;
|
||||
ELSE v_icon := C_OK;
|
||||
END IF;
|
||||
|
||||
ln();
|
||||
ln(v_icon || ' **' || q.question_text || '**');
|
||||
ln();
|
||||
|
||||
IF q.answer IS NOT NULL AND DBMS_LOB.GETLENGTH(q.answer) > 0 THEN
|
||||
ln(q.answer);
|
||||
ln();
|
||||
END IF;
|
||||
|
||||
IF q.score IS NOT NULL THEN
|
||||
ln('*Score: ' || q.score || '%*');
|
||||
END IF;
|
||||
|
||||
IF q.the_comment IS NOT NULL
|
||||
AND DBMS_LOB.GETLENGTH(q.the_comment) > 0 THEN
|
||||
ln();
|
||||
ln('> ' || q.the_comment);
|
||||
END IF;
|
||||
|
||||
ln();
|
||||
END LOOP;
|
||||
|
||||
END LOOP;
|
||||
|
||||
ln('---');
|
||||
ln();
|
||||
END LOOP;
|
||||
|
||||
UPDATE dc_projects
|
||||
SET report_markdown = v_md
|
||||
WHERE id = p_project_id;
|
||||
|
||||
DBMS_LOB.FREETEMPORARY(v_md);
|
||||
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
DBMS_LOB.FREETEMPORARY(v_md);
|
||||
RAISE;
|
||||
END dc_generate_report;
|
||||
/
|
||||
@@ -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;
|
||||
/
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/bash
|
||||
export ANTHROPIC_AUTH_TOKEN="ollama"
|
||||
export ANTHROPIC_API_KEY=""
|
||||
export ANTHROPIC_BASE_URL="http://gx10.aquantico.lan:11434"
|
||||
claude --verbose --model qwen3.6:35b-a3b-q4_K_M
|
||||
claude --verbose --resume b9fed00a-f168-403e-aaca-df35c94338ce --model qwen3.6:35b-a3b-q4_K_M
|
||||
#claude --verbose --model qwen3.6:35b-a3b-q4_K_M
|
||||
6
claude_codei_anthropic.sh
Normal file
6
claude_codei_anthropic.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/bash
|
||||
#export ANTHROPIC_AUTH_TOKEN="ollama"
|
||||
#export ANTHROPIC_API_KEY=""
|
||||
#export ANTHROPIC_BASE_URL="http://gx10.aquantico.lan:11434"
|
||||
claude --verbose --resume b9fed00a-f168-403e-aaca-df35c94338ce --model qwen3.6:35b-a3b-q4_K_M
|
||||
#claude --verbose --model qwen3.6:35b-a3b-q4_K_M
|
||||
@@ -19,6 +19,12 @@ FROM eclipse-temurin:21-jre
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Emoji-Schriftart für PDF-Export (Java AWT nutzt fontconfig-Fallback automatisch)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fonts-noto-color-emoji fontconfig \
|
||||
&& fc-cache -f \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Quarkus Fast-JAR Layout kopieren
|
||||
COPY --from=build /build/target/quarkus-app/lib/ ./lib/
|
||||
COPY --from=build /build/target/quarkus-app/*.jar ./
|
||||
@@ -36,7 +42,7 @@ EXPOSE 8090
|
||||
# DC_AI_OCR_MODEL OCR-Modell
|
||||
# QUARKUS_REST_CLIENT_ORDS_API_URL ORDS-Basis-URL
|
||||
|
||||
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
|
||||
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager -Djava.awt.headless=true"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD curl -sf http://localhost:8090/check/health || exit 1
|
||||
|
||||
0
dc-backend/history.log
Normal file
0
dc-backend/history.log
Normal file
@@ -24,7 +24,7 @@ spec:
|
||||
env:
|
||||
# ORDS-Verbindung (Service im selben Namespace)
|
||||
- name: QUARKUS_REST_CLIENT_ORDS_API_URL
|
||||
value: "http://ords:8080/ords/FRIGOSPED_APP"
|
||||
value: "http://ords:8080/ords/ai_dev"
|
||||
# API-Key für eingehende Anfragen (DC_API_KEY)
|
||||
- name: DC_API_KEY
|
||||
valueFrom:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,16 @@ import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Orchestriert den kompletten Prüf-Ablauf für ein Projekt:
|
||||
@@ -18,7 +25,7 @@ import java.util.Map;
|
||||
* 1. Projekt laden → IN_PROGRESS setzen
|
||||
* 2. Dokumente herunterladen → OCR / Konvertierung → Texte speichern
|
||||
* 3. Texte ins Deutsche übersetzen → gespeichert
|
||||
* 4. Fragenkatalog für jeden Dokument-Text auswerten
|
||||
* 4. Fragenkatalog für jeden Dokument-Text auswerten (Reihenfolge: question_id aufsteigend)
|
||||
* 5. Ergebnisse Frage × Dokument in DB speichern
|
||||
* 6. Projekt auf COMPLETED setzen
|
||||
*
|
||||
@@ -28,6 +35,8 @@ import java.util.Map;
|
||||
public class CheckOrchestrationService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(CheckOrchestrationService.class);
|
||||
private static final DateTimeFormatter ISO_FMT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
|
||||
|
||||
@RestClient
|
||||
OrdsClient ordsClient;
|
||||
@@ -48,30 +57,27 @@ public class CheckOrchestrationService {
|
||||
try {
|
||||
// 1. Projekt laden
|
||||
OrdsProject project = ordsClient.getProject(projectId);
|
||||
LOG.infof("Projekt: '%s', Status: %s, Katalog: %d",
|
||||
project.getName(), project.getStatus(), project.getCatalogId());
|
||||
LOG.infof("Projekt: '%s', Status: %s", project.getName(), project.getStatus());
|
||||
|
||||
// Absicherung: nur PENDING starten
|
||||
if (!"PENDING".equals(project.getStatus())) {
|
||||
LOG.warnf("Projekt %d ist nicht PENDING (ist: %s) – Abbruch",
|
||||
projectId, project.getStatus());
|
||||
// Nur abbrechen wenn bereits aktiv oder fertig (null = neues Projekt)
|
||||
String status = project.getStatus();
|
||||
if ("IN_PROGRESS".equals(status) || "COMPLETED".equals(status)) {
|
||||
LOG.warnf("Projekt %d ist bereits %s – Abbruch", projectId, status);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Status → IN_PROGRESS
|
||||
// 2. Status → IN_PROGRESS (setzt auch processing_started_at in ORDS)
|
||||
Response startResp = ordsClient.startProject(projectId);
|
||||
if (startResp.getStatus() == 409) {
|
||||
LOG.warnf("Projekt %d konnte nicht gestartet werden (409) – Abbruch", projectId);
|
||||
return;
|
||||
}
|
||||
Instant startedAt = Instant.now();
|
||||
|
||||
// 3. Dokumente + Fragen laden
|
||||
// 3. Dokumente laden
|
||||
List<OrdsDocument> documents = ordsClient.getDocuments(projectId).getItems();
|
||||
List<OrdsQuestion> questions = ordsClient
|
||||
.getQuestions(project.getCatalogId()).getItems();
|
||||
|
||||
LOG.infof("Projekt %d: %d Dokument(e), %d Frage(n)",
|
||||
projectId, documents.size(), questions.size());
|
||||
LOG.infof("Projekt %d: %d Dokument(e)", projectId, documents.size());
|
||||
|
||||
if (documents.isEmpty()) {
|
||||
LOG.warnf("Projekt %d hat keine Dokumente – markiere als abgeschlossen", projectId);
|
||||
@@ -79,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<Long, List<OrdsQuestion>> questionsByCatalog = new HashMap<>();
|
||||
for (OrdsDocument doc : documents) {
|
||||
Long catId = doc.getCatalogId();
|
||||
if (catId != null && !questionsByCatalog.containsKey(catId)) {
|
||||
List<OrdsQuestion> qs = ordsClient.getQuestions(catId).getItems()
|
||||
.stream()
|
||||
.sorted(Comparator
|
||||
.comparingInt((OrdsQuestion q) ->
|
||||
q.getCategoryOrderNr() != null ? q.getCategoryOrderNr() : Integer.MAX_VALUE)
|
||||
.thenComparingInt(q ->
|
||||
q.getOrderNr() != null ? q.getOrderNr() : Integer.MAX_VALUE))
|
||||
.collect(Collectors.toList());
|
||||
questionsByCatalog.put(catId, qs);
|
||||
LOG.infof("Katalog %d: %d Frage(n) geladen", catId, qs.size());
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Alte Ergebnisse löschen (ermöglicht Re-Processing)
|
||||
// 5. Fortschrittsberechnung: 1 Schritt OCR + Anzahl Fragen des Dokument-Katalogs
|
||||
int totalSteps = 0;
|
||||
for (OrdsDocument doc : documents) {
|
||||
Long catId = doc.getCatalogId();
|
||||
int qCount = catId != null && questionsByCatalog.containsKey(catId)
|
||||
? questionsByCatalog.get(catId).size() : 0;
|
||||
totalSteps += 1 + qCount;
|
||||
}
|
||||
int[] stepHolder = {0};
|
||||
|
||||
// 6. Alte Ergebnisse löschen (ermöglicht Re-Processing)
|
||||
Response delResp = ordsClient.deleteResults(projectId);
|
||||
LOG.debugf("Alte Ergebnisse gelöscht: %s", delResp.getStatus());
|
||||
LOG.debugf("Alte Ergebnisse gelöscht: HTTP %d", delResp.getStatus());
|
||||
|
||||
// 6. Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS)
|
||||
// Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS)
|
||||
Map<Long, String> originalTexts = new HashMap<>();
|
||||
|
||||
// ─── PHASE A: OCR + Übersetzung ───────────────────────────────
|
||||
// ─── PHASE A: OCR + Übersetzung (wird übersprungen wenn Text bereits vorhanden) ──
|
||||
for (OrdsDocument doc : documents) {
|
||||
LOG.infof("Verarbeite Dokument %d: %s", doc.getId(), doc.getFilename());
|
||||
// Vor dem Schritt: Status auf "OCR für dieses Dokument" setzen
|
||||
pushProgress(projectId, stepHolder[0], totalSteps, "OCR",
|
||||
doc.getId(), null, startedAt);
|
||||
|
||||
String originalText = "";
|
||||
String translatedText = "";
|
||||
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<OrdsQuestion> questions = questionsByCatalog.get(catId);
|
||||
|
||||
if (originalText.isBlank()) {
|
||||
LOG.warnf("Kein Text für Dokument %d – überspringe Auswertung", doc.getId());
|
||||
stepHolder[0] += questions.size();
|
||||
pushProgress(projectId, stepHolder[0], totalSteps);
|
||||
pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
|
||||
doc.getId(), null, startedAt);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (OrdsQuestion question : questions) {
|
||||
// Vor dem Schritt: Status auf "wertet Frage X in Dokument Y aus"
|
||||
pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
|
||||
doc.getId(), question.getQuestionId(), startedAt);
|
||||
|
||||
try {
|
||||
LOG.debugf("Auswertung: Dok %d × Frage %d",
|
||||
doc.getId(), question.getQuestionId());
|
||||
|
||||
EvaluationResult result = evaluationService.evaluate(question, originalText);
|
||||
|
||||
// Abweichung / Hinweis aus Schwellwert + result_handling berechnen
|
||||
int deviation = 0;
|
||||
int warning = 0;
|
||||
if (result.getScore() != null && question.getThreshold() != null
|
||||
@@ -185,8 +239,10 @@ public class CheckOrchestrationService {
|
||||
question.getQuestionId(), doc.getId());
|
||||
}
|
||||
|
||||
// Nach dem Schritt: Fortschritt + ETA aktualisieren
|
||||
stepHolder[0]++;
|
||||
pushProgress(projectId, stepHolder[0], totalSteps);
|
||||
pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
|
||||
doc.getId(), question.getQuestionId(), startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +251,6 @@ public class CheckOrchestrationService {
|
||||
LOG.infof("=== Projekt %d erfolgreich abgeschlossen ===", projectId);
|
||||
|
||||
} catch (Exception e) {
|
||||
// Kritischer Fehler: Projekt bleibt in IN_PROGRESS.
|
||||
// APEX-Oberfläche / Admin muss manuell zurücksetzen.
|
||||
LOG.errorf(e, "=== Kritischer Fehler bei Projekt %d – bleibt IN_PROGRESS ===",
|
||||
projectId);
|
||||
}
|
||||
@@ -207,15 +261,33 @@ public class CheckOrchestrationService {
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Sendet den aktuellen Fortschritt an ORDS (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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
package de.frigosped.dc.service;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.poi.xwpf.usermodel.*;
|
||||
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
|
||||
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.geom.Ellipse2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Konvertiert Markdown-Text zu DOCX (Apache POI) oder PDF (PDFBox).
|
||||
*
|
||||
* Unterstützte Markdown-Elemente:
|
||||
* # ## ### #### Überschriften (4 Ebenen)
|
||||
* **text** Fett
|
||||
* *text* Kursiv
|
||||
* --- Trennlinie
|
||||
* > text Zitat / Blockquote
|
||||
* | a | b | Tabelle
|
||||
* ✅ ❌ ⚠️ ❓ 📄 Fünf Report-Emoji → werden als farbige Icons gerendert
|
||||
* Leerzeile Vertikaler Abstand
|
||||
*
|
||||
* Einstiegspunkte:
|
||||
* toDocx(markdown) → byte[] DOCX-Binärdaten
|
||||
* toPdf(markdown) → byte[] PDF-Binärdaten
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class MarkdownExportService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(MarkdownExportService.class);
|
||||
|
||||
// =========================================================================
|
||||
// Inline-Segment-Modell (gemeinsam für DOCX und PDF)
|
||||
// =========================================================================
|
||||
|
||||
/** Typ eines Inline-Abschnitts innerhalb einer Zeile. */
|
||||
enum SegType { PLAIN, BOLD, ITALIC, EMOJI }
|
||||
|
||||
/** Ein Inline-Abschnitt mit Typ und Rohtext. */
|
||||
record Seg(SegType type, String text) {}
|
||||
|
||||
/**
|
||||
* Whitelist der fünf Report-Emoji-Codepoints.
|
||||
* Nur diese werden als Icon-Bild gerendert. Alle anderen Unicode-Sonderzeichen
|
||||
* (z.B. ● U+25CF, ™ U+2122) bleiben als PLAIN-Text und werden nicht angefasst.
|
||||
*/
|
||||
private static boolean isReportEmoji(int cp) {
|
||||
return cp == 0x2705 // ✅
|
||||
|| cp == 0x274C // ❌
|
||||
|| cp == 0x26A0 // ⚠
|
||||
|| cp == 0x2753 // ❓
|
||||
|| cp == 0x1F4C4; // 📄
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt eine Zeile in typisierte Inline-Segmente.
|
||||
*
|
||||
* Erkennungsreihenfolge pro Position:
|
||||
* 1. **text** → BOLD
|
||||
* 2. *text* → ITALIC (nur wenn nicht Teil von **)
|
||||
* 3. isReportEmoji() → EMOJI (inkl. nachfolgende Variation-Selektoren U+FE0F / ZWJ U+200D)
|
||||
* 4. alles andere → PLAIN (gesammelt bis zum nächsten Marker oder Emoji)
|
||||
*
|
||||
* Beispiel: "✅ **Frage** ist *kursiv*"
|
||||
* → [EMOJI "✅"], [PLAIN " "], [BOLD "Frage"], [PLAIN " ist "], [ITALIC "kursiv"]
|
||||
*/
|
||||
static List<Seg> parseSegments(String text) {
|
||||
List<Seg> out = new ArrayList<>();
|
||||
if (text == null || text.isEmpty()) return out;
|
||||
int pos = 0;
|
||||
while (pos < text.length()) {
|
||||
// **bold**
|
||||
if (text.startsWith("**", pos)) {
|
||||
int end = text.indexOf("**", pos + 2);
|
||||
if (end > pos + 1) {
|
||||
out.add(new Seg(SegType.BOLD, text.substring(pos + 2, end)));
|
||||
pos = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// *italic* (not part of **)
|
||||
if (text.charAt(pos) == '*'
|
||||
&& (pos + 1 >= text.length() || text.charAt(pos + 1) != '*')) {
|
||||
int end = text.indexOf('*', pos + 1);
|
||||
if (end > pos && (end + 1 >= text.length() || text.charAt(end + 1) != '*')) {
|
||||
out.add(new Seg(SegType.ITALIC, text.substring(pos + 1, end)));
|
||||
pos = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bekanntes Report-Emoji
|
||||
int cp = text.codePointAt(pos);
|
||||
if (isReportEmoji(cp)) {
|
||||
StringBuilder eb = new StringBuilder();
|
||||
while (pos < text.length()) {
|
||||
int c = text.codePointAt(pos);
|
||||
if (!isReportEmoji(c) && c != 0xFE0F && c != 0x200D) break;
|
||||
eb.appendCodePoint(c);
|
||||
pos += Character.charCount(c);
|
||||
}
|
||||
out.add(new Seg(SegType.EMOJI, eb.toString()));
|
||||
continue;
|
||||
}
|
||||
// Klartext bis zum nächsten Marker
|
||||
int start = pos;
|
||||
while (pos < text.length()) {
|
||||
int c = text.codePointAt(pos);
|
||||
if (isReportEmoji(c) || text.charAt(pos) == '*') break;
|
||||
pos += Character.charCount(c);
|
||||
}
|
||||
if (pos > start) out.add(new Seg(SegType.PLAIN, text.substring(start, pos)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DOCX-Export via Apache POI
|
||||
// =========================================================================
|
||||
//
|
||||
// Aufbau:
|
||||
// toDocx()
|
||||
// └── Hauptschleife über Zeilen
|
||||
// ├── addHeading() → XWPFParagraph mit Heading-Style + Run
|
||||
// ├── addParagraph() → renderInline() → ein Run pro Segment
|
||||
// ├── addHorizontalRule()→ Paragraph mit Strich-Zeichen
|
||||
// ├── addBlockquote() → eingerückter Paragraph + grauer Hintergrund
|
||||
// └── addTable() → XWPFTable; Zellen via renderInline()
|
||||
//
|
||||
// Inline-Formatierung (renderInline):
|
||||
// parseSegments() liefert die Segmente; für jedes wird ein eigener XWPFRun
|
||||
// erzeugt und run.setBold() / run.setItalic() gesetzt.
|
||||
// EMOJI-Segmente landen als Rohtext im Run (Word kann die Codepoints direkt anzeigen).
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Erzeugt ein DOCX-Dokument aus dem Markdown-String.
|
||||
* \r wird vorab entfernt (Oracle CLOBs liefern \r\n-Zeilenenden).
|
||||
*/
|
||||
public byte[] toDocx(String markdown) throws Exception {
|
||||
try (XWPFDocument doc = new XWPFDocument()) {
|
||||
String[] lines = markdown.replace("\r", "").split("\n", -1);
|
||||
int i = 0;
|
||||
while (i < lines.length) {
|
||||
String line = lines[i];
|
||||
|
||||
if (line.startsWith("#### ")) {
|
||||
addHeading(doc, line.substring(5).trim(), 4);
|
||||
} else if (line.startsWith("### ")) {
|
||||
addHeading(doc, line.substring(4).trim(), 3);
|
||||
} else if (line.startsWith("## ")) {
|
||||
addHeading(doc, line.substring(3).trim(), 2);
|
||||
} else if (line.startsWith("# ")) {
|
||||
addHeading(doc, line.substring(2).trim(), 1);
|
||||
} else if (line.equals("---")) {
|
||||
addHorizontalRule(doc);
|
||||
} else if (line.startsWith("> ")) {
|
||||
addBlockquote(doc, line.substring(2).trim());
|
||||
} else if (line.startsWith("|")) {
|
||||
// Tabellenzeilen sammeln bis die Pipe-Sequenz endet
|
||||
List<String[]> rows = new ArrayList<>();
|
||||
while (i < lines.length && lines[i].startsWith("|")) {
|
||||
if (!isTableSeparator(lines[i])) {
|
||||
String[] parts = lines[i].split("\\|", -1);
|
||||
List<String> cells = new ArrayList<>();
|
||||
for (int p = 1; p < parts.length - 1; p++)
|
||||
cells.add(parts[p].trim());
|
||||
if (!cells.isEmpty()) rows.add(cells.toArray(new String[0]));
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (!rows.isEmpty()) addTable(doc, rows);
|
||||
continue; // i wurde schon inkrementiert
|
||||
} else if (!line.isBlank()) {
|
||||
addParagraph(doc, line);
|
||||
}
|
||||
// Leerzeile erzeugt implizit Abstand durch fehlenden Paragraph-Inhalt
|
||||
i++;
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.write(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/** Trennzeilen wie |:---|:---| überspringen; müssen Bindestriche enthalten. */
|
||||
private static boolean isTableSeparator(String line) {
|
||||
return line.matches("\\|[-: |]+\\|") && line.contains("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt eine Überschrift hinzu.
|
||||
* Setzt den Word-Style "Heading1"–"Heading4" und wählt die Schriftgröße:
|
||||
* Level 1 = 22pt, 2 = 18pt, 3 = 14pt, 4 = 12pt.
|
||||
*/
|
||||
private void addHeading(XWPFDocument doc, String text, int level) {
|
||||
XWPFParagraph p = doc.createParagraph();
|
||||
p.setStyle("Heading" + level);
|
||||
XWPFRun run = p.createRun();
|
||||
run.setText(text);
|
||||
run.setBold(true);
|
||||
run.setFontSize(switch (level) { case 1 -> 22; case 2 -> 18; case 3 -> 14; default -> 12; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt einen normalen Absatz mit Inline-Formatierung hinzu.
|
||||
* Delegiert an renderInline(), das Bold/Italic/Emoji korrekt als separate Runs setzt.
|
||||
*/
|
||||
private void addParagraph(XWPFDocument doc, String text) {
|
||||
renderInline(doc.createParagraph(), text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert Inline-Formatierung in einen vorhandenen Paragraph.
|
||||
* parseSegments() liefert die Segmente; jedes bekommt einen eigenen XWPFRun
|
||||
* mit setBold()/setItalic(). Emoji-Codepoints landen als Rohtext (Word rendert sie nativ).
|
||||
*/
|
||||
private void renderInline(XWPFParagraph p, String text) {
|
||||
for (Seg seg : parseSegments(text)) {
|
||||
XWPFRun run = p.createRun();
|
||||
run.setText(seg.text());
|
||||
if (seg.type() == SegType.BOLD) run.setBold(true);
|
||||
if (seg.type() == SegType.ITALIC) run.setItalic(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt eine visuelle Trennlinie ein (Dashes-Zeichen, kein echtes OOXML-Border).
|
||||
*/
|
||||
private void addHorizontalRule(XWPFDocument doc) {
|
||||
doc.createParagraph().createRun()
|
||||
.setText("──────────────────────────────────────────────");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt einen eingerückten Blockquote-Absatz ein.
|
||||
* Einrückung: 720 Twips (= 1,27 cm), Hintergrund: #F0F0F0, Text kursiv.
|
||||
*/
|
||||
private void addBlockquote(XWPFDocument doc, String text) {
|
||||
XWPFParagraph p = doc.createParagraph();
|
||||
p.setIndentationLeft(720);
|
||||
try {
|
||||
CTShd shd = p.getCTP().addNewPPr().addNewShd();
|
||||
shd.setVal(STShd.CLEAR); shd.setColor("auto"); shd.setFill("F0F0F0");
|
||||
} catch (Exception ignored) {}
|
||||
XWPFRun run = p.createRun();
|
||||
run.setItalic(true);
|
||||
run.setText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt eine Tabelle ein.
|
||||
* Wichtig: Zellen werden über renderInline() befüllt, nicht über cell.setText(),
|
||||
* damit Bold/Italic innerhalb von Tabellenzellen korrekt angewandt werden.
|
||||
*/
|
||||
private void addTable(XWPFDocument doc, List<String[]> rows) {
|
||||
if (rows.isEmpty()) return;
|
||||
int cols = rows.get(0).length;
|
||||
XWPFTable table = doc.createTable(rows.size(), cols);
|
||||
table.setWidth("100%");
|
||||
for (int r = 0; r < rows.size(); r++) {
|
||||
XWPFTableRow row = table.getRow(r);
|
||||
String[] cells = rows.get(r);
|
||||
for (int c = 0; c < cols; c++) {
|
||||
XWPFTableCell cell = c < row.getTableCells().size()
|
||||
? row.getCell(c) : row.addNewTableCell();
|
||||
List<XWPFParagraph> paras = cell.getParagraphs();
|
||||
XWPFParagraph para = paras.isEmpty() ? cell.addParagraph() : paras.get(0);
|
||||
// Vorhandene (leere) Runs entfernen bevor renderInline neue anlegt
|
||||
while (!para.getRuns().isEmpty()) para.removeRun(0);
|
||||
if (c < cells.length) renderInline(para, cells[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PDF-Export via PDFBox
|
||||
// =========================================================================
|
||||
//
|
||||
// Koordinatensystem: PDFBox nutzt PDF-Koordinaten (0,0 = unten links).
|
||||
// y startet bei PAGE_HEIGHT - MARGIN (oben) und läuft nach unten.
|
||||
// newPage() setzt y zurück auf PAGE_HEIGHT - MARGIN.
|
||||
//
|
||||
// Aufrufkette für eine normale Zeile:
|
||||
// write()
|
||||
// └── renderLine() zerlegt und bricht Zeile um
|
||||
// ├── writeWrappedText() reiner Text ohne Formatierung
|
||||
// │ └── flushText() schreibt eine Textzeile auf den Stream
|
||||
// └── splitWords() + flushInlineLine() für gemischte Segmente
|
||||
// └── renderSegsAt() schreibt Segmente inline nebeneinander
|
||||
// ├── cs.showText() für PLAIN/BOLD/ITALIC
|
||||
// └── cs.drawImage() für EMOJI (aus emojiImage())
|
||||
//
|
||||
// Emoji-Rendering:
|
||||
// Java AWT im headless-Modus kann NotoColorEmoji (COLR v1) nicht rendern.
|
||||
// Lösung: emojiImage() zeichnet die Icons als Java2D-Shapes in ein
|
||||
// BufferedImage und gibt es als PDImageXObject zurück.
|
||||
// =========================================================================
|
||||
|
||||
private static final float MARGIN = 50f;
|
||||
private static final float LINE_HEIGHT = 14f;
|
||||
private static final float PAGE_WIDTH = PDRectangle.A4.getWidth();
|
||||
private static final float PAGE_HEIGHT = PDRectangle.A4.getHeight();
|
||||
private static final float TEXT_WIDTH = PAGE_WIDTH - 2 * MARGIN;
|
||||
|
||||
/**
|
||||
* Erzeugt ein PDF aus dem Markdown-String.
|
||||
* \r wird vorab entfernt (Oracle CLOBs liefern \r\n; PDType1Font akzeptiert
|
||||
* nur Latin-1 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<Seg> segs, float startX, float baseline, float size,
|
||||
float emojiSize) throws Exception {
|
||||
float x = startX;
|
||||
for (Seg seg : segs) {
|
||||
if (seg.type() == SegType.EMOJI) {
|
||||
PDImageXObject img = emojiImage(seg.text(), emojiSize);
|
||||
float imgH = emojiSize;
|
||||
float imgW = imgH * img.getWidth() / (float) img.getHeight();
|
||||
// 0.40f: Bild leicht über die Baseline heben (vertikal zentriert zum Text)
|
||||
cs.drawImage(img, x, baseline - imgH * 0.40f, imgW, imgH);
|
||||
x += imgW + 1;
|
||||
} else {
|
||||
String txt = safe(seg.text());
|
||||
if (!txt.isEmpty()) {
|
||||
PDType1Font f = fontFor(seg.type());
|
||||
cs.beginText();
|
||||
cs.setFont(f, size);
|
||||
cs.newLineAtOffset(x, baseline);
|
||||
cs.showText(txt);
|
||||
cs.endText();
|
||||
x += f.getStringWidth(txt) / 1000f * size;
|
||||
}
|
||||
}
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert eine Markdown-Zeile mit Zeilenumbruch.
|
||||
*
|
||||
* Zwei Pfade:
|
||||
* a) Nur PLAIN-Segmente → writeWrappedText() (einfacher, schneller Pfad)
|
||||
* b) Gemischt (Bold/Italic/Emoji) → splitWords() zerlegt in Wort-Einheiten,
|
||||
* dann werden diese zu Zeilen gepackt und mit flushInlineLine() geschrieben.
|
||||
*
|
||||
* indent: horizontaler Einzug in Punkten (z.B. 20 für Blockquotes).
|
||||
*/
|
||||
void renderLine(String text, PDType1Font defaultFont, float size, float indent)
|
||||
throws Exception {
|
||||
List<Seg> segs = parseSegments(text);
|
||||
boolean onlyPlain = segs.stream().allMatch(s -> s.type() == SegType.PLAIN);
|
||||
if (onlyPlain) {
|
||||
writeWrappedText(defaultFont, size, indent, text);
|
||||
return;
|
||||
}
|
||||
|
||||
float emojiPt = size * 1.5f;
|
||||
float maxW = TEXT_WIDTH - indent;
|
||||
|
||||
List<List<Seg>> words = splitWords(segs);
|
||||
List<List<Seg>> line = new ArrayList<>();
|
||||
float lineW = 0;
|
||||
|
||||
for (List<Seg> word : words) {
|
||||
float ww = 0;
|
||||
for (Seg s : word) ww += segWidth(s, s.type() == SegType.EMOJI ? emojiPt : size);
|
||||
|
||||
if (!line.isEmpty() && lineW + ww > maxW) {
|
||||
flushInlineLine(line, size, emojiPt, indent);
|
||||
line = new ArrayList<>();
|
||||
lineW = 0;
|
||||
// Führendes Leerzeichen am Zeilenanfang überspringen
|
||||
if (word.size() == 1 && word.get(0).type() == SegType.PLAIN
|
||||
&& word.get(0).text().isBlank()) continue;
|
||||
}
|
||||
line.add(word);
|
||||
lineW += ww;
|
||||
}
|
||||
if (!line.isEmpty()) flushInlineLine(line, size, emojiPt, indent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt eine fertig zusammengestellte Inline-Zeile auf den PDF-Stream.
|
||||
* Berechnet die Zeilenhöhe als Maximum aus LINE_HEIGHT und Emoji-Größe,
|
||||
* ruft ensureSpace() für den Seitenumbruch und delegiert an renderSegsAt().
|
||||
*/
|
||||
private void flushInlineLine(List<List<Seg>> words, float size, float emojiPt,
|
||||
float indent) throws Exception {
|
||||
float lineH = Math.max(LINE_HEIGHT, emojiPt * 1.05f);
|
||||
ensureSpace(lineH);
|
||||
List<Seg> flat = new ArrayList<>();
|
||||
for (List<Seg> w : words) flat.addAll(w);
|
||||
renderSegsAt(flat, MARGIN + indent, y, size, emojiPt);
|
||||
y -= lineH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt eine Liste von Segmenten in "Wörter" (für den Zeilenumbruch).
|
||||
*
|
||||
* Regeln:
|
||||
* - EMOJI: jedes Emoji ist ein eigenes Wort (kein Umbruch innerhalb)
|
||||
* - PLAIN/BOLD/ITALIC: an Leerzeichen aufteilen; jedes Leerzeichen
|
||||
* wird als eigenes PLAIN-" "-Wort eingefügt (Trennstelle beim Umbrechen)
|
||||
*/
|
||||
private List<List<Seg>> splitWords(List<Seg> segs) {
|
||||
List<List<Seg>> words = new ArrayList<>();
|
||||
List<Seg> cur = new ArrayList<>();
|
||||
for (Seg seg : segs) {
|
||||
if (seg.type() == SegType.EMOJI) {
|
||||
if (!cur.isEmpty()) { words.add(cur); cur = new ArrayList<>(); }
|
||||
words.add(List.of(seg));
|
||||
continue;
|
||||
}
|
||||
String txt = seg.text();
|
||||
int start = 0;
|
||||
for (int i = 0; i <= txt.length(); i++) {
|
||||
if (i == txt.length() || txt.charAt(i) == ' ') {
|
||||
if (i > start)
|
||||
cur.add(new Seg(seg.type(), txt.substring(start, i)));
|
||||
if (i < txt.length()) {
|
||||
if (!cur.isEmpty()) { words.add(cur); cur = new ArrayList<>(); }
|
||||
words.add(List.of(new Seg(SegType.PLAIN, " ")));
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (start < txt.length())
|
||||
cur.add(new Seg(seg.type(), txt.substring(start)));
|
||||
}
|
||||
if (!cur.isEmpty()) words.add(cur);
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wortumbruch für reinen Text (keine Inline-Formatierung).
|
||||
* Misst jedes Wort mit getStringWidth(); bricht um wenn die Zeile voll ist.
|
||||
* Schreibt jede fertige Zeile via flushText().
|
||||
*/
|
||||
void writeWrappedText(PDType1Font font, float size, float indent, String text)
|
||||
throws Exception {
|
||||
float maxW = TEXT_WIDTH - indent;
|
||||
String[] words = text.split(" ", -1);
|
||||
StringBuilder cur = new StringBuilder();
|
||||
for (String w : words) {
|
||||
String candidate = cur.isEmpty() ? w : cur + " " + w;
|
||||
float width;
|
||||
try { width = font.getStringWidth(safe(candidate)) / 1000f * size; }
|
||||
catch (Exception e) { width = maxW + 1; }
|
||||
if (width > maxW && !cur.isEmpty()) {
|
||||
flushText(font, size, indent, cur.toString());
|
||||
cur = new StringBuilder(w);
|
||||
} else {
|
||||
cur = new StringBuilder(candidate);
|
||||
}
|
||||
}
|
||||
if (!cur.isEmpty()) flushText(font, size, indent, cur.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt eine einzelne Textzeile auf den PDF-Content-Stream.
|
||||
* Ruft ensureSpace() → ggf. newPage() vor dem Schreiben.
|
||||
* Dekrementiert y um LINE_HEIGHT danach.
|
||||
*/
|
||||
void flushText(PDType1Font font, float size, float indent, String text) throws Exception {
|
||||
ensureSpace(LINE_HEIGHT);
|
||||
cs.beginText();
|
||||
cs.setFont(font, size);
|
||||
cs.newLineAtOffset(MARGIN + indent, y);
|
||||
cs.showText(safe(text));
|
||||
cs.endText();
|
||||
y -= LINE_HEIGHT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert eine Tabelle ins PDF.
|
||||
* Spaltenbreite = TEXT_WIDTH / Anzahl Spalten (gleichmäßig verteilt).
|
||||
* Zellen werden inline via renderSegsAt() geschrieben — Emoji erscheinen als Icons.
|
||||
* Kein Rahmen (PDFBox-Rahmen wären aufwendiger und nicht nötig für Report-Tabellen).
|
||||
*/
|
||||
void renderTable(List<String[]> rows) throws Exception {
|
||||
if (rows.isEmpty()) return;
|
||||
int maxCols = rows.stream().mapToInt(r -> r.length).max().orElse(1);
|
||||
float colW = TEXT_WIDTH / maxCols;
|
||||
float rowH = LINE_HEIGHT + 8;
|
||||
|
||||
for (String[] row : rows) {
|
||||
ensureSpace(rowH);
|
||||
float cx = MARGIN;
|
||||
for (int c = 0; c < maxCols; c++) {
|
||||
String cell = c < row.length ? row[c].trim() : "";
|
||||
List<Seg> segs = parseSegments(cell);
|
||||
float emojiPt = rowH * 0.85f;
|
||||
renderSegsAt(segs, cx + 2, y, 10, emojiPt);
|
||||
cx += colW;
|
||||
}
|
||||
y -= rowH;
|
||||
}
|
||||
skip(2);
|
||||
}
|
||||
|
||||
// ── Hauptschleife ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* Verarbeitet den Markdown-String Zeile für Zeile und baut das PDF auf.
|
||||
*
|
||||
* Dispatching:
|
||||
* "#### " / "### " / "## " / "# "
|
||||
* → skip() (Abstand davor) + renderLine() mit Bold + größerer Schrift
|
||||
* + skip() (Abstand danach)
|
||||
* "---"
|
||||
* → skip(4) + horizontale Linie direkt auf Content-Stream (moveTo/lineTo/stroke)
|
||||
* + y -= 6
|
||||
* "> text"
|
||||
* → renderLine() mit Italic-Font, 20pt Einzug
|
||||
* "|..."
|
||||
* → Zeilen in tableAccum sammeln; sobald keine Pipe-Zeile mehr kommt
|
||||
* → renderTable(); Trenner-Zeilen (|---|) werden übersprungen
|
||||
* Leerzeile
|
||||
* → skip(4): 4pt vertikaler Abstand
|
||||
* Sonstiger Text
|
||||
* → renderLine() mit Normal-Font, 11pt
|
||||
*/
|
||||
void write(String markdown) throws Exception {
|
||||
String[] lines = markdown.split("\n", -1);
|
||||
List<String[]> tableAccum = new ArrayList<>();
|
||||
|
||||
for (String line : lines) {
|
||||
// Tabellenpuffer flushen sobald eine Nicht-Pipe-Zeile kommt
|
||||
if (!line.startsWith("|") && !tableAccum.isEmpty()) {
|
||||
renderTable(tableAccum);
|
||||
tableAccum.clear();
|
||||
}
|
||||
|
||||
if (line.startsWith("#### ")) {
|
||||
skip(4); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(2);
|
||||
} else if (line.startsWith("### ")) {
|
||||
skip(6); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(3);
|
||||
} else if (line.startsWith("## ")) {
|
||||
skip(8); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(4);
|
||||
} else if (line.startsWith("# ")) {
|
||||
skip(10); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(6);
|
||||
} else if (line.equals("---")) {
|
||||
skip(4);
|
||||
ensureSpace(2);
|
||||
cs.setLineWidth(0.5f);
|
||||
cs.moveTo(MARGIN, y);
|
||||
cs.lineTo(PAGE_WIDTH - MARGIN, y);
|
||||
cs.stroke();
|
||||
y -= 6;
|
||||
} else if (line.startsWith("> ")) {
|
||||
renderLine(line.substring(2).trim(), fItalic, 10, 20);
|
||||
} else if (line.startsWith("|")) {
|
||||
if (!isTableSeparator(line)) {
|
||||
String[] parts = line.split("\\|", -1);
|
||||
List<String> cells = new ArrayList<>();
|
||||
for (int p = 1; p < parts.length - 1; p++)
|
||||
cells.add(parts[p].trim());
|
||||
if (!cells.isEmpty()) tableAccum.add(cells.toArray(new String[0]));
|
||||
}
|
||||
} else if (line.isBlank()) {
|
||||
skip(4);
|
||||
} else {
|
||||
renderLine(line, fNorm, 11, 0);
|
||||
}
|
||||
}
|
||||
if (!tableAccum.isEmpty()) renderTable(tableAccum);
|
||||
cs.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,8 @@ quarkus.log.category."de.frigosped".level=DEBUG
|
||||
# KI-Kommunikation mitloggen (nur in Dev)
|
||||
%dev.dc.ai.log-requests=true
|
||||
%dev.dc.ai.log-responses=true
|
||||
%prod.dc.ai.log-requests=false
|
||||
%prod.dc.ai.log-responses=false
|
||||
%prod.dc.ai.log-requests=true
|
||||
%prod.dc.ai.log-responses=true
|
||||
|
||||
# =============================================================================
|
||||
# Jackson: snake_case für ORDS-Responses
|
||||
|
||||
66
dc-backend/test-run.sh
Normal file
66
dc-backend/test-run.sh
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setzt Projekt auf Ausgangszustand (status=NULL) und triggert POST /check/{id}
|
||||
# Verwendung: bash test-run.sh [projectId]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ID="${1:-1}"
|
||||
BACKEND="http://localhost:8090"
|
||||
DB_USER="wksp_ai"
|
||||
DB_PASS='s!)löyx209aasgtrasdasiudkash5235FDSXljLJ'
|
||||
DB_HOST="10.75.10.171"
|
||||
DB_PORT="32710"
|
||||
DB_SID="freepdb1"
|
||||
KUBECONFIG_FILE="$(cd "$(dirname "$0")/.." && pwd)/env/frigo-dev.yaml"
|
||||
|
||||
echo "=== Test-Run: Projekt $PROJECT_ID ==="
|
||||
|
||||
# 1. Projekt zurücksetzen (status=NULL, progress=0, completed_at=NULL)
|
||||
# Ergebnisse löschen, aber original_text/translated_text BLEIBEN erhalten
|
||||
# → Phase A (OCR) wird beim nächsten Lauf automatisch übersprungen
|
||||
echo ""
|
||||
echo "→ DB-Reset: status=NULL, Ergebnisse löschen (Texte bleiben) ..."
|
||||
sql -S "$DB_USER/$DB_PASS@$DB_HOST:$DB_PORT/$DB_SID" <<SQL
|
||||
DELETE FROM dc_results WHERE project_id = $PROJECT_ID;
|
||||
UPDATE dc_projects
|
||||
SET status = NULL,
|
||||
progress = 0,
|
||||
completed_at = NULL
|
||||
WHERE id = $PROJECT_ID;
|
||||
COMMIT;
|
||||
SELECT id, name, status, catalog_id, progress FROM dc_projects WHERE id = $PROJECT_ID;
|
||||
SQL
|
||||
|
||||
# 2. Port-Forward sicherstellen (im Hintergrund, falls noch nicht aktiv)
|
||||
if ! curl -sf "$BACKEND/check/health" > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "→ Starte Port-Forward localhost:8090 → dc-backend:8090 ..."
|
||||
KUBECONFIG="$KUBECONFIG_FILE" kubectl port-forward \
|
||||
-n ai-env svc/dc-backend 8090:8090 &
|
||||
PF_PID=$!
|
||||
echo " Port-Forward PID: $PF_PID"
|
||||
sleep 3
|
||||
else
|
||||
PF_PID=""
|
||||
echo "→ Port-Forward bereits aktiv."
|
||||
fi
|
||||
|
||||
# 3. Trigger
|
||||
echo ""
|
||||
echo "→ POST $BACKEND/check/$PROJECT_ID ..."
|
||||
RESPONSE=$(curl -sf -X POST "$BACKEND/check/$PROJECT_ID" \
|
||||
-H "Content-Type: application/json" || echo "FEHLER: curl fehlgeschlagen")
|
||||
echo " Response: $RESPONSE"
|
||||
|
||||
# 4. Logs streamen
|
||||
echo ""
|
||||
echo "→ Logs (Ctrl+C zum Beenden):"
|
||||
POD=$(KUBECONFIG="$KUBECONFIG_FILE" kubectl get pod -n ai-env -l app=dc-backend \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
|
||||
KUBECONFIG="$KUBECONFIG_FILE" kubectl logs -f "$POD" -n ai-env --since=10s
|
||||
|
||||
# Cleanup Port-Forward
|
||||
if [ -n "${PF_PID:-}" ]; then
|
||||
kill "$PF_PID" 2>/dev/null || true
|
||||
fi
|
||||
Reference in New Issue
Block a user