feat: Add catalog generation functionality and related models
- Implemented CatalogResource for handling catalog generation requests and status checks. - Created CatalogGenerationService to manage the asynchronous generation process. - Added models for catalog generation responses, requests, and structured catalog data. - Introduced utility scripts for database interaction and Kubernetes pod retrieval. - Enhanced error handling and logging throughout the catalog generation workflow.
This commit is contained in:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
/
|
||||
|
||||
|
||||
47
Scripts/DC_CATALOG_GEN_ALTER.sql
Normal file
47
Scripts/DC_CATALOG_GEN_ALTER.sql
Normal file
@@ -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;
|
||||
231
Scripts/DC_UTILS_PKG.sql
Normal file
231
Scripts/DC_UTILS_PKG.sql
Normal file
@@ -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;
|
||||
/
|
||||
*/
|
||||
@@ -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;
|
||||
/
|
||||
|
||||
10
dc-backend/getPods.sh
Normal file
10
dc-backend/getPods.sh
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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": <new_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": <new_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": <new_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)
|
||||
// =========================================================================
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<GeneratedCategory> categories;
|
||||
|
||||
public List<GeneratedCategory> getCategories() { return categories; }
|
||||
public void setCategories(List<GeneratedCategory> categories) { this.categories = categories; }
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class GeneratedCategory {
|
||||
private String name;
|
||||
private String description;
|
||||
private int orderNr;
|
||||
private List<GeneratedQuestion> 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<GeneratedQuestion> getQuestions() { return questions; }
|
||||
public void setQuestions(List<GeneratedQuestion> 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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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", " ");
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String, GeneratedCatalogStructure.GeneratedCategory> 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<String> splitIntoChunks(String text, int maxChunkSize) {
|
||||
if (text.length() <= maxChunkSize) return List.of(text);
|
||||
|
||||
List<String> 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<String, GeneratedCatalogStructure.GeneratedCategory> categoryMap,
|
||||
List<GeneratedCatalogStructure.GeneratedCategory> 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<GeneratedCatalogStructure.GeneratedQuestion> 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<String, GeneratedCatalogStructure.GeneratedCategory> categoryMap) {
|
||||
|
||||
List<GeneratedCatalogStructure.GeneratedCategory> cats = new ArrayList<>(categoryMap.values());
|
||||
|
||||
// Reihenfolge und Nummerierung normalisieren
|
||||
for (int i = 0; i < cats.size(); i++) {
|
||||
cats.get(i).setOrderNr(i + 1);
|
||||
List<GeneratedCatalogStructure.GeneratedQuestion> 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<ChatMessage> 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<AiMessage> 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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
31
sqlrun.sh
Normal file
31
sqlrun.sh
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user