diff --git a/.claude/settings.local.json b/.claude/settings.local.json index abe2250..36fa764 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -31,7 +31,9 @@ "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 *)" + "Bash(KUBECONFIG=/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml kubectl logs *)", + "Bash(KUBECONFIG=/mnt/c/source/Frigosped/Dokumenten-Check/env/frigo-dev.yaml kubectl get pods -n ai-env -l app=dc-backend)", + "Bash(chmod +x *)" ] } } diff --git a/Scripts/DC Generate Report.sql b/Scripts/DC Generate Report.sql index 1eca510..438c5e1 100644 --- a/Scripts/DC Generate Report.sql +++ b/Scripts/DC Generate Report.sql @@ -1,193 +1,8 @@ -- ============================================================================= --- dc_generate_report – Erstellt einen Markdown-Prüfbericht für ein Projekt +-- Migration: dc_generate_report → DC_UTILS_PKG.generate_report -- --- 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 +-- Die Standalone-Prozedur wurde in DC_UTILS_PKG.generate_report überführt. +-- Dieses Skript entfernt die veraltete Prozedur aus der Datenbank. -- ============================================================================= -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; -/ +DROP PROCEDURE dc_generate_report; diff --git a/Scripts/DC_BACKEND_PKG.sql b/Scripts/DC_BACKEND_PKG.sql index 390ada2..f300516 100644 --- a/Scripts/DC_BACKEND_PKG.sql +++ b/Scripts/DC_BACKEND_PKG.sql @@ -122,7 +122,7 @@ CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS -- ------------------------------------------------------------------------- -- Generiert den Markdown-Prüfbericht für ein Projekt neu und speichert ihn - -- in dc_projects.report_markdown. Ruft intern dc_generate_report auf. + -- in dc_projects.report_markdown. Ruft intern DC_UTILS_PKG.generate_report auf. -- -- Beispiel: -- DC_BACKEND_PKG.regenerate_report(1); @@ -131,6 +131,66 @@ CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS p_project_id IN NUMBER ); + -- ------------------------------------------------------------------------- + -- Startet die asynchrone Katalog-Generierung aus einem Regelwerk-Dokument. + -- Gibt sofort zurück (202 Accepted-Muster) – die KI läuft im Hintergrund. + -- + -- p_file BLOB des Regelwerks (PDF, DOCX, ODT, TXT) + -- p_mime_type MIME-Typ, z.B. 'application/pdf' + -- p_filename Dateiname (wird im multipart-Part übergeben) + -- p_catalog_name Name des anzulegenden Katalogs (z.B. 'ADSp 2017') + -- p_document_type_id ID des Dokumenttyps (dc_document_types.id) + -- p_description Optionale Beschreibung + -- Rückgabe ID des Katalog-Placeholders (Status: GENERATING) + -- + -- Beispiel (mit Warten auf Abschluss): + -- DECLARE + -- l_blob BLOB; + -- l_id NUMBER; + -- BEGIN + -- SELECT file_blob INTO l_blob FROM dc_reference_documents WHERE id = 1; + -- l_id := DC_BACKEND_PKG.generate_catalog( + -- p_file => l_blob, + -- p_mime_type => 'application/pdf', + -- p_filename => 'ADSp-2017.pdf', + -- p_catalog_name => 'ADSp 2017', + -- p_document_type_id => 1, + -- p_description => 'Allgemeine Deutsche Spediteurbedingungen 2017'); + -- DBMS_OUTPUT.PUT_LINE('Katalog ID: ' || l_id || ' (läuft im Hintergrund)'); + -- DC_BACKEND_PKG.wait_for_catalog(l_id); -- optional, wartet bis READY + -- DBMS_OUTPUT.PUT_LINE('Katalog fertig!'); + -- END; + -- ------------------------------------------------------------------------- + FUNCTION generate_catalog( + p_file IN BLOB, + p_mime_type IN VARCHAR2 DEFAULT 'application/pdf', + p_filename IN VARCHAR2 DEFAULT 'dokument.pdf', + p_catalog_name IN VARCHAR2, + p_document_type_id IN NUMBER, + p_description IN VARCHAR2 DEFAULT NULL + ) RETURN NUMBER; + + -- ------------------------------------------------------------------------- + -- Liest den Generierungsstatus direkt aus der DB (kein REST-Aufruf). + -- Rückgabe: 'GENERATING', 'READY' oder 'ERROR' + -- ------------------------------------------------------------------------- + FUNCTION get_catalog_status( + p_catalog_id IN NUMBER + ) RETURN VARCHAR2; + + -- ------------------------------------------------------------------------- + -- Wartet (polling) bis der Katalog den Status READY erreicht hat. + -- Wirft eine Exception bei Timeout oder wenn der Katalog ERROR meldet. + -- + -- p_timeout_sec Max. Wartezeit in Sekunden (Default: 1800 = 30 Min.) + -- p_interval_sec Polling-Intervall in Sekunden (Default: 10) + -- ------------------------------------------------------------------------- + PROCEDURE wait_for_catalog( + p_catalog_id IN NUMBER, + p_timeout_sec IN NUMBER DEFAULT 1800, + p_interval_sec IN NUMBER DEFAULT 10 + ); + END DC_BACKEND_PKG; / @@ -376,7 +436,7 @@ CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS p_project_id IN NUMBER ) IS BEGIN - dc_generate_report(p_project_id => p_project_id); + DC_UTILS_PKG.generate_report(p_project_id => p_project_id); EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20110, @@ -384,6 +444,190 @@ CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS END regenerate_report; + -- ------------------------------------------------------------------------- + -- Interne Hilfsfunktion: schreibt einen VARCHAR2-Text als UTF8-Bytes in ein BLOB. + -- ------------------------------------------------------------------------- + PROCEDURE append_text_to_blob(p_dest IN OUT BLOB, p_text IN VARCHAR2) IS + l_raw RAW(32767); + BEGIN + l_raw := UTL_RAW.CAST_TO_RAW(p_text); + DBMS_LOB.WRITEAPPEND(p_dest, UTL_RAW.LENGTH(l_raw), l_raw); + END append_text_to_blob; + + + -- ------------------------------------------------------------------------- + FUNCTION get_catalog_status( + p_catalog_id IN NUMBER + ) RETURN VARCHAR2 IS + l_status VARCHAR2(20); + BEGIN + SELECT generation_status + INTO l_status + FROM dc_question_catalogs + WHERE id = p_catalog_id; + RETURN l_status; + EXCEPTION + WHEN NO_DATA_FOUND THEN + RAISE_APPLICATION_ERROR(-20310, + 'Katalog ' || p_catalog_id || ' nicht gefunden.'); + END get_catalog_status; + + + -- ------------------------------------------------------------------------- + PROCEDURE wait_for_catalog( + p_catalog_id IN NUMBER, + p_timeout_sec IN NUMBER DEFAULT 1800, + p_interval_sec IN NUMBER DEFAULT 10 + ) IS + l_start TIMESTAMP := SYSTIMESTAMP; + l_status VARCHAR2(20); + l_elapsed NUMBER; + BEGIN + LOOP + l_status := get_catalog_status(p_catalog_id); + + IF l_status = 'READY' THEN + RETURN; + END IF; + + IF l_status = 'ERROR' THEN + DECLARE + l_error VARCHAR2(4000); + BEGIN + SELECT generation_error + INTO l_error + FROM dc_question_catalogs + WHERE id = p_catalog_id; + RAISE_APPLICATION_ERROR(-20311, + 'Katalog ' || p_catalog_id || ' Generierung fehlgeschlagen: ' + || NVL(l_error, 'Unbekannter Fehler')); + END; + END IF; + + -- Timeout prüfen + l_elapsed := + EXTRACT(SECOND FROM (SYSTIMESTAMP - l_start)) + + EXTRACT(MINUTE FROM (SYSTIMESTAMP - l_start)) * 60 + + EXTRACT(HOUR FROM (SYSTIMESTAMP - l_start)) * 3600; + + IF l_elapsed > p_timeout_sec THEN + RAISE_APPLICATION_ERROR(-20312, + 'Timeout nach ' || p_timeout_sec || 's – Katalog ' || + p_catalog_id || ' noch im Status ' || l_status || '.'); + END IF; + + DBMS_SESSION.SLEEP(p_interval_sec); + END LOOP; + END wait_for_catalog; + + + -- ------------------------------------------------------------------------- + FUNCTION generate_catalog( + p_file IN BLOB, + p_mime_type IN VARCHAR2 DEFAULT 'application/pdf', + p_filename IN VARCHAR2 DEFAULT 'dokument.pdf', + p_catalog_name IN VARCHAR2, + p_document_type_id IN NUMBER, + p_description IN VARCHAR2 DEFAULT NULL + ) RETURN NUMBER IS + + c_crlf CONSTANT VARCHAR2(2) := CHR(13) || CHR(10); + l_boundary VARCHAR2(50) := 'dc-boundary-' || TO_CHAR(SYSDATE, 'YYYYMMDDHHMI') + || DBMS_RANDOM.STRING('X', 8); + l_url VARCHAR2(500); + l_body BLOB; + l_response CLOB; + l_status NUMBER; + l_catalog_id NUMBER; + + -- Hilfsfunktion: multipart-Textfeld anfügen + PROCEDURE add_field(p_name VARCHAR2, p_value VARCHAR2) IS + BEGIN + IF p_value IS NULL THEN RETURN; END IF; + append_text_to_blob(l_body, + '--' || l_boundary || c_crlf + || 'Content-Disposition: form-data; name="' || p_name || '"' || c_crlf + || c_crlf + || p_value || c_crlf); + END add_field; + + BEGIN + l_url := c_base_url || '/catalog/generate'; + + -- ---- multipart-Body aufbauen ---- + DBMS_LOB.CREATETEMPORARY(l_body, TRUE); + + -- Textfelder + add_field('catalog_name', p_catalog_name); + add_field('document_type_id', TO_CHAR(p_document_type_id)); + add_field('description', p_description); + + -- Datei-Part: Header + append_text_to_blob(l_body, + '--' || l_boundary || c_crlf + || 'Content-Disposition: form-data; name="file"; filename="' + || p_filename || '"' || c_crlf + || 'Content-Type: ' || p_mime_type || c_crlf + || c_crlf); + + -- Datei-Part: BLOB-Inhalt anhängen + DBMS_LOB.APPEND(l_body, p_file); + + -- Datei-Part: Abschluss-CRLF + append_text_to_blob(l_body, c_crlf); + + -- Abschließende Boundary + append_text_to_blob(l_body, '--' || l_boundary || '--' || c_crlf); + + -- ---- HTTP-Request senden ---- + APEX_WEB_SERVICE.G_REQUEST_HEADERS.DELETE; + APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).NAME := 'Content-Type'; + APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).VALUE := + 'multipart/form-data; boundary=' || l_boundary; + + IF g_api_key IS NOT NULL AND LENGTH(TRIM(g_api_key)) > 0 THEN + APEX_WEB_SERVICE.G_REQUEST_HEADERS(2).NAME := 'X-API-KEY'; + APEX_WEB_SERVICE.G_REQUEST_HEADERS(2).VALUE := g_api_key; + END IF; + + l_response := APEX_WEB_SERVICE.MAKE_REST_REQUEST( + p_url => l_url, + p_http_method => 'POST', + p_body_blob => l_body + ); + DBMS_LOB.FREETEMPORARY(l_body); + + l_status := APEX_WEB_SERVICE.G_STATUS_CODE; + + -- 202 Accepted = asynchrone Verarbeitung gestartet + IF l_status NOT IN (200, 201, 202) THEN + RAISE_APPLICATION_ERROR(-20300, + 'Katalog-Generierung fehlgeschlagen: HTTP ' || l_status + || ' – ' || SUBSTR(l_response, 1, 500)); + END IF; + + -- catalog_id aus JSON-Antwort lesen: + -- {"catalog_id": 42, "catalog_name": "...", "generation_status": "GENERATING"} + l_catalog_id := TO_NUMBER(JSON_VALUE(l_response, '$.catalog_id')); + + IF l_catalog_id IS NULL THEN + RAISE_APPLICATION_ERROR(-20301, + 'Antwort enthält keine catalog_id: ' || SUBSTR(l_response, 1, 500)); + END IF; + + RETURN l_catalog_id; + + EXCEPTION + WHEN OTHERS THEN + IF DBMS_LOB.ISTEMPORARY(l_body) = 1 THEN + DBMS_LOB.FREETEMPORARY(l_body); + END IF; + IF SQLCODE IN (-20300, -20301) THEN RAISE; END IF; + RAISE_APPLICATION_ERROR(-20302, + 'Verbindungsfehler bei Katalog-Generierung: ' || SQLERRM); + END generate_catalog; + + END DC_BACKEND_PKG; / diff --git a/Scripts/DC_CATALOG_GEN_ALTER.sql b/Scripts/DC_CATALOG_GEN_ALTER.sql new file mode 100644 index 0000000..20cc351 --- /dev/null +++ b/Scripts/DC_CATALOG_GEN_ALTER.sql @@ -0,0 +1,47 @@ +-- ============================================================================= +-- DC_CATALOG_GEN_ALTER.sql +-- Ergänzt dc_question_catalogs um Spalten für asynchrone Katalog-Generierung. +-- Idempotent: Fehler "Spalte existiert bereits" wird ignoriert. +-- ============================================================================= + +BEGIN + EXECUTE IMMEDIATE ' + ALTER TABLE dc_question_catalogs ADD ( + generation_status VARCHAR2(20) DEFAULT ''READY'', + generation_error VARCHAR2(4000) + ) + '; + DBMS_OUTPUT.PUT_LINE('Spalten generation_status/generation_error angelegt.'); +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE = -1430 THEN + DBMS_OUTPUT.PUT_LINE('Spalten existieren bereits – kein Änderungsbedarf.'); + ELSE + RAISE; + END IF; +END; +/ + +-- Bestehende Zeilen bekommen Status READY (Kataloge wurden manuell erstellt) +UPDATE dc_question_catalogs +SET generation_status = 'READY' +WHERE generation_status IS NULL; + +COMMIT; + +-- Constraint nachträglich hinzufügen (idempotent) +BEGIN + EXECUTE IMMEDIATE ' + ALTER TABLE dc_question_catalogs + ADD CONSTRAINT chk_cat_gen_status + CHECK (generation_status IN (''GENERATING'', ''READY'', ''ERROR'')) + '; +EXCEPTION + WHEN OTHERS THEN + IF SQLCODE IN (-2264, -2261) THEN NULL; -- Constraint existiert bereits + ELSE RAISE; + END IF; +END; +/ + +COMMIT; diff --git a/Scripts/DC_UTILS_PKG.sql b/Scripts/DC_UTILS_PKG.sql new file mode 100644 index 0000000..ee0af24 --- /dev/null +++ b/Scripts/DC_UTILS_PKG.sql @@ -0,0 +1,231 @@ +-- ============================================================================= +-- DC_UTILS_PKG +-- Hilfsprozeduren und -funktionen für den Dokumenten-Check +-- +-- Deployment: +-- sqlplus user/pass@db @"Scripts/DC_UTILS_PKG.sql" +-- ============================================================================= + + +-- ============================================================================= +-- Package Spec +-- ============================================================================= +CREATE OR REPLACE PACKAGE DC_UTILS_PKG AS + + -- ------------------------------------------------------------------------- + -- Erstellt einen Markdown-Prüfbericht für ein Projekt und speichert ihn + -- in dc_projects.report_markdown. + -- + -- 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 + -- + -- Beispiel: + -- BEGIN DC_UTILS_PKG.generate_report(p_project_id => 1); COMMIT; END; + -- ------------------------------------------------------------------------- + PROCEDURE generate_report(p_project_id IN NUMBER); + +END DC_UTILS_PKG; +/ + + +-- ============================================================================= +-- Package Body +-- ============================================================================= +CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS + + -- ------------------------------------------------------------------------- + PROCEDURE 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(13)||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('# 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(); + 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 generate_report; + +END DC_UTILS_PKG; +/ + + +-- ============================================================================= +-- Schnelltest (optional, auskommentiert) +-- ============================================================================= +/* +BEGIN + DC_UTILS_PKG.generate_report(p_project_id => 1); + COMMIT; + DBMS_OUTPUT.PUT_LINE('Bericht generiert.'); +END; +/ +*/ diff --git a/Scripts/ORDS REST Services.sql b/Scripts/ORDS REST Services.sql index f9b9cd4..8636e75 100644 --- a/Scripts/ORDS REST Services.sql +++ b/Scripts/ORDS REST Services.sql @@ -365,7 +365,7 @@ BEGIN -- Markdown-Bericht generieren (Fehler ignoriert, Projekt bleibt COMPLETED) BEGIN - dc_generate_report(:project_id); + DC_UTILS_PKG.generate_report(:project_id); EXCEPTION WHEN OTHERS THEN NULL; END; @@ -406,7 +406,7 @@ BEGIN p_comments => 'Generiert report_markdown neu (z.B. nach Konfigurationsänderung)', p_source => q'[ BEGIN - dc_generate_report(:project_id); + DC_UTILS_PKG.generate_report(:project_id); :status_code := 200; HTP.P('{"project_id":' || :project_id || ',"report_generated":true}'); EXCEPTION @@ -731,6 +731,301 @@ BEGIN ]' ); + + -- =========================================================================== + -- TEMPLATE 13: catalogs/ – POST (Katalog anlegen) + -- Ergaenzt das bestehende catalogs/-Template um eine Schreib-Operation. + -- =========================================================================== + + -- 13. POST /api/dc/catalogs/ + -- Body: {"name":"ADSp 2017","description":"...","document_type_id":1} + -- Legt den Katalog mit generation_status=GENERATING an (async Flow). + ORDS.DEFINE_HANDLER( + p_module_name => 'frigosped.dc', + p_pattern => 'catalogs/', + p_method => 'POST', + p_source_type => ORDS.SOURCE_TYPE_PLSQL, + p_comments => '201 Created mit neuer catalog_id; 400 bei fehlendem name/document_type_id', + p_source => q'[ + DECLARE + v_body CLOB; + v_name VARCHAR2(100); + v_desc CLOB; + v_dt_id NUMBER; + v_new_id NUMBER; + BEGIN + v_body := :body_text; + v_name := JSON_VALUE(v_body, '$.name'); + v_desc := JSON_VALUE(v_body, '$.description' RETURNING CLOB); + v_dt_id := TO_NUMBER(JSON_VALUE(v_body, '$.document_type_id')); + + IF v_name IS NULL OR v_dt_id IS NULL THEN + :status_code := 400; + HTP.P('{"error":"name und document_type_id sind Pflichtfelder"}'); + RETURN; + END IF; + + INSERT INTO dc_question_catalogs (name, description, document_type_id, generation_status) + VALUES (v_name, v_desc, v_dt_id, 'GENERATING') + RETURNING id INTO v_new_id; + + :status_code := 201; + HTP.P('{"id":' || v_new_id + || ',"name":"' || REPLACE(v_name, '"', '''') || '"' + || ',"document_type_id":' || v_dt_id + || ',"generation_status":"GENERATING"}'); + + EXCEPTION + WHEN OTHERS THEN + ROLLBACK; + :status_code := 500; + HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}'); + END; + ]' + ); + + + -- =========================================================================== + -- TEMPLATE 16: catalogs/:catalog_id/generation-status (GET + PUT) + -- =========================================================================== + ORDS.DEFINE_TEMPLATE( + p_module_name => 'frigosped.dc', + p_pattern => 'catalogs/:catalog_id/generation-status', + p_priority => 0, + p_comments => 'Status der asynchronen Katalog-Generierung lesen/schreiben' + ); + + -- 16a. GET /api/dc/catalogs/:catalog_id/generation-status + ORDS.DEFINE_HANDLER( + p_module_name => 'frigosped.dc', + p_pattern => 'catalogs/:catalog_id/generation-status', + p_method => 'GET', + p_source_type => ORDS.SOURCE_TYPE_COLLECTION_ITEM, + p_items_per_page => 1, + p_comments => 'Gibt generation_status und generation_error zurueck', + p_source => q'[ + SELECT + c.id AS catalog_id, + c.name, + c.description, + c.generation_status, + c.generation_error + FROM dc_question_catalogs c + WHERE c.id = :catalog_id + ]' + ); + + -- 16b. PUT /api/dc/catalogs/:catalog_id/generation-status + -- Body: {"generation_status":"READY"} oder {"generation_status":"ERROR","generation_error":"..."} + ORDS.DEFINE_HANDLER( + p_module_name => 'frigosped.dc', + p_pattern => 'catalogs/:catalog_id/generation-status', + p_method => 'PUT', + p_source_type => ORDS.SOURCE_TYPE_PLSQL, + p_comments => 'Setzt generation_status (GENERATING|READY|ERROR) und optionalen Fehlertext', + p_source => q'[ + DECLARE + v_body CLOB; + v_status VARCHAR2(20); + v_error VARCHAR2(4000); + v_rows INTEGER; + BEGIN + v_body := :body_text; + v_status := JSON_VALUE(v_body, '$.generation_status'); + v_error := JSON_VALUE(v_body, '$.generation_error'); + + IF v_status NOT IN ('GENERATING', 'READY', 'ERROR') THEN + :status_code := 400; + HTP.P('{"error":"generation_status muss GENERATING, READY oder ERROR sein"}'); + RETURN; + END IF; + + UPDATE dc_question_catalogs + SET generation_status = v_status, + generation_error = v_error + WHERE id = :catalog_id; + + v_rows := SQL%ROWCOUNT; + IF v_rows = 0 THEN + :status_code := 404; + HTP.P('{"error":"Katalog nicht gefunden","catalog_id":' || :catalog_id || '}'); + RETURN; + END IF; + + :status_code := 200; + HTP.P('{"catalog_id":' || :catalog_id + || ',"generation_status":"' || v_status || '"' + || ',"updated":true}'); + + EXCEPTION + WHEN OTHERS THEN + ROLLBACK; + :status_code := 500; + HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}'); + END; + ]' + ); + + + -- =========================================================================== + -- TEMPLATE 14: catalogs/:catalog_id/categories/ (POST) + -- =========================================================================== + ORDS.DEFINE_TEMPLATE( + p_module_name => 'frigosped.dc', + p_pattern => 'catalogs/:catalog_id/categories/', + p_priority => 0, + p_comments => 'Fragenkategorien eines Katalogs anlegen' + ); + + -- 14. POST /api/dc/catalogs/:catalog_id/categories/ + -- Body: {"name":"Haftung","description":"...","order_nr":1} + ORDS.DEFINE_HANDLER( + p_module_name => 'frigosped.dc', + p_pattern => 'catalogs/:catalog_id/categories/', + p_method => 'POST', + p_source_type => ORDS.SOURCE_TYPE_PLSQL, + p_comments => '201 Created mit neuer category_id; 400 bei fehlendem name', + p_source => q'[ + DECLARE + v_body CLOB; + v_name VARCHAR2(100); + v_desc CLOB; + v_order NUMBER; + v_new_id NUMBER; + BEGIN + v_body := :body_text; + v_name := JSON_VALUE(v_body, '$.name'); + v_desc := JSON_VALUE(v_body, '$.description' RETURNING CLOB); + v_order := NVL(TO_NUMBER(JSON_VALUE(v_body, '$.order_nr')), 0); + + IF v_name IS NULL THEN + :status_code := 400; + HTP.P('{"error":"name ist ein Pflichtfeld"}'); + RETURN; + END IF; + + INSERT INTO dc_question_categories (catalog_id, name, description, order_nr) + VALUES (:catalog_id, v_name, v_desc, v_order) + RETURNING id INTO v_new_id; + + :status_code := 201; + HTP.P('{"id":' || v_new_id + || ',"catalog_id":' || :catalog_id + || ',"name":"' || REPLACE(v_name, '"', '''') || '"' + || ',"order_nr":' || v_order || '}'); + + EXCEPTION + WHEN OTHERS THEN + ROLLBACK; + :status_code := 500; + HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}'); + END; + ]' + ); + + + -- =========================================================================== + -- TEMPLATE 15: categories/:category_id/questions/ (POST) + -- =========================================================================== + ORDS.DEFINE_TEMPLATE( + p_module_name => 'frigosped.dc', + p_pattern => 'categories/:category_id/questions/', + p_priority => 0, + p_comments => 'Fragen einer Kategorie anlegen' + ); + + -- 15. POST /api/dc/categories/:category_id/questions/ + -- Body: { + -- "question_text": "...", + -- "evaluation_type": "SCORE", + -- "threshold": 70, + -- "result_handling": "ABWEICHUNG", + -- "example_0_percent": "...", + -- "example_100_percent": "...", + -- "order_nr": 1 + -- } + ORDS.DEFINE_HANDLER( + p_module_name => 'frigosped.dc', + p_pattern => 'categories/:category_id/questions/', + p_method => 'POST', + p_source_type => ORDS.SOURCE_TYPE_PLSQL, + p_comments => '201 Created mit neuer question_id; 400 bei fehlendem question_text', + p_source => q'[ + DECLARE + v_body CLOB; + v_text CLOB; + v_eval_type VARCHAR2(20); + v_threshold NUMBER; + v_handling VARCHAR2(20); + v_ex0 CLOB; + v_ex100 CLOB; + v_order NUMBER; + v_new_id NUMBER; + BEGIN + v_body := :body_text; + v_text := JSON_VALUE(v_body, '$.question_text' RETURNING CLOB); + v_eval_type := NVL(JSON_VALUE(v_body, '$.evaluation_type'), 'SCORE'); + v_threshold := NVL(TO_NUMBER(JSON_VALUE(v_body, '$.threshold')), 70); + v_handling := NVL(JSON_VALUE(v_body, '$.result_handling'), 'ABWEICHUNG'); + v_ex0 := JSON_VALUE(v_body, '$.example_0_percent' RETURNING CLOB); + v_ex100 := JSON_VALUE(v_body, '$.example_100_percent' RETURNING CLOB); + v_order := NVL(TO_NUMBER(JSON_VALUE(v_body, '$.order_nr')), 0); + + IF v_text IS NULL THEN + :status_code := 400; + HTP.P('{"error":"question_text ist ein Pflichtfeld"}'); + RETURN; + END IF; + + IF v_eval_type NOT IN ('SCORE', 'BINARY') THEN + :status_code := 400; + HTP.P('{"error":"evaluation_type muss SCORE oder BINARY sein"}'); + RETURN; + END IF; + + IF v_handling NOT IN ('ABWEICHUNG', 'HINWEIS') THEN + :status_code := 400; + HTP.P('{"error":"result_handling muss ABWEICHUNG oder HINWEIS sein"}'); + RETURN; + END IF; + + INSERT INTO dc_questions ( + category_id, + question_text, + evaluation_type, + threshold, + result_handling, + example_0_percent, + example_100_percent, + order_nr + ) VALUES ( + :category_id, + v_text, + v_eval_type, + v_threshold, + v_handling, + v_ex0, + v_ex100, + v_order + ) + RETURNING id INTO v_new_id; + + :status_code := 201; + HTP.P('{"id":' || v_new_id + || ',"category_id":' || :category_id + || ',"evaluation_type":"' || v_eval_type || '"' + || ',"threshold":' || v_threshold + || ',"order_nr":' || v_order || '}'); + + EXCEPTION + WHEN OTHERS THEN + ROLLBACK; + :status_code := 500; + HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}'); + END; + ]' + ); + COMMIT; END; / diff --git a/dc-backend/getPods.sh b/dc-backend/getPods.sh new file mode 100644 index 0000000..beffe21 --- /dev/null +++ b/dc-backend/getPods.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +export KUBECONFIG="$SCRIPT_DIR/../env/frigo-dev.yaml" + +TAG="${1:-latest}" + + +kubectl get pods -n ai-env -l app=dc-backend diff --git a/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java b/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java index 7f93044..7ce042e 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java +++ b/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java @@ -42,6 +42,18 @@ public class AiModelProducer { @ConfigProperty(name = "dc.ai.main.temperature", defaultValue = "0.1") double mainTemperature; + @ConfigProperty(name = "dc.ai.catalog.model") + String catalogModelName; + + @ConfigProperty(name = "dc.ai.catalog.timeout", defaultValue = "1200s") + String catalogTimeout; + + @ConfigProperty(name = "dc.ai.catalog.max-retries", defaultValue = "1") + int catalogMaxRetries; + + @ConfigProperty(name = "dc.ai.catalog.temperature", defaultValue = "0.1") + double catalogTemperature; + @ConfigProperty(name = "dc.ai.ocr.model") String ocrModelName; @@ -77,6 +89,26 @@ public class AiModelProducer { .build(); } + /** + * Katalog-Modell: Vollständige Katalog-Generierung aus Regelwerk-Dokument. + * Längerer Timeout (1200s) notwendig da komplette Dokumente analysiert werden. + */ + @Produces + @ApplicationScoped + @Named("catalog") + public ChatLanguageModel catalogModel() { + return OllamaChatModel.builder() + .baseUrl(baseUrl) + .modelName(catalogModelName) + .temperature(catalogTemperature) + .timeout(parseDuration(catalogTimeout)) + .maxRetries(catalogMaxRetries) + .customHeaders(Map.of("X-API-KEY", apiKey)) + .logRequests(logRequests) + .logResponses(logResponses) + .build(); + } + /** * OCR-Modell: Bildtext-Extraktion aus PDF-Seiten (Vision-Modus) * Modell: quen3.5:9b diff --git a/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java b/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java index 3556dcd..16c35af 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java +++ b/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java @@ -27,7 +27,7 @@ import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; public interface OrdsClient { // ========================================================================= - // Fragenkatalog (Lesend) + // Fragenkatalog (Lesend + Schreibend) // ========================================================================= /** @@ -38,6 +38,52 @@ public interface OrdsClient { @Path("/catalogs/{catalogId}/questions/") OrdsQuestionList getQuestions(@PathParam("catalogId") long catalogId); + /** + * POST /api/dc/catalogs/ + * Legt einen neuen Fragenkatalog an. + * Antwort: 201 Created, Body {"id": , "name": "...", ...} + */ + @POST + @Path("/catalogs/") + Response createCatalog(OrdsCatalogCreateRequest body); + + /** + * POST /api/dc/catalogs/:catalogId/categories/ + * Legt eine neue Fragenkategorie in einem Katalog an. + * Antwort: 201 Created, Body {"id": , ...} + */ + @POST + @Path("/catalogs/{catalogId}/categories/") + Response createCategory(@PathParam("catalogId") long catalogId, + OrdsCategoryCreateRequest body); + + /** + * POST /api/dc/categories/:categoryId/questions/ + * Legt eine neue Frage in einer Kategorie an. + * Antwort: 201 Created, Body {"id": , ...} + */ + @POST + @Path("/categories/{categoryId}/questions/") + Response createQuestion(@PathParam("categoryId") long categoryId, + OrdsQuestionCreateRequest body); + + /** + * GET /api/dc/catalogs/:catalogId/generation-status + * Liest den Generierungsstatus (GENERATING | READY | ERROR). + */ + @GET + @Path("/catalogs/{catalogId}/generation-status") + OrdsCatalogStatusResponse getCatalogStatus(@PathParam("catalogId") long catalogId); + + /** + * PUT /api/dc/catalogs/:catalogId/generation-status + * Setzt den Generierungsstatus nach Abschluss oder Fehler. + */ + @PUT + @Path("/catalogs/{catalogId}/generation-status") + Response updateCatalogStatus(@PathParam("catalogId") long catalogId, + OrdsCatalogStatusUpdateRequest body); + // ========================================================================= // Projekte (Status-Steuerung) // ========================================================================= diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/CatalogGenerateResponse.java b/dc-backend/src/main/java/de/frigosped/dc/model/CatalogGenerateResponse.java new file mode 100644 index 0000000..12926df --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/model/CatalogGenerateResponse.java @@ -0,0 +1,31 @@ +package de.frigosped.dc.model; + +public class CatalogGenerateResponse { + + private long catalogId; + private String catalogName; + private String description; + private String generationStatus; + + public CatalogGenerateResponse() {} + + public CatalogGenerateResponse(long catalogId, String catalogName, + String description, String generationStatus) { + this.catalogId = catalogId; + this.catalogName = catalogName; + this.description = description; + this.generationStatus = generationStatus; + } + + public long getCatalogId() { return catalogId; } + public void setCatalogId(long v) { this.catalogId = v; } + + public String getCatalogName() { return catalogName; } + public void setCatalogName(String v) { this.catalogName = v; } + + public String getDescription() { return description; } + public void setDescription(String v) { this.description = v; } + + public String getGenerationStatus() { return generationStatus; } + public void setGenerationStatus(String v) { this.generationStatus = v; } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/GeneratedCatalogStructure.java b/dc-backend/src/main/java/de/frigosped/dc/model/GeneratedCatalogStructure.java new file mode 100644 index 0000000..29bcb75 --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/model/GeneratedCatalogStructure.java @@ -0,0 +1,69 @@ +package de.frigosped.dc.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +/** + * KI-Ausgabe: strukturierter Fragenkatalog aus einem Regelwerk-Dokument. + * Jackson mappt snake_case-JSON automatisch auf camelCase-Felder. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class GeneratedCatalogStructure { + + private List categories; + + public List getCategories() { return categories; } + public void setCategories(List categories) { this.categories = categories; } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GeneratedCategory { + private String name; + private String description; + private int orderNr; + private List questions; + + public String getName() { return name; } + public void setName(String v) { this.name = v; } + + public String getDescription() { return description; } + public void setDescription(String v) { this.description = v; } + + public int getOrderNr() { return orderNr; } + public void setOrderNr(int v) { this.orderNr = v; } + + public List getQuestions() { return questions; } + public void setQuestions(List questions) { this.questions = questions; } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GeneratedQuestion { + private String questionText; + private String evaluationType; + private Integer threshold; + private String resultHandling; + private String example0Percent; + private String example100Percent; + private int orderNr; + + public String getQuestionText() { return questionText; } + public void setQuestionText(String v) { this.questionText = v; } + + public String getEvaluationType() { return evaluationType; } + public void setEvaluationType(String v) { this.evaluationType = v; } + + public Integer getThreshold() { return threshold; } + public void setThreshold(Integer v) { this.threshold = v; } + + public String getResultHandling() { return resultHandling; } + public void setResultHandling(String v) { this.resultHandling = v; } + + public String getExample0Percent() { return example0Percent; } + public void setExample0Percent(String v) { this.example0Percent = v; } + + public String getExample100Percent() { return example100Percent; } + public void setExample100Percent(String v) { this.example100Percent = v; } + + public int getOrderNr() { return orderNr; } + public void setOrderNr(int v) { this.orderNr = v; } + } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogCreateRequest.java b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogCreateRequest.java new file mode 100644 index 0000000..9fb18e8 --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogCreateRequest.java @@ -0,0 +1,25 @@ +package de.frigosped.dc.model; + +public class OrdsCatalogCreateRequest { + + private String name; + private String description; + private long documentTypeId; + + public OrdsCatalogCreateRequest() {} + + public OrdsCatalogCreateRequest(String name, String description, long documentTypeId) { + this.name = name; + this.description = description; + this.documentTypeId = documentTypeId; + } + + public String getName() { return name; } + public void setName(String v) { this.name = v; } + + public String getDescription() { return description; } + public void setDescription(String v) { this.description = v; } + + public long getDocumentTypeId() { return documentTypeId; } + public void setDocumentTypeId(long v) { this.documentTypeId = v; } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusResponse.java b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusResponse.java new file mode 100644 index 0000000..77192cd --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusResponse.java @@ -0,0 +1,28 @@ +package de.frigosped.dc.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class OrdsCatalogStatusResponse { + + private Long catalogId; + private String name; + private String description; + private String generationStatus; + private String generationError; + + public Long getCatalogId() { return catalogId; } + public void setCatalogId(Long v) { this.catalogId = v; } + + public String getName() { return name; } + public void setName(String v) { this.name = v; } + + public String getDescription() { return description; } + public void setDescription(String v) { this.description = v; } + + public String getGenerationStatus() { return generationStatus; } + public void setGenerationStatus(String v) { this.generationStatus = v; } + + public String getGenerationError() { return generationError; } + public void setGenerationError(String v) { this.generationError = v; } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusUpdateRequest.java b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusUpdateRequest.java new file mode 100644 index 0000000..a317c60 --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusUpdateRequest.java @@ -0,0 +1,20 @@ +package de.frigosped.dc.model; + +public class OrdsCatalogStatusUpdateRequest { + + private String generationStatus; + private String generationError; + + public OrdsCatalogStatusUpdateRequest() {} + + public OrdsCatalogStatusUpdateRequest(String generationStatus, String generationError) { + this.generationStatus = generationStatus; + this.generationError = generationError; + } + + public String getGenerationStatus() { return generationStatus; } + public void setGenerationStatus(String v) { this.generationStatus = v; } + + public String getGenerationError() { return generationError; } + public void setGenerationError(String v) { this.generationError = v; } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCategoryCreateRequest.java b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCategoryCreateRequest.java new file mode 100644 index 0000000..fb9ff92 --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCategoryCreateRequest.java @@ -0,0 +1,25 @@ +package de.frigosped.dc.model; + +public class OrdsCategoryCreateRequest { + + private String name; + private String description; + private int orderNr; + + public OrdsCategoryCreateRequest() {} + + public OrdsCategoryCreateRequest(String name, String description, int orderNr) { + this.name = name; + this.description = description; + this.orderNr = orderNr; + } + + public String getName() { return name; } + public void setName(String v) { this.name = v; } + + public String getDescription() { return description; } + public void setDescription(String v) { this.description = v; } + + public int getOrderNr() { return orderNr; } + public void setOrderNr(int v) { this.orderNr = v; } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionCreateRequest.java b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionCreateRequest.java new file mode 100644 index 0000000..79ce255 --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionCreateRequest.java @@ -0,0 +1,35 @@ +package de.frigosped.dc.model; + +public class OrdsQuestionCreateRequest { + + private String questionText; + private String evaluationType; + private Integer threshold; + private String resultHandling; + private String example0Percent; + private String example100Percent; + private int orderNr; + + public OrdsQuestionCreateRequest() {} + + public String getQuestionText() { return questionText; } + public void setQuestionText(String v) { this.questionText = v; } + + public String getEvaluationType() { return evaluationType; } + public void setEvaluationType(String v) { this.evaluationType = v; } + + public Integer getThreshold() { return threshold; } + public void setThreshold(Integer v) { this.threshold = v; } + + public String getResultHandling() { return resultHandling; } + public void setResultHandling(String v) { this.resultHandling = v; } + + public String getExample0Percent() { return example0Percent; } + public void setExample0Percent(String v) { this.example0Percent = v; } + + public String getExample100Percent() { return example100Percent; } + public void setExample100Percent(String v) { this.example100Percent = v; } + + public int getOrderNr() { return orderNr; } + public void setOrderNr(int v) { this.orderNr = v; } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/resource/CatalogResource.java b/dc-backend/src/main/java/de/frigosped/dc/resource/CatalogResource.java new file mode 100644 index 0000000..59ff9eb --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/resource/CatalogResource.java @@ -0,0 +1,138 @@ +package de.frigosped.dc.resource; + +import de.frigosped.dc.model.CatalogGenerateResponse; +import de.frigosped.dc.model.OrdsCatalogStatusResponse; +import de.frigosped.dc.service.CatalogGenerationService; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import de.frigosped.dc.client.OrdsClient; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; + +import java.nio.file.Files; + +/** + * REST-Endpunkte zur automatischen Fragenkatalog-Generierung. + * + * POST /catalog/generate → 202 Accepted, Verarbeitung läuft asynchron + * GET /catalog/status/{catalogId} → Aktueller Status (GENERATING | READY | ERROR) + * + * curl-Beispiel: + * # Starten: + * curl -X POST http://localhost:8090/catalog/generate \ + * -F "file=@ADSp-2017.pdf;type=application/pdf" \ + * -F "catalog_name=ADSp 2017" \ + * -F "document_type_id=1" \ + * -F "description=Allgemeine Deutsche Spediteurbedingungen 2017" + * + * # Status prüfen: + * curl http://localhost:8090/catalog/status/42 + */ +@Path("/catalog") +@Produces(MediaType.APPLICATION_JSON) +public class CatalogResource { + + private static final Logger LOG = Logger.getLogger(CatalogResource.class); + + @Inject + CatalogGenerationService catalogGenerationService; + + @Inject + @RestClient + OrdsClient ordsClient; + + /** + * POST /catalog/generate (multipart/form-data) + * Gibt sofort 202 Accepted zurück. Verarbeitung läuft im Hintergrund. + * + * Response: {"catalog_id": 42, "catalog_name": "ADSp 2017", + * "description": "...", "generation_status": "GENERATING"} + */ + @POST + @Path("/generate") + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Response generate( + @RestForm FileUpload file, + @RestForm("catalog_name") String catalogName, + @RestForm("document_type_id") Long documentTypeId, + @RestForm String description) { + + if (file == null || file.uploadedFile() == null) { + return badRequest("file ist ein Pflichtfeld."); + } + if (catalogName == null || catalogName.isBlank()) { + return badRequest("catalog_name ist ein Pflichtfeld."); + } + if (documentTypeId == null) { + return badRequest("document_type_id ist ein Pflichtfeld."); + } + + try { + // Bytes jetzt lesen – Temp-Datei wird nach Request-Ende ggf. gelöscht + byte[] fileBytes = Files.readAllBytes(file.uploadedFile()); + String mimeType = file.contentType(); + String filename = file.fileName() != null ? file.fileName() : "dokument"; + String desc = description != null ? description.trim() : null; + String name = catalogName.trim(); + + LOG.infof("Katalog-Generierung angefordert: name='%s', file='%s' (%d Bytes)", + name, filename, fileBytes.length); + + long catalogId = catalogGenerationService.startGeneration( + fileBytes, mimeType, filename, name, desc, documentTypeId); + + return Response.accepted( + new CatalogGenerateResponse(catalogId, name, desc, "GENERATING")).build(); + + } catch (Exception e) { + LOG.errorf(e, "Fehler beim Start der Katalog-Generierung für '%s'", catalogName); + return serverError(e.getMessage()); + } + } + + /** + * GET /catalog/status/{catalogId} + * Fragt den aktuellen Generierungsstatus aus der DB ab. + * + * Response: {"catalog_id": 42, "name": "ADSp 2017", + * "generation_status": "READY", "generation_error": null} + */ + @GET + @Path("/status/{catalogId}") + public Response status(@PathParam("catalogId") long catalogId) { + try { + OrdsCatalogStatusResponse statusResp = ordsClient.getCatalogStatus(catalogId); + if (statusResp == null || statusResp.getCatalogId() == null) { + return Response.status(Response.Status.NOT_FOUND) + .entity("{\"error\":\"Katalog nicht gefunden\",\"catalog_id\":" + + catalogId + "}") + .build(); + } + return Response.ok(statusResp).build(); + } catch (Exception e) { + LOG.errorf(e, "Fehler beim Status-Abruf für Katalog %d", catalogId); + return serverError(e.getMessage()); + } + } + + private Response badRequest(String message) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("{\"error\":\"" + escapeJson(message) + "\"}") + .build(); + } + + private Response serverError(String message) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("{\"error\":\"" + escapeJson(message) + "\"}") + .build(); + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "'").replace("\n", " "); + } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java b/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java new file mode 100644 index 0000000..be02237 --- /dev/null +++ b/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java @@ -0,0 +1,517 @@ +package de.frigosped.dc.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.frigosped.dc.client.OrdsClient; +import de.frigosped.dc.model.*; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.output.Response; +import dev.langchain4j.data.message.AiMessage; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.ws.rs.core.Response.Status; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Generiert einen Fragenkatalog aus einem Regelwerk-Dokument (z.B. ADSp, AGB). + * + * Chunked-Ansatz: Das Dokument wird in Abschnitte (~15k Zeichen) aufgeteilt. + * Pro Abschnitt wird ein KI-Aufruf gemacht (kürzerer Kontext → kein Timeout). + * Die Ergebnisse werden anschließend nach Kategoriename zusammengeführt. + * + * Asynchroner Ablauf: + * 1. startGeneration() → legt Katalog-Placeholder (GENERATING) in DB an, startet Background-Task + * 2. runGeneration() → OCR/Textextraktion → Chunking → KI pro Chunk → Merge → Persistierung + * 3. Status in DB → READY (Erfolg) oder ERROR (Fehler) + */ +@ApplicationScoped +public class CatalogGenerationService { + + private static final Logger LOG = Logger.getLogger(CatalogGenerationService.class); + + private static final int MAX_TEXT_CHARS = 120_000; + private static final int CHUNK_SIZE = 15_000; + + private static final Pattern JSON_PATTERN = + Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```|([{][\\s\\S]*[}])", + Pattern.DOTALL); + + @Inject + DocumentProcessingService documentProcessingService; + + @Inject + @RestClient + OrdsClient ordsClient; + + @Inject + @Named("catalog") + ChatLanguageModel catalogModel; + + @Inject + ObjectMapper objectMapper; + + // ========================================================================= + // Öffentliche API – sofortige Rückgabe (202-Muster) + // ========================================================================= + + public long startGeneration(byte[] fileBytes, + String mimeType, + String filename, + String catalogName, + String description, + long documentTypeId) { + + long catalogId = createCatalogPlaceholder(catalogName, description, documentTypeId); + LOG.infof("Katalog-Placeholder angelegt (ID %d), starte Hintergrund-Generierung für '%s'", + catalogId, catalogName); + + CompletableFuture.runAsync( + () -> runGeneration(catalogId, fileBytes, mimeType, filename, catalogName)); + + return catalogId; + } + + // ========================================================================= + // Hintergrund-Verarbeitung + // ========================================================================= + + private void runGeneration(long catalogId, byte[] fileBytes, + String mimeType, String filename, String catalogName) { + try { + LOG.infof("[Katalog %d] Starte Textextraktion aus '%s'...", catalogId, filename); + String rawText = documentProcessingService.extractText(fileBytes, mimeType, filename); + LOG.infof("[Katalog %d] Textextraktion abgeschlossen: %d Zeichen", catalogId, rawText.length()); + + String documentText = truncateIfNeeded(catalogId, rawText); + List chunks = splitIntoChunks(documentText, CHUNK_SIZE); + LOG.infof("[Katalog %d] Dokument in %d Chunk(s) à max. %d Zeichen aufgeteilt", + catalogId, chunks.size(), CHUNK_SIZE); + + // Pro Chunk: Kategorien+Fragen extrahieren, nach Kategoriename mergen + Map categoryMap = new LinkedHashMap<>(); + + for (int i = 0; i < chunks.size(); i++) { + int chunkNr = i + 1; + int totalChunks = chunks.size(); + LOG.infof("[Katalog %d] Chunk %d/%d an KI senden (%d Zeichen)...", + catalogId, chunkNr, totalChunks, chunks.get(i).length()); + + GeneratedCatalogStructure chunkResult = + generateStructureFromChunk(catalogName, chunks.get(i), chunkNr, totalChunks); + + if (chunkResult == null || chunkResult.getCategories() == null) { + LOG.warnf("[Katalog %d] Chunk %d lieferte kein Ergebnis – übersprungen", + catalogId, chunkNr); + continue; + } + + mergeInto(categoryMap, chunkResult.getCategories()); + LOG.infof("[Katalog %d] Chunk %d/%d fertig – %d Kategorien bisher gesamt", + catalogId, chunkNr, totalChunks, categoryMap.size()); + } + + if (categoryMap.isEmpty()) { + throw new IllegalStateException("KI hat für keinen Chunk Kategorien generiert."); + } + + GeneratedCatalogStructure merged = buildMergedStructure(categoryMap); + + int totalQuestions = merged.getCategories().stream() + .mapToInt(c -> c.getQuestions() == null ? 0 : c.getQuestions().size()) + .sum(); + LOG.infof("[Katalog %d] Merge abgeschlossen: %d Kategorien, %d Fragen", + catalogId, merged.getCategories().size(), totalQuestions); + + persistCategories(catalogId, merged); + updateStatus(catalogId, "READY", null); + + } catch (Exception e) { + LOG.errorf(e, "[Katalog %d] Generierung fehlgeschlagen", catalogId); + updateStatus(catalogId, "ERROR", + abbreviate(e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), 3900)); + } + } + + // ========================================================================= + // Chunk-Splitting + // ========================================================================= + + private List splitIntoChunks(String text, int maxChunkSize) { + if (text.length() <= maxChunkSize) return List.of(text); + + List chunks = new ArrayList<>(); + int start = 0; + + while (start < text.length()) { + int end = Math.min(start + maxChunkSize, text.length()); + + // Nicht mitten in einem Paragraph trennen – rückwärts nach Doppel-Newline suchen + if (end < text.length()) { + int boundary = text.lastIndexOf("\n\n", end); + if (boundary > start + maxChunkSize / 2) { + end = boundary + 2; + } else { + // Fallback: Satzende suchen + int sentEnd = Math.max( + text.lastIndexOf(". ", end), + text.lastIndexOf(".\n", end)); + if (sentEnd > start + maxChunkSize / 2) { + end = sentEnd + 2; + } + } + } + + chunks.add(text.substring(start, end).strip()); + start = end; + } + + return chunks; + } + + // ========================================================================= + // Merge: Kategorien nach normalisiertem Namen zusammenführen + // ========================================================================= + + private void mergeInto(Map categoryMap, + List newCategories) { + for (var cat : newCategories) { + if (cat.getName() == null || cat.getName().isBlank()) continue; + String key = normalizeKey(cat.getName()); + + if (categoryMap.containsKey(key)) { + var existing = categoryMap.get(key); + List merged = + new ArrayList<>(existing.getQuestions() != null ? existing.getQuestions() : List.of()); + if (cat.getQuestions() != null) merged.addAll(cat.getQuestions()); + existing.setQuestions(merged); + } else { + // Mutable List sicherstellen + if (cat.getQuestions() != null) { + cat.setQuestions(new ArrayList<>(cat.getQuestions())); + } + categoryMap.put(key, cat); + } + } + } + + private GeneratedCatalogStructure buildMergedStructure( + Map categoryMap) { + + List cats = new ArrayList<>(categoryMap.values()); + + // Reihenfolge und Nummerierung normalisieren + for (int i = 0; i < cats.size(); i++) { + cats.get(i).setOrderNr(i + 1); + List qs = cats.get(i).getQuestions(); + if (qs != null) { + for (int j = 0; j < qs.size(); j++) { + fillQuestionDefaults(qs.get(j), j + 1); + } + } + } + + var structure = new GeneratedCatalogStructure(); + structure.setCategories(cats); + return structure; + } + + private String normalizeKey(String name) { + return name.toLowerCase(Locale.GERMAN) + .replaceAll("[^a-z0-9äöüß ]", "") + .trim() + .replaceAll("\\s+", " "); + } + + // ========================================================================= + // Status-Updates via ORDS + // ========================================================================= + + private void updateStatus(long catalogId, String status, String error) { + try { + ordsClient.updateCatalogStatus(catalogId, + new OrdsCatalogStatusUpdateRequest(status, error)); + LOG.infof("[Katalog %d] Status → %s", catalogId, status); + } catch (Exception e) { + LOG.errorf(e, "[Katalog %d] Status-Update auf '%s' fehlgeschlagen", catalogId, status); + } + } + + // ========================================================================= + // ORDS: Katalog-Placeholder anlegen + // ========================================================================= + + private long createCatalogPlaceholder(String name, String description, long documentTypeId) { + var request = new OrdsCatalogCreateRequest(name, description, documentTypeId); + var response = ordsClient.createCatalog(request); + String body = response.readEntity(String.class); + + if (response.getStatus() != Status.CREATED.getStatusCode()) { + throw new RuntimeException( + "ORDS Katalog-Erstellung fehlgeschlagen (HTTP " + + response.getStatus() + "): " + body); + } + return extractId(body, "Katalog"); + } + + // ========================================================================= + // ORDS: Kategorien und Fragen persistieren + // ========================================================================= + + private void persistCategories(long catalogId, GeneratedCatalogStructure structure) { + for (var category : structure.getCategories()) { + long categoryId = createCategory(catalogId, category); + if (category.getQuestions() == null) continue; + for (var question : category.getQuestions()) { + createQuestion(categoryId, question); + } + } + } + + private long createCategory(long catalogId, + GeneratedCatalogStructure.GeneratedCategory cat) { + var request = new OrdsCategoryCreateRequest(cat.getName(), cat.getDescription(), cat.getOrderNr()); + var response = ordsClient.createCategory(catalogId, request); + String body = response.readEntity(String.class); + + if (response.getStatus() != Status.CREATED.getStatusCode()) { + throw new RuntimeException( + "ORDS Kategorie-Erstellung fehlgeschlagen (HTTP " + + response.getStatus() + "): " + body); + } + return extractId(body, "Kategorie '" + cat.getName() + "'"); + } + + private void createQuestion(long categoryId, + GeneratedCatalogStructure.GeneratedQuestion q) { + var request = new OrdsQuestionCreateRequest(); + request.setQuestionText(q.getQuestionText()); + request.setEvaluationType(q.getEvaluationType()); + request.setThreshold(q.getThreshold()); + request.setResultHandling(q.getResultHandling()); + request.setExample0Percent(q.getExample0Percent()); + request.setExample100Percent(q.getExample100Percent()); + request.setOrderNr(q.getOrderNr()); + + var response = ordsClient.createQuestion(categoryId, request); + if (response.getStatus() != Status.CREATED.getStatusCode()) { + String body = response.readEntity(String.class); + LOG.warnf("Frage konnte nicht angelegt werden (HTTP %d): %s", + response.getStatus(), body); + } + } + + // ========================================================================= + // KI-Aufruf pro Chunk + // ========================================================================= + + private GeneratedCatalogStructure generateStructureFromChunk(String catalogName, + String chunkText, + int chunkNr, + int totalChunks) { + List messages = List.of( + SystemMessage.from(buildChunkSystemPrompt(chunkNr, totalChunks)), + UserMessage.from( + "## Regelwerk: " + catalogName + + " (Abschnitt " + chunkNr + " von " + totalChunks + ")\n\n" + + chunkText + + "\n\n---\n\nErstelle jetzt den Fragenkatalog-Ausschnitt als JSON:") + ); + + Response response = catalogModel.generate(messages); + String rawJson = response.content().text(); + return parseCatalogStructure(rawJson, catalogName + " Chunk " + chunkNr); + } + + private String buildChunkSystemPrompt(int chunkNr, int totalChunks) { + return """ + Du bist ein Experte für Transportrecht, AGB-Analyse und Dokumentenprüfung. + + AUFGABE: Erstelle aus dem folgenden ABSCHNITT (%d von %d) eines Regelwerks + einen Fragenkatalog-Ausschnitt zur Dokumentenprüfung. + Der Katalog wird verwendet, um Transportdokumente auf Einhaltung des Regelwerks zu prüfen. + + ════════════════════════════════════════ + STRUKTUR (PFLICHT) + ════════════════════════════════════════ + • Erstelle 2–5 THEMATISCHE Kategorien NUR für diesen Abschnitt. + Beispiele: "Haftung", "Informationspflichten", "Fristen und Termine". + NICHT eine Kategorie pro Paragraph. + • Pro Kategorie: 3–15 EINZELNE, KONKRETE Fragen. + Jede Frage prüft GENAU EINE Anforderung. + KEINE Mega-Fragen wie "Sind alle Anforderungen aus §X erfüllt?". + • Decke ALLE Klauseln/Paragraphen dieses Abschnitts ab. + Sehr ähnliche Klauseln dürfen zu einer Frage zusammengefasst werden. + + ════════════════════════════════════════ + BEISPIEL einer korrekten Frage (dieses Muster IMMER einhalten): + ════════════════════════════════════════ + { + "question_text": "Ist der Haftungshöchstbetrag für Güterschäden auf 8,33 SZR/kg Rohgewicht begrenzt?", + "evaluation_type": "BINARY", + "threshold": 80, + "result_handling": "ABWEICHUNG", + "example_0_percent": "Dokument enthält keine Haftungsbegrenzung; der Spediteur haftet unbeschränkt nach allgemeinem Deliktsrecht ohne Mengenbezug.", + "example_100_percent": "Dokument regelt ausdrücklich: Haftung max. 8,33 SZR pro kg Rohgewicht gem. §431 HGB; bei Totalverlust Sendung max. 1,25 Mio. Euro." + } + + ════════════════════════════════════════ + REGELN FÜR DIE FELDER + ════════════════════════════════════════ + evaluation_type: + "SCORE" → graduell erfüllbar (Vollständigkeit, Klarheit, Umfang) + "BINARY" → Ja/Nein (Klausel vorhanden oder nicht, Wert genannt oder nicht) + + threshold (0–100): + 80 → haftungsrelevant oder rechtlich verbindlich + 70 → wichtig, aber nicht haftungskritisch + 60 → empfohlen + + result_handling: + "ABWEICHUNG" → Fehlen hat rechtliche oder vertragliche Konsequenzen + "HINWEIS" → empfohlen, kein Pflichtverstoß + + ════════════════════════════════════════ + PFLICHTREGELN FÜR BEISPIELE + ════════════════════════════════════════ + example_0_percent und example_100_percent sind IMMER auszufüllen. + Leere Felder oder Texte wie "nicht erfüllt" sind VERBOTEN. + + example_0_percent: Was fehlt konkret? + → "Dokument enthält keine Regelung zu [konkretem Merkmal]." + → "Es fehlt jeder Hinweis auf [konkreten Wert/Klausel/Verweis]." + + example_100_percent: Was ist konkret vorhanden? + → "Dokument enthält: [Beispielformulierung, Zahlenwert, Paragraph]." + → "Klar geregelt: [Merkmal] mit [konkretem Wert oder Beispieltext]." + + ════════════════════════════════════════ + AUSGABE + ════════════════════════════════════════ + Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Objekt. + Kein Text, keine Erklärung davor oder danach. + + { + "categories": [ + { + "name": "Kategoriename (max. 80 Zeichen)", + "description": "Welche Paragraphen/Themen diese Kategorie abdeckt", + "order_nr": 1, + "questions": [ + { + "question_text": "...", + "evaluation_type": "SCORE", + "threshold": 70, + "result_handling": "ABWEICHUNG", + "example_0_percent": "...", + "example_100_percent": "...", + "order_nr": 1 + } + ] + } + ] + } + """.formatted(chunkNr, totalChunks); + } + + // ========================================================================= + // JSON-Parsing + // ========================================================================= + + private GeneratedCatalogStructure parseCatalogStructure(String rawJson, String context) { + try { + String json = extractJson(rawJson); + GeneratedCatalogStructure structure = + objectMapper.readValue(json, GeneratedCatalogStructure.class); + + if (structure.getCategories() != null) { + for (int i = 0; i < structure.getCategories().size(); i++) { + var cat = structure.getCategories().get(i); + if (cat.getOrderNr() == 0) cat.setOrderNr(i + 1); + if (cat.getQuestions() != null) { + for (int j = 0; j < cat.getQuestions().size(); j++) { + fillQuestionDefaults(cat.getQuestions().get(j), j + 1); + } + } + } + } + return structure; + + } catch (Exception e) { + LOG.errorf("JSON-Parsing fehlgeschlagen für '%s': %s", context, e.getMessage()); + throw new RuntimeException("KI-Antwort konnte nicht geparst werden: " + e.getMessage(), e); + } + } + + private void fillQuestionDefaults(GeneratedCatalogStructure.GeneratedQuestion q, int orderNr) { + if (q.getOrderNr() == 0) q.setOrderNr(orderNr); + if (q.getEvaluationType() == null) q.setEvaluationType("SCORE"); + if (q.getThreshold() == null) q.setThreshold(70); + if (q.getResultHandling() == null) q.setResultHandling("ABWEICHUNG"); + if (isBlank(q.getExample0Percent())) + q.setExample0Percent( + "Dokument enthält keine Regelung zur Anforderung: " + + abbreviate(q.getQuestionText(), 120)); + if (isBlank(q.getExample100Percent())) + q.setExample100Percent( + "Anforderung vollständig und klar erfüllt: " + + abbreviate(q.getQuestionText(), 120)); + } + + private String extractJson(String text) { + if (text == null || text.isBlank()) + throw new IllegalArgumentException("Leere KI-Antwort"); + + Matcher m = JSON_PATTERN.matcher(text); + if (m.find()) { + String candidate = m.group(1) != null ? m.group(1) : m.group(2); + if (candidate != null && !candidate.isBlank()) return candidate.trim(); + } + int start = text.indexOf('{'); + int end = text.lastIndexOf('}'); + if (start >= 0 && end > start) return text.substring(start, end + 1); + throw new IllegalArgumentException("Kein JSON in KI-Antwort gefunden"); + } + + // ========================================================================= + // Hilfsmethoden + // ========================================================================= + + private String truncateIfNeeded(long catalogId, String text) { + if (text.length() <= MAX_TEXT_CHARS) return text; + LOG.warnf("[Katalog %d] Text (%d Zeichen) überschreitet Limit – kürze auf %d", + catalogId, text.length(), MAX_TEXT_CHARS); + return text.substring(0, MAX_TEXT_CHARS) + + "\n\n[... Dokument wurde aus Kapazitätsgründen gekürzt ...]"; + } + + @SuppressWarnings("unchecked") + private long extractId(String responseBody, String context) { + try { + Map map = objectMapper.readValue(responseBody, Map.class); + Object id = map.get("id"); + if (id instanceof Number n) return n.longValue(); + throw new IllegalStateException("Kein 'id'-Feld in ORDS-Antwort"); + } catch (Exception e) { + throw new RuntimeException( + "ID-Parsing fehlgeschlagen für " + context + ": " + responseBody, e); + } + } + + private String abbreviate(String s, int maxLen) { + if (s == null) return ""; + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "…"; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java b/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java index 2a9e543..1b99d28 100644 --- a/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java +++ b/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java @@ -367,10 +367,14 @@ public class MarkdownExportService { } /** - * Bewegt y um n Punkte nach unten (fügt vertikalen Leerraum ein). - * Ruft kein ensureSpace() auf — bei Seitenende greift das folgende renderLine/flushText. + * Fügt n Punkte vertikalen Abstand ein. + * Passt nicht mehr auf die Seite → newPage() (Abstand wird nicht auf die neue Seite + * übertragen, damit Überschriften nicht mit riesigem Leerraum oben erscheinen). */ - void skip(float n) { y -= n; } + void skip(float n) throws Exception { + if (y - n < MARGIN) newPage(); + else y -= n; + } // ── Emoji → Java2D-Shape ────────────────────────────────────────────── /** @@ -733,13 +737,13 @@ public class MarkdownExportService { } if (line.startsWith("#### ")) { - skip(4); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(2); + skip(10); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(4); } else if (line.startsWith("### ")) { - skip(6); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(3); + skip(20); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(6); } else if (line.startsWith("## ")) { - skip(8); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(4); + skip(28); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(8); } else if (line.startsWith("# ")) { - skip(10); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(6); + skip(20); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(1); } else if (line.equals("---")) { skip(4); ensureSpace(2); diff --git a/dc-backend/src/main/resources/application.properties b/dc-backend/src/main/resources/application.properties index 097bdd4..b9d65bc 100644 --- a/dc-backend/src/main/resources/application.properties +++ b/dc-backend/src/main/resources/application.properties @@ -23,6 +23,12 @@ dc.ai.main.timeout=300s dc.ai.main.max-retries=2 dc.ai.main.temperature=0.1 +# Katalog-Generierungsmodell: längerer Timeout für vollständige Katalog-Analyse +dc.ai.catalog.model=qwen3.6:35b-a3b-q4_K_M +dc.ai.catalog.timeout=1200s +dc.ai.catalog.max-retries=1 +dc.ai.catalog.temperature=0.1 + # OCR-Modell: Bildtext-Extraktion aus PDF-Seiten dc.ai.ocr.model=qwen3.6:35b-a3b-q4_K_M dc.ai.ocr.timeout=120s diff --git a/sqlrun.sh b/sqlrun.sh new file mode 100644 index 0000000..83a5a31 --- /dev/null +++ b/sqlrun.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Führt ein SQL-Skript gegen die Frigosped DC-Datenbank aus. +# +# Verwendung: +# ./sqlrun.sh "Scripts/DC_UTILS_PKG.sql" +# ./sqlrun.sh "Scripts/DC_UTILS_PKG.sql" "Scripts/DC_BACKEND_PKG.sql" +# ./sqlrun.sh # ohne Argument: interaktive SQLcl-Session + +set -euo pipefail + +DB_USER="wksp_ai" +DB_PASS='s!)löyx209aasgtrasdasiudkash5235FDSXljLJ' +DB_HOST="10.75.10.171" +DB_PORT="32710" +DB_SID="freepdb1" +CONNECT="${DB_USER}/${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_SID}" + +export NLS_LANG="GERMAN_GERMANY.AL32UTF8" + +cd "$(dirname "$0")" + +if [ $# -eq 0 ]; then + echo "Interaktive SQLcl-Session (Strg+D zum Beenden)" + exec sql "$CONNECT" +fi + +for script in "$@"; do + echo ">>> $script" + sql "$CONNECT" @"$script" + echo +done