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": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(quarkus dev *)",
|
"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
|
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;
|
END DC_BACKEND_PKG;
|
||||||
/
|
/
|
||||||
|
|
||||||
@@ -262,6 +297,93 @@ CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS
|
|||||||
END wait_for_completion;
|
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;
|
END DC_BACKEND_PKG;
|
||||||
/
|
/
|
||||||
|
|
||||||
@@ -294,4 +416,24 @@ BEGIN
|
|||||||
DBMS_OUTPUT.PUT_LINE('Response: ' || l_response);
|
DBMS_OUTPUT.PUT_LINE('Response: ' || l_response);
|
||||||
END;
|
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:
|
-- Endpunkt-Übersicht:
|
||||||
-- 1. GET /api/dc/catalogs/ Alle Fragenkataloge
|
-- 1. GET /api/dc/catalogs/ Alle Fragenkataloge
|
||||||
-- 2. GET /api/dc/catalogs/:id/questions/ Fragen eines Katalogs (flach)
|
-- 2. GET /api/dc/catalogs/:id/questions/ Fragen eines Katalogs (flach, nach order_nr sortiert)
|
||||||
-- 3. GET /api/dc/projects/:id Projektdetail
|
-- 3. GET /api/dc/projects/:id Projektdetail (inkl. Detailstatus + ETA)
|
||||||
-- 4. POST /api/dc/projects/:id/start → IN_PROGRESS
|
-- 4. POST /api/dc/projects/:id/start → IN_PROGRESS (setzt processing_started_at)
|
||||||
-- 5. PUT /api/dc/projects/:id/progress Fortschritt aktualisieren
|
-- 5. PUT /api/dc/projects/:id/progress Fortschritt + Phase/Doc/Question/ETA
|
||||||
-- 6. POST /api/dc/projects/:id/complete → COMPLETED
|
-- 6. POST /api/dc/projects/:id/complete → COMPLETED (setzt current_phase=COMPLETED)
|
||||||
-- 7. GET /api/dc/projects/:id/documents/ Dokument-Liste (kein BLOB)
|
-- 7. GET /api/dc/projects/:id/documents/ Dokument-Liste (kein BLOB)
|
||||||
-- 8. GET /api/dc/documents/:id/file BLOB-Download
|
-- 8. GET /api/dc/documents/:id/file BLOB-Download (PLSQL-Workaround)
|
||||||
-- 9. PUT /api/dc/documents/:id/texts OCR-Text + Übersetzung schreiben
|
-- 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
|
-- 10. POST /api/dc/projects/:id/results Prüfergebnis einfügen
|
||||||
-- 11. DELETE /api/dc/projects/:id/results Alle Ergebnisse löschen
|
-- 11. DELETE /api/dc/projects/:id/results Alle Ergebnisse löschen
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
@@ -115,17 +116,19 @@ BEGIN
|
|||||||
cat.id AS category_id,
|
cat.id AS category_id,
|
||||||
cat.name AS category_name,
|
cat.name AS category_name,
|
||||||
cat.description AS category_description,
|
cat.description AS category_description,
|
||||||
|
cat.order_nr AS category_order_nr,
|
||||||
q.question_text,
|
q.question_text,
|
||||||
q.evaluation_type,
|
q.evaluation_type,
|
||||||
q.threshold,
|
q.threshold,
|
||||||
q.result_handling,
|
q.result_handling,
|
||||||
q.example_0_percent,
|
q.example_0_percent,
|
||||||
q.example_100_percent,
|
q.example_100_percent,
|
||||||
|
q.order_nr,
|
||||||
q.row_version
|
q.row_version
|
||||||
FROM dc_questions q
|
FROM dc_questions q
|
||||||
JOIN dc_question_categories cat ON cat.id = q.category_id
|
JOIN dc_question_categories cat ON cat.id = q.category_id
|
||||||
WHERE cat.catalog_id = :catalog_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_method => 'GET',
|
||||||
p_source_type => ORDS.SOURCE_TYPE_COLLECTION_ITEM,
|
p_source_type => ORDS.SOURCE_TYPE_COLLECTION_ITEM,
|
||||||
p_items_per_page => 1,
|
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'[
|
p_source => q'[
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
@@ -162,7 +165,12 @@ BEGIN
|
|||||||
p.notification_email,
|
p.notification_email,
|
||||||
p.row_version,
|
p.row_version,
|
||||||
p.created,
|
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
|
FROM dc_projects p
|
||||||
WHERE p.id = :project_id
|
WHERE p.id = :project_id
|
||||||
]'
|
]'
|
||||||
@@ -185,7 +193,7 @@ BEGIN
|
|||||||
p_pattern => 'projects/:project_id/start',
|
p_pattern => 'projects/:project_id/start',
|
||||||
p_method => 'POST',
|
p_method => 'POST',
|
||||||
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
|
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'[
|
p_source => q'[
|
||||||
DECLARE
|
DECLARE
|
||||||
v_status dc_projects.status%TYPE;
|
v_status dc_projects.status%TYPE;
|
||||||
@@ -196,16 +204,21 @@ BEGIN
|
|||||||
WHERE id = :project_id
|
WHERE id = :project_id
|
||||||
FOR UPDATE NOWAIT;
|
FOR UPDATE NOWAIT;
|
||||||
|
|
||||||
IF v_status != 'PENDING' THEN
|
IF v_status IN ('IN_PROGRESS', 'COMPLETED') THEN
|
||||||
:status_code := 409;
|
:status_code := 409;
|
||||||
HTP.P('{"error":"Projekt ist nicht im Status PENDING",'
|
HTP.P('{"error":"Projekt ist bereits ' || v_status || '",'
|
||||||
|| '"current_status":"' || v_status || '"}');
|
|| '"current_status":"' || v_status || '"}');
|
||||||
RETURN;
|
RETURN;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
UPDATE dc_projects
|
UPDATE dc_projects
|
||||||
SET status = 'IN_PROGRESS',
|
SET status = 'IN_PROGRESS',
|
||||||
progress = 0
|
progress = 0,
|
||||||
|
processing_started_at = SYSTIMESTAMP,
|
||||||
|
current_phase = 'OCR',
|
||||||
|
current_doc_id = NULL,
|
||||||
|
current_question_id = NULL,
|
||||||
|
estimated_completion_at = NULL
|
||||||
WHERE id = :project_id;
|
WHERE id = :project_id;
|
||||||
|
|
||||||
:status_code := 200;
|
:status_code := 200;
|
||||||
@@ -236,20 +249,30 @@ BEGIN
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- 5. PUT /api/dc/projects/:project_id/progress
|
-- 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(
|
ORDS.DEFINE_HANDLER(
|
||||||
p_module_name => 'frigosped.dc',
|
p_module_name => 'frigosped.dc',
|
||||||
p_pattern => 'projects/:project_id/progress',
|
p_pattern => 'projects/:project_id/progress',
|
||||||
p_method => 'PUT',
|
p_method => 'PUT',
|
||||||
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
|
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'[
|
p_source => q'[
|
||||||
DECLARE
|
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
|
BEGIN
|
||||||
v_body := :body_text;
|
v_body := :body_text;
|
||||||
v_progress := TO_NUMBER(JSON_VALUE(v_body, '$.progress'));
|
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
|
IF v_progress IS NULL OR v_progress < 0 OR v_progress > 100 THEN
|
||||||
:status_code := 400;
|
:status_code := 400;
|
||||||
@@ -257,8 +280,20 @@ BEGIN
|
|||||||
RETURN;
|
RETURN;
|
||||||
END IF;
|
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
|
UPDATE dc_projects
|
||||||
SET progress = v_progress
|
SET progress = v_progress,
|
||||||
|
current_phase = NVL(v_phase, current_phase),
|
||||||
|
current_doc_id = v_doc_id,
|
||||||
|
current_question_id = v_question_id,
|
||||||
|
estimated_completion_at = NVL(v_eta, estimated_completion_at)
|
||||||
WHERE id = :project_id;
|
WHERE id = :project_id;
|
||||||
|
|
||||||
IF SQL%ROWCOUNT = 0 THEN
|
IF SQL%ROWCOUNT = 0 THEN
|
||||||
@@ -300,7 +335,7 @@ BEGIN
|
|||||||
p_pattern => 'projects/:project_id/complete',
|
p_pattern => 'projects/:project_id/complete',
|
||||||
p_method => 'POST',
|
p_method => 'POST',
|
||||||
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
|
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'[
|
p_source => q'[
|
||||||
DECLARE
|
DECLARE
|
||||||
v_status dc_projects.status%TYPE;
|
v_status dc_projects.status%TYPE;
|
||||||
@@ -321,9 +356,19 @@ BEGIN
|
|||||||
UPDATE dc_projects
|
UPDATE dc_projects
|
||||||
SET status = 'COMPLETED',
|
SET status = 'COMPLETED',
|
||||||
completed_at = v_now,
|
completed_at = v_now,
|
||||||
progress = 100
|
progress = 100,
|
||||||
|
current_phase = 'COMPLETED',
|
||||||
|
current_doc_id = NULL,
|
||||||
|
current_question_id = NULL,
|
||||||
|
estimated_completion_at = NULL
|
||||||
WHERE id = :project_id;
|
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;
|
:status_code := 200;
|
||||||
HTP.P('{"project_id":' || :project_id
|
HTP.P('{"project_id":' || :project_id
|
||||||
|| ',"status":"COMPLETED"'
|
|| ',"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/
|
-- TEMPLATE 7: projects/:project_id/documents/
|
||||||
@@ -393,36 +469,78 @@ BEGIN
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- 8. GET /api/dc/documents/:doc_id/file
|
-- 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(
|
ORDS.DEFINE_HANDLER(
|
||||||
p_module_name => 'frigosped.dc',
|
p_module_name => 'frigosped.dc',
|
||||||
p_pattern => 'documents/:doc_id/file',
|
p_pattern => 'documents/:doc_id/file',
|
||||||
p_method => 'GET',
|
p_method => 'GET',
|
||||||
p_source_type => ORDS.SOURCE_TYPE_MEDIA,
|
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
|
||||||
p_comments => 'Streamt original_file BLOB; ORDS setzt Content-Type aus mime_type-Spalte',
|
p_comments => 'Streamt original_file BLOB mit korrektem Content-Type (PLSQL-Workaround fuer ORDS-Bug)',
|
||||||
p_source => q'[
|
p_source => q'[
|
||||||
SELECT
|
DECLARE
|
||||||
d.original_file AS content,
|
v_blob BLOB;
|
||||||
d.mime_type AS content_type,
|
v_mime VARCHAR2(200);
|
||||||
d.filename AS filename,
|
v_filename VARCHAR2(500);
|
||||||
d.updated AS last_modified
|
BEGIN
|
||||||
FROM dc_project_documents d
|
SELECT original_file, mime_type, filename
|
||||||
WHERE d.id = :doc_id
|
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(
|
ORDS.DEFINE_TEMPLATE(
|
||||||
p_module_name => 'frigosped.dc',
|
p_module_name => 'frigosped.dc',
|
||||||
p_pattern => 'documents/:doc_id/texts',
|
p_pattern => 'documents/:doc_id/texts',
|
||||||
p_priority => 0,
|
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": "## ..."}
|
-- Body: {"original_text": "## ...", "translated_text": "## ..."}
|
||||||
-- Beide Felder sind optional: NULL-Wert ueberschreibt nicht (nur wenn im Body vorhanden)
|
-- Beide Felder sind optional: NULL-Wert ueberschreibt nicht (nur wenn im Body vorhanden)
|
||||||
ORDS.DEFINE_HANDLER(
|
ORDS.DEFINE_HANDLER(
|
||||||
@@ -566,12 +684,6 @@ BEGIN
|
|||||||
)
|
)
|
||||||
RETURNING id INTO v_new_id;
|
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;
|
:status_code := 201;
|
||||||
HTP.P('{"id":' || v_new_id
|
HTP.P('{"id":' || v_new_id
|
||||||
|| ',"project_id":' || :project_id
|
|| ',"project_id":' || :project_id
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/bash
|
||||||
export ANTHROPIC_AUTH_TOKEN="ollama"
|
export ANTHROPIC_AUTH_TOKEN="ollama"
|
||||||
export ANTHROPIC_API_KEY=""
|
export ANTHROPIC_API_KEY=""
|
||||||
export ANTHROPIC_BASE_URL="http://gx10.aquantico.lan:11434"
|
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
|
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
|
# Quarkus Fast-JAR Layout kopieren
|
||||||
COPY --from=build /build/target/quarkus-app/lib/ ./lib/
|
COPY --from=build /build/target/quarkus-app/lib/ ./lib/
|
||||||
COPY --from=build /build/target/quarkus-app/*.jar ./
|
COPY --from=build /build/target/quarkus-app/*.jar ./
|
||||||
@@ -36,7 +42,7 @@ EXPOSE 8090
|
|||||||
# DC_AI_OCR_MODEL OCR-Modell
|
# DC_AI_OCR_MODEL OCR-Modell
|
||||||
# QUARKUS_REST_CLIENT_ORDS_API_URL ORDS-Basis-URL
|
# 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 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
CMD curl -sf http://localhost:8090/check/health || exit 1
|
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:
|
env:
|
||||||
# ORDS-Verbindung (Service im selben Namespace)
|
# ORDS-Verbindung (Service im selben Namespace)
|
||||||
- name: QUARKUS_REST_CLIENT_ORDS_API_URL
|
- 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)
|
# API-Key für eingehende Anfragen (DC_API_KEY)
|
||||||
- name: DC_API_KEY
|
- name: DC_API_KEY
|
||||||
valueFrom:
|
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.annotation.ClientHeaderParam;
|
||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
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.
|
* 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}")
|
* → @ClientHeaderParam(name = "Authorization", value = "${dc.ords.bearer-token}")
|
||||||
*/
|
*/
|
||||||
@RegisterRestClient(configKey = "ords-api")
|
@RegisterRestClient(configKey = "ords-api")
|
||||||
|
@RegisterProvider(OrdsLoggingFilter.class)
|
||||||
@Path("/api/dc")
|
@Path("/api/dc")
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -94,6 +97,14 @@ public interface OrdsClient {
|
|||||||
@Produces(MediaType.WILDCARD)
|
@Produces(MediaType.WILDCARD)
|
||||||
Response downloadFile(@PathParam("docId") long docId);
|
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
|
* PUT /api/dc/documents/:docId/texts
|
||||||
* Schreibt original_text und/oder translated_text zurück.
|
* Schreibt original_text und/oder translated_text zurück.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package de.frigosped.dc.client;
|
|||||||
import jakarta.annotation.Priority;
|
import jakarta.annotation.Priority;
|
||||||
import jakarta.ws.rs.client.ClientRequestContext;
|
import jakarta.ws.rs.client.ClientRequestContext;
|
||||||
import jakarta.ws.rs.client.ClientRequestFilter;
|
import jakarta.ws.rs.client.ClientRequestFilter;
|
||||||
|
import jakarta.ws.rs.client.ClientResponseContext;
|
||||||
import jakarta.ws.rs.client.ClientResponseFilter;
|
import jakarta.ws.rs.client.ClientResponseFilter;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.ext.Provider;
|
import jakarta.ws.rs.ext.Provider;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import java.io.ByteArrayInputStream;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loggt jede HTTP-Anfrage und -Antwort des ORDS REST Clients:
|
* Loggt jede HTTP-Anfrage und -Antwort des ORDS REST Clients:
|
||||||
@@ -20,7 +21,7 @@ import java.util.Arrays;
|
|||||||
* - Response-Status + Body
|
* - Response-Status + Body
|
||||||
*/
|
*/
|
||||||
@Provider
|
@Provider
|
||||||
@Priority(Javax.ws.rs.Priorities.AUTHENTICATION + 1)
|
@Priority(jakarta.ws.rs.Priorities.AUTHENTICATION + 1)
|
||||||
public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFilter {
|
public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFilter {
|
||||||
|
|
||||||
private static final Logger LOG = Logger.getLogger(OrdsLoggingFilter.class);
|
private static final Logger LOG = Logger.getLogger(OrdsLoggingFilter.class);
|
||||||
@@ -62,13 +63,7 @@ public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFil
|
|||||||
} else {
|
} else {
|
||||||
body = entity.toString();
|
body = entity.toString();
|
||||||
}
|
}
|
||||||
if (body.isBlank()) return null;
|
return body.isBlank() ? null : body;
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String readResponse(ClientResponseContext ctx) throws IOException {
|
private String readResponse(ClientResponseContext ctx) throws IOException {
|
||||||
@@ -81,7 +76,7 @@ public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFil
|
|||||||
ctx.setEntityStream(new ByteArrayInputStream(raw));
|
ctx.setEntityStream(new ByteArrayInputStream(raw));
|
||||||
|
|
||||||
if (raw.length > 4000) {
|
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() : "";
|
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 Long categoryId;
|
||||||
private String categoryName;
|
private String categoryName;
|
||||||
private String categoryDescription;
|
private String categoryDescription;
|
||||||
|
private Integer categoryOrderNr;
|
||||||
private String questionText;
|
private String questionText;
|
||||||
private String evaluationType; // z.B. SCORE, BINARY
|
private String evaluationType; // z.B. SCORE, BINARY
|
||||||
private Integer threshold; // Schwellwert 0–100
|
private Integer threshold; // Schwellwert 0–100
|
||||||
private String resultHandling; // ABWEICHUNG | HINWEIS
|
private String resultHandling; // ABWEICHUNG | HINWEIS
|
||||||
private String example0Percent; // Beispiel: Anforderung nicht erfüllt
|
private String example0Percent; // Beispiel: Anforderung nicht erfüllt
|
||||||
private String example100Percent; // Beispiel: Anforderung voll erfüllt
|
private String example100Percent; // Beispiel: Anforderung voll erfüllt
|
||||||
|
private Integer orderNr;
|
||||||
private Integer rowVersion;
|
private Integer rowVersion;
|
||||||
|
|
||||||
public OrdsQuestion() {}
|
public OrdsQuestion() {}
|
||||||
@@ -50,6 +52,12 @@ public class OrdsQuestion {
|
|||||||
public String getExample100Percent() { return example100Percent; }
|
public String getExample100Percent() { return example100Percent; }
|
||||||
public void setExample100Percent(String v) { this.example100Percent = v; }
|
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 Integer getRowVersion() { return rowVersion; }
|
||||||
public void setRowVersion(Integer v) { this.rowVersion = v; }
|
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
|
* 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 {
|
public class ProgressRequest {
|
||||||
|
|
||||||
private Integer progress;
|
private Integer progress;
|
||||||
|
private String currentPhase;
|
||||||
|
private Long currentDocId;
|
||||||
|
private Long currentQuestionId;
|
||||||
|
private String estimatedCompletionAt;
|
||||||
|
|
||||||
public ProgressRequest() {}
|
public ProgressRequest() {}
|
||||||
|
|
||||||
@@ -14,6 +26,27 @@ public class ProgressRequest {
|
|||||||
this.progress = progress;
|
this.progress = progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ProgressRequest(int progress, String currentPhase, Long currentDocId,
|
||||||
|
Long currentQuestionId, String estimatedCompletionAt) {
|
||||||
|
this.progress = progress;
|
||||||
|
this.currentPhase = currentPhase;
|
||||||
|
this.currentDocId = currentDocId;
|
||||||
|
this.currentQuestionId = currentQuestionId;
|
||||||
|
this.estimatedCompletionAt = estimatedCompletionAt;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getProgress() { return progress; }
|
public Integer getProgress() { return progress; }
|
||||||
public void setProgress(Integer v) { this.progress = v; }
|
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.eclipse.microprofile.rest.client.inject.RestClient;
|
||||||
import org.jboss.logging.Logger;
|
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.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestriert den kompletten Prüf-Ablauf für ein Projekt:
|
* Orchestriert den kompletten Prüf-Ablauf für ein Projekt:
|
||||||
@@ -18,7 +25,7 @@ import java.util.Map;
|
|||||||
* 1. Projekt laden → IN_PROGRESS setzen
|
* 1. Projekt laden → IN_PROGRESS setzen
|
||||||
* 2. Dokumente herunterladen → OCR / Konvertierung → Texte speichern
|
* 2. Dokumente herunterladen → OCR / Konvertierung → Texte speichern
|
||||||
* 3. Texte ins Deutsche übersetzen → gespeichert
|
* 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
|
* 5. Ergebnisse Frage × Dokument in DB speichern
|
||||||
* 6. Projekt auf COMPLETED setzen
|
* 6. Projekt auf COMPLETED setzen
|
||||||
*
|
*
|
||||||
@@ -28,6 +35,8 @@ import java.util.Map;
|
|||||||
public class CheckOrchestrationService {
|
public class CheckOrchestrationService {
|
||||||
|
|
||||||
private static final Logger LOG = Logger.getLogger(CheckOrchestrationService.class);
|
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
|
@RestClient
|
||||||
OrdsClient ordsClient;
|
OrdsClient ordsClient;
|
||||||
@@ -48,30 +57,27 @@ public class CheckOrchestrationService {
|
|||||||
try {
|
try {
|
||||||
// 1. Projekt laden
|
// 1. Projekt laden
|
||||||
OrdsProject project = ordsClient.getProject(projectId);
|
OrdsProject project = ordsClient.getProject(projectId);
|
||||||
LOG.infof("Projekt: '%s', Status: %s, Katalog: %d",
|
LOG.infof("Projekt: '%s', Status: %s", project.getName(), project.getStatus());
|
||||||
project.getName(), project.getStatus(), project.getCatalogId());
|
|
||||||
|
|
||||||
// Absicherung: nur PENDING starten
|
// Nur abbrechen wenn bereits aktiv oder fertig (null = neues Projekt)
|
||||||
if (!"PENDING".equals(project.getStatus())) {
|
String status = project.getStatus();
|
||||||
LOG.warnf("Projekt %d ist nicht PENDING (ist: %s) – Abbruch",
|
if ("IN_PROGRESS".equals(status) || "COMPLETED".equals(status)) {
|
||||||
projectId, project.getStatus());
|
LOG.warnf("Projekt %d ist bereits %s – Abbruch", projectId, status);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Status → IN_PROGRESS
|
// 2. Status → IN_PROGRESS (setzt auch processing_started_at in ORDS)
|
||||||
Response startResp = ordsClient.startProject(projectId);
|
Response startResp = ordsClient.startProject(projectId);
|
||||||
if (startResp.getStatus() == 409) {
|
if (startResp.getStatus() == 409) {
|
||||||
LOG.warnf("Projekt %d konnte nicht gestartet werden (409) – Abbruch", projectId);
|
LOG.warnf("Projekt %d konnte nicht gestartet werden (409) – Abbruch", projectId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Instant startedAt = Instant.now();
|
||||||
|
|
||||||
// 3. Dokumente + Fragen laden
|
// 3. Dokumente laden
|
||||||
List<OrdsDocument> documents = ordsClient.getDocuments(projectId).getItems();
|
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)",
|
LOG.infof("Projekt %d: %d Dokument(e)", projectId, documents.size());
|
||||||
projectId, documents.size(), questions.size());
|
|
||||||
|
|
||||||
if (documents.isEmpty()) {
|
if (documents.isEmpty()) {
|
||||||
LOG.warnf("Projekt %d hat keine Dokumente – markiere als abgeschlossen", projectId);
|
LOG.warnf("Projekt %d hat keine Dokumente – markiere als abgeschlossen", projectId);
|
||||||
@@ -79,27 +85,64 @@ public class CheckOrchestrationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fortschrittsberechnung
|
// 4. Fragen pro Katalog laden (Katalog ist am Dokument, nicht am Projekt)
|
||||||
// Pro Dokument: 1 Schritt OCR/Übersetzung + questions.size() Auswertungsschritte
|
// Fragen werden nach question_id aufsteigend sortiert.
|
||||||
int totalSteps = documents.size() * (1 + questions.size());
|
Map<Long, List<OrdsQuestion>> questionsByCatalog = new HashMap<>();
|
||||||
int[] stepHolder = {0}; // Array für Verwendung in Lambda
|
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);
|
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<>();
|
Map<Long, String> originalTexts = new HashMap<>();
|
||||||
|
|
||||||
// ─── PHASE A: OCR + Übersetzung ───────────────────────────────
|
// ─── PHASE A: OCR + Übersetzung (wird übersprungen wenn Text bereits vorhanden) ──
|
||||||
for (OrdsDocument doc : documents) {
|
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 originalText = "";
|
||||||
String translatedText = "";
|
|
||||||
|
|
||||||
|
if (Integer.valueOf(1).equals(doc.getHasOriginalText())) {
|
||||||
|
// Resume: Text aus vorherigem Lauf wiederverwenden
|
||||||
|
LOG.infof("Dokument %d: Text bereits vorhanden – überspringe OCR/Übersetzung",
|
||||||
|
doc.getId());
|
||||||
|
try {
|
||||||
|
OrdsDocumentTexts texts = ordsClient.getTexts(doc.getId());
|
||||||
|
originalText = texts.getOriginalText() != null ? texts.getOriginalText() : "";
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warnf("Text-Laden für Dokument %d fehlgeschlagen: %s", doc.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOG.infof("Verarbeite Dokument %d: %s", doc.getId(), doc.getFilename());
|
||||||
|
String translatedText = "";
|
||||||
try {
|
try {
|
||||||
// Datei herunterladen
|
|
||||||
Response fileResp = ordsClient.downloadFile(doc.getId());
|
Response fileResp = ordsClient.downloadFile(doc.getId());
|
||||||
if (fileResp.getStatus() != 200) {
|
if (fileResp.getStatus() != 200) {
|
||||||
LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)",
|
LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)",
|
||||||
@@ -107,51 +150,62 @@ public class CheckOrchestrationService {
|
|||||||
} else {
|
} else {
|
||||||
byte[] fileBytes = fileResp.readEntity(byte[].class);
|
byte[] fileBytes = fileResp.readEntity(byte[].class);
|
||||||
|
|
||||||
// OCR / Konvertierung → Markdown
|
|
||||||
originalText = documentProcessingService.extractText(
|
originalText = documentProcessingService.extractText(
|
||||||
fileBytes, doc.getMimeType(), doc.getFilename());
|
fileBytes, doc.getMimeType(), doc.getFilename());
|
||||||
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
|
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
|
||||||
(long) doc.getId(), (long) originalText.length());
|
(long) doc.getId(), (long) originalText.length());
|
||||||
|
|
||||||
// Übersetzung ins Deutsche
|
|
||||||
translatedText = evaluationService.translate(originalText);
|
translatedText = evaluationService.translate(originalText);
|
||||||
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
|
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
|
||||||
(long) doc.getId(), (long) translatedText.length());
|
(long) doc.getId(), (long) translatedText.length());
|
||||||
|
|
||||||
// Texte in ORDS zurückschreiben
|
|
||||||
ordsClient.updateTexts(doc.getId(),
|
ordsClient.updateTexts(doc.getId(),
|
||||||
new TextsRequest(originalText, translatedText));
|
new TextsRequest(originalText, translatedText));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
|
LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
originalTexts.put(doc.getId(), originalText);
|
originalTexts.put(doc.getId(), originalText);
|
||||||
|
|
||||||
// Fortschritt aktualisieren
|
// Nach dem Schritt: Fortschritt hochzählen + ETA aktualisieren
|
||||||
stepHolder[0]++;
|
stepHolder[0]++;
|
||||||
pushProgress(projectId, stepHolder[0], totalSteps);
|
pushProgress(projectId, stepHolder[0], totalSteps, "OCR",
|
||||||
|
doc.getId(), null, startedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PHASE B: Fragen auswerten ────────────────────────────────
|
// ─── PHASE B: Fragen auswerten ────────────────────────────────
|
||||||
for (OrdsDocument doc : documents) {
|
for (OrdsDocument doc : documents) {
|
||||||
String originalText = originalTexts.getOrDefault(doc.getId(), "");
|
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()) {
|
if (originalText.isBlank()) {
|
||||||
LOG.warnf("Kein Text für Dokument %d – überspringe Auswertung", doc.getId());
|
LOG.warnf("Kein Text für Dokument %d – überspringe Auswertung", doc.getId());
|
||||||
stepHolder[0] += questions.size();
|
stepHolder[0] += questions.size();
|
||||||
pushProgress(projectId, stepHolder[0], totalSteps);
|
pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
|
||||||
|
doc.getId(), null, startedAt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (OrdsQuestion question : questions) {
|
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 {
|
try {
|
||||||
LOG.debugf("Auswertung: Dok %d × Frage %d",
|
LOG.debugf("Auswertung: Dok %d × Frage %d",
|
||||||
doc.getId(), question.getQuestionId());
|
doc.getId(), question.getQuestionId());
|
||||||
|
|
||||||
EvaluationResult result = evaluationService.evaluate(question, originalText);
|
EvaluationResult result = evaluationService.evaluate(question, originalText);
|
||||||
|
|
||||||
// Abweichung / Hinweis aus Schwellwert + result_handling berechnen
|
|
||||||
int deviation = 0;
|
int deviation = 0;
|
||||||
int warning = 0;
|
int warning = 0;
|
||||||
if (result.getScore() != null && question.getThreshold() != null
|
if (result.getScore() != null && question.getThreshold() != null
|
||||||
@@ -185,8 +239,10 @@ public class CheckOrchestrationService {
|
|||||||
question.getQuestionId(), doc.getId());
|
question.getQuestionId(), doc.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nach dem Schritt: Fortschritt + ETA aktualisieren
|
||||||
stepHolder[0]++;
|
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);
|
LOG.infof("=== Projekt %d erfolgreich abgeschlossen ===", projectId);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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 ===",
|
LOG.errorf(e, "=== Kritischer Fehler bei Projekt %d – bleibt IN_PROGRESS ===",
|
||||||
projectId);
|
projectId);
|
||||||
}
|
}
|
||||||
@@ -207,15 +261,33 @@ public class CheckOrchestrationService {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sendet den aktuellen Fortschritt an ORDS (0–100%).
|
* Sendet Fortschritt + Schrittdetails + ETA an ORDS.
|
||||||
* Fehler werden nur geloggt, kein Abbruch.
|
* Wird vor (step = bisherige Schritte) und nach (step = bisherige + 1) jedem Schritt aufgerufen.
|
||||||
*/
|
*/
|
||||||
private void pushProgress(long projectId, int step, int total) {
|
private void pushProgress(long projectId, int stepsCompleted, int total,
|
||||||
int pct = total > 0 ? Math.min(step * 100 / total, 99) : 0;
|
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 {
|
try {
|
||||||
ordsClient.updateProgress(projectId, new ProgressRequest(pct));
|
ordsClient.updateProgress(projectId,
|
||||||
|
new ProgressRequest(pct, phase, docId, questionId, eta));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.debugf("Fortschritt konnte nicht gesetzt werden (%d%%): %s", pct, e.getMessage());
|
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)
|
# KI-Kommunikation mitloggen (nur in Dev)
|
||||||
%dev.dc.ai.log-requests=true
|
%dev.dc.ai.log-requests=true
|
||||||
%dev.dc.ai.log-responses=true
|
%dev.dc.ai.log-responses=true
|
||||||
%prod.dc.ai.log-requests=false
|
%prod.dc.ai.log-requests=true
|
||||||
%prod.dc.ai.log-responses=false
|
%prod.dc.ai.log-responses=true
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Jackson: snake_case für ORDS-Responses
|
# 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