initial commit

This commit is contained in:
Wolf G. Beckmann
2026-04-27 14:57:33 +02:00
commit 1816168d1f
60 changed files with 4615 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(quarkus dev *)",
"Bash(bash *)"
]
}
}

107
CLAUDE.md Normal file
View File

@@ -0,0 +1,107 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Dokumenten-Check** is a document pre-verification system for Frigosped (freight forwarding). It checks transport documents and general terms & conditions (AGB) against configurable question catalogs, with AI-powered document translation and evaluation. Built on **Oracle APEX** + **Oracle Database 23c**.
## Database Setup
```bash
# Connect to Oracle DB and run scripts in order:
sqlplus user/pass@db @"Scripts/Datenmodell DocumentCheck.sql"
sqlplus user/pass@db @"Scripts/Fragenkatalog-AGB anlegen.sql"
```
No build/compilation step — APEX applications deploy directly to Oracle instances.
## Architecture
### Data Model (10 tables in `Scripts/Datenmodell DocumentCheck.sql`)
**Configuration hierarchy:**
- `dc_document_types``dc_question_catalogs``dc_question_categories``dc_questions`
**Execution hierarchy:**
- `dc_projects``dc_project_documents``dc_results`
**Supporting:** `dc_users`, `dc_user_roles`, `dc_reference_documents`
### Key Patterns
- **Audit triggers on all tables**: Every table has BEFORE INSERT/UPDATE triggers that set `created_at`, `created_by`, `updated_at`, `updated_by`, `row_version`. User identity comes from `APEX_APPLICATION.G_USER`.
- **Oracle Identity columns**: All PKs use `GENERATED BY DEFAULT AS IDENTITY`.
- **Config-driven evaluation**: Each question defines `evaluation_type`, `threshold`, `result_on_deviation` (ABWEICHUNG/HINWEIS), plus example answers for 0%/100% cases.
- **Async processing**: Projects progress through `PENDING → IN_PROGRESS → COMPLETED` with a 0100% `processing_progress` field and email notification on completion.
- **Filestorage**: `dc_project_documents` stores the`original_file` (BLOB) with MIME type and filename metadata. The original text `original_text` (CLOB) after after OCR and conversion to Markdown and the `translated_file` after Translation to German (CLOB)
### Result Types
- `OK`, `UNKLAR` (unclear), `NOK` (not OK)
- Boolean flags: `is_deviation`, `is_warning`
### User Roles
- `ADMIN` — configuration management
- `AUDITOR` — document verification
## Key Files
| File | Purpose |
|------|---------|
| `Scripts/Datenmodell DocumentCheck.sql` | Full DB schema: 10 tables + 8 triggers |
| `Scripts/Fragenkatalog-AGB anlegen.sql` | Sample AGB question catalog (6 categories, 25 questions) |
| `Scripts/ORDS REST Services.sql` | All 11 ORDS REST endpoints (idempotent, run after schema) |
| `Doku/Projektbeschreibung.md` | German requirements specification |
| `Doku/Dokumenten-Check.md` | Data model + UI dialog specifications |
## Backend Service (Quarkus)
Located in `dc-backend/`. Java 21, Quarkus 3.15.1, LangChain4J 0.36.2.
```bash
# Dev-Modus (hot reload)
cd dc-backend
mvn quarkus:dev
# Produktion bauen
mvn package -DskipTests
java -jar target/quarkus-app/quarkus-run.jar
```
**Trigger-Endpunkt:**
```bash
POST http://localhost:8090/check/{projectId}
# → 202 Accepted, Verarbeitung läuft asynchron
```
### Backend-Architektur
```
CheckResource POST /check/{projectId} → 202, Fire & Forget
CheckOrchestrationService Haupt-Ablauf (Phase A: OCR/Übersetzung, Phase B: Auswertung)
DocumentProcessingService PDF→KI-OCR, DOCX→POI→Markdown, ODT→ODF-Toolkit→Markdown
EvaluationService translate() + evaluate() via LangChain4J ChatLanguageModel
OrdsClient (REST Client) MicroProfile REST Client für alle /api/dc/-Endpunkte
AiModelProducer (CDI) @Named("main") gpt-oss:20b + @Named("ocr") quen3.5:9b
```
### KI-Modelle
| Qualifier | Modell | Zweck |
|-----------|--------|-------|
| `@Named("main")` | `gpt-oss:20b` | Übersetzung + Fragenauswertung |
| `@Named("ocr")` | `quen3.5:9b` | PDF-Seiten als Bild → Markdown-Text |
Beide sprechen `https://ollama.aquantico.de` an (Ollama-API `/api/chat`) mit `X-API-KEY`-Header.
Konfiguration in `dc-backend/src/main/resources/application.properties`.
### Verarbeitungsablauf
1. ORDS: Projekt laden + `status → IN_PROGRESS`
2. ORDS: Dokumente + Fragen laden
3. ORDS: Alte Ergebnisse löschen (Re-Processing-fähig)
4. **Phase A** (pro Dokument): Download → OCR/Konvertierung → Übersetzung → ORDS `PUT /texts`
5. **Phase B** (pro Dokument × Frage): Auswertung → Abweichungs-Berechnung → ORDS `POST /results`
6. ORDS: `status → COMPLETED`
Abweichung/Hinweis wird in Java berechnet (nicht durch KI): `score < threshold` → prüfe `result_handling` (ABWEICHUNG/HINWEIS).

225
Doku/Dokumenten-Check.md Normal file
View File

@@ -0,0 +1,225 @@
# Dokumenten-Check
Projekt: https://projects.zoho.eu/portal/aquantico#project/323095000000333012
## Datenmodell
```mermaid
```
```mermaid
erDiagram
dc_users {
number id PK
varchar2_50 username UK
varchar2_100 email UK
varchar2_255 password_hash
varchar2_20 role
number is_active
date created_at
date updated_at
}
dc_user_roles {
number id PK
varchar2_20 role_name UK
clob description
}
dc_document_types {
number id PK
varchar2_100 name
clob description
}
dc_reference_documents {
number id PK
number uploaded_by FK
number document_type_id FK
varchar2_100 name
clob description
varchar2_500 file_path
date upload_date
varchar2_100 mime_type
}
dc_question_catalogs {
number id PK
number created_by_user FK
number based_on_reference FK
number document_type_id FK
varchar2_100 name
varchar2_4000 description
}
dc_question_categories {
number id PK
number catalog_id FK
varchar2_100 name
clob description
}
dc_questions {
number id PK
number category_id FK
clob question_text
varchar2_20 evaluation_type
number threshold
varchar2_20 result_handling
clob example_0_percent
clob example_100_percent
}
dc_projects {
number id PK
number catalog_id FK
number created_by_user FK
varchar2_100 name
clob description
varchar2_20 status
number progress
date completed_at
varchar2_100 notification_email
}
dc_project_documents {
number id PK
number project_id FK
number document_type_id FK
blob original_file
blob translated_file
varchar2_255 filename
varchar2_100 mime_type
date uploaded_at
}
dc_results {
number id PK
number project_id FK
number question_id FK
number doc_id FK
clob answer
number score
varchar2_20 result_type
number deviation
number warning
clob the_comment
date evaluated_at
}
dc_users ||--o{ dc_reference_documents : "uploads"
dc_users ||--o{ dc_question_catalogs : "creates"
dc_users ||--o{ dc_projects : "creates"
dc_document_types ||--o{ dc_reference_documents : "used_in"
dc_document_types ||--o{ dc_question_catalogs : "used_in"
dc_document_types ||--o{ dc_project_documents : "used_in"
dc_reference_documents ||--o{ dc_question_catalogs : "based_on"
dc_question_catalogs ||--o{ dc_question_categories : "contains"
dc_question_categories ||--o{ dc_questions : "contains"
dc_question_catalogs ||--o{ dc_projects : "uses"
dc_projects ||--o{ dc_project_documents : "contains"
dc_projects ||--o{ dc_results : "has"
dc_questions ||--o{ dc_results : "answered_in"
dc_project_documents ||--o{ dc_results : "evaluated_in"
```
## Backend
```mermaid
graph TB
subgraph Browser["🌐 Browser"]
APEX["Oracle APEX\nFrontend UI"]
end
subgraph OracleDB["🗄️ Oracle Database 23c"]
ORDS["ORDS REST Services\n/api/dc/..."]
subgraph Schema["DB Schema"]
Config["Konfiguration\ndc_document_types\ndc_question_catalogs\ndc_question_categories\ndc_questions"]
Exec["Ausführung\ndc_projects\ndc_project_documents\ndc_results"]
Users["dc_users\ndc_user_roles\ndc_reference_documents"]
end
ORDS <--> Schema
end
subgraph Backend["☕ Quarkus Backend (Port 8090)"]
CheckRes["CheckResource\nPOST /check/{projectId}"]
Orch["CheckOrchestrationService\nHauptablauf (async)"]
DocProc["DocumentProcessingService\nPDF/DOCX/ODT → Markdown"]
EvalSvc["EvaluationService\nÜbersetzung + Auswertung"]
OrdsClient["OrdsClient\nMicroProfile REST Client"]
CheckRes -->|"202 Accepted\nFire & Forget"| Orch
Orch --> DocProc
Orch --> EvalSvc
Orch --> OrdsClient
end
subgraph AI["🤖 Ollama (ollama.aquantico.de)"]
MainModel["gpt-oss:20b\nÜbersetzung + Fragenauswertung"]
OcrModel["quen3.5:9b\nPDF Vision-OCR"]
end
APEX -->|"Dokument hochladen\nProjekt anlegen"| ORDS
APEX -->|"POST /check/{id}"| CheckRes
APEX -->|"Ergebnisse lesen"| ORDS
OrdsClient -->|"Projekt/Dok/Fragen laden\nErgebnisse speichern"| ORDS
DocProc -->|"Base64-Bild\nX-API-KEY"| OcrModel
EvalSvc -->|"Text + Prompt\nX-API-KEY"| MainModel
subgraph PhaseA["Phase A (pro Dokument)"]
A1["Download BLOB"] --> A2["OCR / Konvertierung\n→ Markdown"]
A2 --> A3["Übersetzung → Deutsch"]
A3 --> A4["Texte speichern (ORDS)"]
end
subgraph PhaseB["Phase B (pro Dok × Frage)"]
B1["KI-Auswertung\nscore 0100"] --> B2["Abweichung/Hinweis\nberechnen (Java)"]
B2 --> B3["Ergebnis speichern\n(ORDS)"]
end
Orch --> PhaseA
Orch --> PhaseB
style Browser fill:#dbeafe
style OracleDB fill:#fef3c7
style Backend fill:#dcfce7
style AI fill:#f3e8ff
style PhaseA fill:#f0f9ff
style PhaseB fill:#f0f9ff
```
## Dialoge
### Fragenkatalog
#### Tabelle
* Name
* Beschreibung
* Referenz-Dokument
#### Dialog
* Name (Im Kopf aussuchen)
* Referenz-Dokument Upload (Im Kopf)
* Master/Detail
* Fragen-Kategorien
* Fragen
### Projekte
#### Tabelle
* Name
* Beschreibung
* Anz ok,nok,undefiniert
#### Dialog
* Name (Im Kopf Aussuchen)
* Beschreibung
* Master/Details
* Dokumente
* Ergebnisse

View File

@@ -0,0 +1,58 @@
### Ziel
Unterstützende Vorprüfung von Dokumenten
- TU-Dokumenten gegen eine manuell zu konfigurierenden Fragenkatalog
- Transportbedingungen gegen die **Allgemeinen Deutschen Spediteurbedingungen (ADSp)
**
### Leistungsumfang
Erstellen einer APEX-Applikation mit folgenden Funktionalitäten
**Administration**
- Konfigurierbare **Fragenk****a****taloge** (TU-Dokumente / Transportbedingungen)
- Es werden in Kategorien Fragen definiert und angegeben wie die Frage bewertet werden soll.
- Beispiele für eine 0% Bewertung und Beispiele für eine 100% Bewertung
- Schwellwert für OK / Unklar / Nicht OK
- Umgang mit dem Ergebnis der Antwort
- Markieren als Abweichung
- Ausgabe als Hinweis (z.B. um Strafzahlungen explizit anzuzeigen)
- Hinterlegung von Referenzdokumenten auf die sich die Fragen beziehen können. (z.B. die ADSp)
- Benutzeradministration
- Anlegen und Verwalten von Benutzern
- Wer darf Konfigurationen hinterlegen?
- Wer darf Dokumente prüfen?
- Hinterlegen einer E-Mail für die Benachrichtigung
**Prüf-Funktionalität**
- Anlegen eines Prüfungsauftrags
- Laden von Dokumenten
- Angabe, welche Prüfung erfolgen soll
- Start der Prüfung
- Prüfung als Hintergrundverarbeitung
- Dokumente ins Deutsche Übersetzen
- Dokumente entsprechend des vorgegebenen Fragenkataloges prüfen
- Pro Dokument ein Prüfprotokoll erstellen
- Wenn alle Prüfungen abgeschlossen sind
- Abschließendes Prüfungsprotokoll erstellen
- Benutzer per Email über die abgeschlossene Prüfung Informieren
- Alle Prüfungsprotokolle werden angehängt, auch das abschließende
- Link, um in die Applikation zum Prüfungsauftrag zu springen um die Prüfungsergebnisse, Dokumente original und übersetzt direkt anzeigen zu lassen
- Prüfungsübersicht
- Anzeige der laufenden und fertiggestellten Prüfungen
- Bei laufenden Prüfungen den Fortschritt
- Sprung zum Prüfungsauftrag
- Prüfungsauftrag anzeigen
- Darstellen welcher Fragenkatalog auf die Dokumente angewendet wurde
- Auflisten der Dokumente (original und übersetzt) mit der Möglichkeit der Anzeige und des Downloads
- Darstellen der Prüfungsergebnisse (oder Fehler bei der Verarbeitung) pro Dokument
ACHTUNG: Eine KI kann Fehler machen. Die Ergebnisse können nicht rechtssicher sein.

297
Scripts/DC_BACKEND_PKG.sql Normal file
View File

@@ -0,0 +1,297 @@
-- =============================================================================
-- DC_BACKEND_PKG
-- PL/SQL-Schnittstelle zum Quarkus dc-backend (Kubernetes Service dc-backend:8090)
--
-- Voraussetzungen:
-- - APEX_WEB_SERVICE-Berechtigung für das Schema:
-- GRANT EXECUTE ON APEX_240100.APEX_WEB_SERVICE TO <schema>; -- APEX-Version anpassen
-- - ACL für ausgehende HTTP-Verbindung im selben Namespace:
-- DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
-- host => 'dc-backend', lower_port => 8090, upper_port => 8090,
-- ace => xs$ace_type(privilege_list => xs$name_list('connect','resolve'),
-- principal_name => '<schema>', principal_type => xs_acl.ptype_db));
--
-- Deployment:
-- sqlplus user/pass@db @"Scripts/DC_BACKEND_PKG.sql"
-- =============================================================================
-- =============================================================================
-- Package Spec
-- =============================================================================
CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS
-- Basis-URL des dc-backend Kubernetes Service
c_base_url CONSTANT VARCHAR2(200) := 'http://dc-backend:8090';
-- API-Key (X-API-KEY Header).
-- Leer = kein Auth (Dev-Modus).
-- Zur Laufzeit setzen: DC_BACKEND_PKG.g_api_key := 'mein-key';
g_api_key VARCHAR2(500) := '';
-- -------------------------------------------------------------------------
-- Startet die KI-gestützte Dokumentenprüfung für ein Projekt (asynchron).
-- Das Projekt muss im Status PENDING sein.
-- POST /check/{p_project_id} → 202 Accepted
--
-- p_project_id ID des dc_projects-Eintrags
-- p_status_code HTTP-Statuscode der Antwort (202 = OK, 409 = nicht PENDING)
-- p_response JSON-Antwort des Backend
-- -------------------------------------------------------------------------
PROCEDURE start_check(
p_project_id IN NUMBER,
p_status_code OUT NUMBER,
p_response OUT VARCHAR2
);
-- -------------------------------------------------------------------------
-- Health-Check: prüft ob der dc-backend Service erreichbar ist.
-- GET /check/health → 200 UP
--
-- Rückgabe: TRUE = Service läuft, FALSE = nicht erreichbar
-- -------------------------------------------------------------------------
FUNCTION health_check RETURN BOOLEAN;
-- -------------------------------------------------------------------------
-- Startet die Prüfung und wirft eine Exception wenn nicht 202 zurückkommt.
-- Convenience-Wrapper für direkte APEX-Button-Aufrufe.
-- -------------------------------------------------------------------------
PROCEDURE start_check_or_raise(
p_project_id IN NUMBER
);
-- -------------------------------------------------------------------------
-- Fortschritts-Record
-- -------------------------------------------------------------------------
TYPE t_project_status IS RECORD (
status dc_projects.status%TYPE,
processing_progress dc_projects.progress%TYPE,
is_completed BOOLEAN,
is_running BOOLEAN,
is_pending BOOLEAN
);
-- -------------------------------------------------------------------------
-- Liest Status und Fortschritt eines Projekts direkt aus der DB.
-- Kein REST-Aufruf sofort und transaktionssicher.
--
-- Beispiel:
-- l_s := DC_BACKEND_PKG.get_status(1);
-- DBMS_OUTPUT.PUT_LINE(l_s.status || ' ' || l_s.processing_progress || '%');
-- -------------------------------------------------------------------------
FUNCTION get_status(
p_project_id IN NUMBER
) RETURN t_project_status;
-- -------------------------------------------------------------------------
-- Wartet (polling) bis das Projekt COMPLETED oder ein Timeout erreicht ist.
-- Wirft eine Exception bei Timeout oder wenn das Projekt nicht gefunden wird.
--
-- p_timeout_sec Max. Wartezeit in Sekunden (Default: 1800 = 30 Min.)
-- p_interval_sec Polling-Intervall in Sekunden (Default: 5)
-- -------------------------------------------------------------------------
PROCEDURE wait_for_completion(
p_project_id IN NUMBER,
p_timeout_sec IN NUMBER DEFAULT 1800,
p_interval_sec IN NUMBER DEFAULT 5
);
END DC_BACKEND_PKG;
/
-- =============================================================================
-- Package Body
-- =============================================================================
CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS
-- -------------------------------------------------------------------------
-- Setzt gemeinsame Request-Header (Content-Type + optional API-Key)
-- -------------------------------------------------------------------------
PROCEDURE set_headers IS
l_idx PLS_INTEGER := 1;
BEGIN
APEX_WEB_SERVICE.G_REQUEST_HEADERS.DELETE;
APEX_WEB_SERVICE.G_REQUEST_HEADERS(l_idx).NAME := 'Content-Type';
APEX_WEB_SERVICE.G_REQUEST_HEADERS(l_idx).VALUE := 'application/json';
l_idx := l_idx + 1;
IF g_api_key IS NOT NULL AND LENGTH(TRIM(g_api_key)) > 0 THEN
APEX_WEB_SERVICE.G_REQUEST_HEADERS(l_idx).NAME := 'X-API-KEY';
APEX_WEB_SERVICE.G_REQUEST_HEADERS(l_idx).VALUE := g_api_key;
END IF;
END set_headers;
-- -------------------------------------------------------------------------
PROCEDURE start_check(
p_project_id IN NUMBER,
p_status_code OUT NUMBER,
p_response OUT VARCHAR2
) IS
l_url VARCHAR2(500);
l_response CLOB;
BEGIN
l_url := c_base_url || '/check/' || TO_CHAR(p_project_id);
set_headers();
l_response := APEX_WEB_SERVICE.MAKE_REST_REQUEST(
p_url => l_url,
p_http_method => 'POST',
p_body => '{}' -- leerer Body, projectId ist im Pfad
);
p_status_code := APEX_WEB_SERVICE.G_STATUS_CODE;
p_response := SUBSTR(l_response, 1, 4000);
EXCEPTION
WHEN OTHERS THEN
p_status_code := -1;
p_response := 'Verbindungsfehler: ' || SQLERRM;
END start_check;
-- -------------------------------------------------------------------------
FUNCTION health_check RETURN BOOLEAN IS
l_url VARCHAR2(500);
l_response CLOB;
l_status NUMBER;
BEGIN
l_url := c_base_url || '/check/health';
set_headers();
l_response := APEX_WEB_SERVICE.MAKE_REST_REQUEST(
p_url => l_url,
p_http_method => 'GET'
);
l_status := APEX_WEB_SERVICE.G_STATUS_CODE;
RETURN l_status = 200;
EXCEPTION
WHEN OTHERS THEN
RETURN FALSE;
END health_check;
-- -------------------------------------------------------------------------
PROCEDURE start_check_or_raise(
p_project_id IN NUMBER
) IS
l_status NUMBER;
l_response VARCHAR2(4000);
BEGIN
start_check(
p_project_id => p_project_id,
p_status_code => l_status,
p_response => l_response
);
IF l_status = 202 THEN
-- OK: Verarbeitung wurde gestartet
NULL;
ELSIF l_status = 409 THEN
RAISE_APPLICATION_ERROR(-20100,
'Projekt ' || p_project_id || ' ist nicht im Status PENDING.');
ELSIF l_status = 401 THEN
RAISE_APPLICATION_ERROR(-20101,
'Ungültiger API-Key für dc-backend.');
ELSIF l_status = -1 THEN
RAISE_APPLICATION_ERROR(-20102,
'dc-backend nicht erreichbar: ' || l_response);
ELSE
RAISE_APPLICATION_ERROR(-20103,
'Unerwarteter HTTP-Status ' || l_status || ': ' || l_response);
END IF;
END start_check_or_raise;
-- -------------------------------------------------------------------------
FUNCTION get_status(
p_project_id IN NUMBER
) RETURN t_project_status IS
l_rec t_project_status;
BEGIN
SELECT status, progress
INTO l_rec.status, l_rec.processing_progress
FROM dc_projects
WHERE id = p_project_id;
l_rec.is_completed := l_rec.status = 'COMPLETED';
l_rec.is_running := l_rec.status = 'IN_PROGRESS';
l_rec.is_pending := l_rec.status = 'PENDING';
RETURN l_rec;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(-20110,
'Projekt ' || p_project_id || ' nicht gefunden.');
END get_status;
-- -------------------------------------------------------------------------
PROCEDURE wait_for_completion(
p_project_id IN NUMBER,
p_timeout_sec IN NUMBER DEFAULT 1800,
p_interval_sec IN NUMBER DEFAULT 5
) IS
l_start TIMESTAMP := SYSTIMESTAMP;
l_status t_project_status;
BEGIN
LOOP
l_status := get_status(p_project_id);
EXIT WHEN l_status.is_completed;
IF EXTRACT(SECOND FROM (SYSTIMESTAMP - l_start))
+ EXTRACT(MINUTE FROM (SYSTIMESTAMP - l_start)) * 60
+ EXTRACT(HOUR FROM (SYSTIMESTAMP - l_start)) * 3600
> p_timeout_sec
THEN
RAISE_APPLICATION_ERROR(-20111,
'Timeout nach ' || p_timeout_sec || 's Projekt ' ||
p_project_id || ' noch im Status ' || l_status.status ||
' (' || l_status.processing_progress || '%).');
END IF;
DBMS_SESSION.SLEEP(p_interval_sec);
END LOOP;
END wait_for_completion;
END DC_BACKEND_PKG;
/
-- =============================================================================
-- Schnelltest (optional, auskommentiert)
-- =============================================================================
/*
-- Health-Check
BEGIN
IF DC_BACKEND_PKG.health_check() THEN
DBMS_OUTPUT.PUT_LINE('dc-backend: UP');
ELSE
DBMS_OUTPUT.PUT_LINE('dc-backend: NICHT ERREICHBAR');
END IF;
END;
/
-- Prüfung starten (Projekt-ID anpassen)
DECLARE
l_status NUMBER;
l_response VARCHAR2(4000);
BEGIN
DC_BACKEND_PKG.start_check(
p_project_id => 1,
p_status_code => l_status,
p_response => l_response
);
DBMS_OUTPUT.PUT_LINE('Status: ' || l_status);
DBMS_OUTPUT.PUT_LINE('Response: ' || l_response);
END;
/
*/

View File

@@ -0,0 +1,535 @@
drop table dc_users cascade constraints;
drop table dc_document_types cascade constraints;
drop table dc_user_roles cascade constraints;
drop table dc_question_catalogs cascade constraints;
drop table dc_question_categories cascade constraints;
drop table dc_questions cascade constraints;
drop table dc_reference_documents cascade constraints;
drop table dc_projects cascade constraints;
drop table dc_project_documents cascade constraints;
drop table dc_results cascade constraints;
-- create tables
create table dc_users (
id number generated by default on null as identity
constraint dc_users_id_pk primary key,
username varchar2(50 char)
constraint users_username_unq unique not null,
email varchar2(100 char)
constraint users_email_unq unique not null,
password_hash varchar2(255 char),
role varchar2(20 char),
is_active number default on null 1,
created_at date default on null sysdate,
updated_at date default on null sysdate,
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
create table dc_document_types (
id number generated by default on null as identity
constraint dc_document_types_id_pk primary key,
name varchar2(100 char) not null,
description clob,
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
create table dc_reference_documents (
id number generated by default on null as identity
constraint dc_reference_documents_id_pk primary key,
uploaded_by number constraint dc_reference_documents_uploaded_by_fk
references dc_users,
document_type_id number constraint dc_reference_documents_doc_type_fk
references dc_document_types,
name varchar2(100 char) not null,
description clob,
file_path varchar2(500 char),
upload_date date default on null sysdate,
mime_type varchar2(100 char),
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
-- table index
create index dc_reference_documents_i1 on dc_reference_documents (uploaded_by);
create table dc_user_roles (
id number generated by default on null as identity
constraint dc_user_roles_id_pk primary key,
role_name varchar2(20 char)
constraint user_roles_role_name_unq unique not null,
description clob,
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
create table dc_question_catalogs (
id number generated by default on null as identity
constraint dc_question_catalogs_id_pk primary key,
created_by_user number constraint dc_question_catalogs_created_by_fk
references dc_users,
based_on_reference number constraint dc_question_catalogs_reference_document_fk
references dc_reference_documents,
document_type_id number constraint dc_question_catalogs_doc_type_fk
references dc_document_types,
name varchar2(100 char) not null,
description varchar2(4000 char),
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
-- table index
create index dc_question_catalogs_i1 on dc_question_catalogs (created_by);
create table dc_question_categories (
id number generated by default on null as identity
constraint dc_question_categories_id_pk primary key,
catalog_id number constraint dc_question_categories_catalog_id_fk
references dc_question_catalogs,
name varchar2(100 char) not null,
description varchar2(4000 char),
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
-- table index
create index dc_question_categories_i1 on dc_question_categories (catalog_id);
create table dc_questions (
id number generated by default on null as identity
constraint dc_questions_id_pk primary key,
category_id number constraint dc_questions_category_id_fk
references dc_question_categories,
question_text clob not null,
evaluation_type varchar2(20 char),
threshold number,
result_handling varchar2(20 char),
example_0_percent clob,
example_100_percent clob,
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
-- table index
create index dc_questions_i1 on dc_questions (category_id);
create table dc_projects (
id number generated by default on null as identity
constraint dc_projects_id_pk primary key,
catalog_id number constraint dc_projects_catalog_id_fk
references dc_question_catalogs,
created_by_user number constraint dc_projects_created_by_fk
references dc_users,
name varchar2(100 char) not null,
description clob,
status varchar2(20 char) constraint dc_projects_status_ck
check (status in ('PENDING','IN_PROGRESS','COMPLETED')),
progress number default on null 0,
completed_at date,
notification_email varchar2(100 char),
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
-- table index
create index dc_projects_i1 on dc_projects (catalog_id);
create index dc_projects_i2 on dc_projects (created_by);
create table dc_project_documents (
id number generated by default on null as identity
constraint dc_project_documents_id_pk primary key,
project_id number constraint dc_project_documents_project_id_fk
references dc_projects,
document_type_id number constraint dc_project_documents_doc_type_fk
references dc_document_types,
catalog_id number constraint dc_project_documents_catalog_id_fk
references dc_question_catalogs,
original_file blob,
original_text clob,
translated_text clob,
filename varchar2(255 char) not null,
mime_type varchar2(100 char),
uploaded_at date default on null sysdate,
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
-- table index
create index dc_project_documents_i1 on dc_project_documents (project_id);
create table dc_results (
id number generated by default on null as identity
constraint dc_results_id_pk primary key,
project_id number constraint dc_results_project_id_fk
references dc_projects,
question_id number constraint dc_results_question_id_fk
references dc_questions,
doc_id number constraint dc_results_doc_id_fk
references dc_project_documents,
answer clob,
score number,
result_type varchar2(20 char) constraint dc_results_result_type_ck
check (result_type in ('OK','UNKLAR','NOK')),
deviation number default on null 0,
warning number default on null 0,
the_comment clob,
evaluated_at date default on null sysdate,
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
updated date not null,
updated_by varchar2(255 char) not null
);
-- table index
create index dc_results_i1 on dc_results (project_id);
create index dc_results_i2 on dc_results (question_id);
create index dc_results_i3 on dc_results (doc_id);
-- triggers
create or replace trigger dc_users_biu
before insert or update
on dc_users
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_users_biu;
/
create or replace trigger dc_user_roles_biu
before insert or update
on dc_user_roles
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_user_roles_biu;
/
create or replace trigger dc_document_types_biu
before insert or update
on dc_document_types
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_document_types_biu;
/
create or replace trigger dc_question_catalogs_biu
before insert or update
on dc_question_catalogs
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_question_catalogs_biu;
/
create or replace trigger dc_question_categories_biu
before insert or update
on dc_question_categories
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_question_categories_biu;
/
create or replace trigger dc_questions_biu
before insert or update
on dc_questions
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_questions_biu;
/
create or replace trigger dc_reference_documents_biu
before insert or update
on dc_reference_documents
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_reference_documents_biu;
/
create or replace trigger dc_projects_biu
before insert or update
on dc_projects
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_projects_biu;
/
create or replace trigger dc_project_documents_biu
before insert or update
on dc_project_documents
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_project_documents_biu;
/
create or replace trigger dc_results_biu
before insert or update
on dc_results
for each row
begin
if inserting then
:new.row_version := 1;
elsif updating then
:new.row_version := NVL(:old.row_version, 0) + 1;
end if;
if inserting then
:new.created := sysdate;
:new.created_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end if;
:new.updated := sysdate;
:new.updated_by := coalesce(sys_context('APEX$SESSION','APP_USER'),user);
end dc_results_biu;
/
-- Generated by Quick SQL 1.2.9 13.4.2026, 17:07:42
/*
users
id number generated by default on null as identity constraint users_id_pk primary key
username vc50 /nn /unique
email vc100 /nn /unique
password_hash vc255
role vc20
is_active num /default 1
created_at date /default sysdate
updated_at date /default sysdate
user_roles
id number generated by default on null as identity constraint user_roles_id_pk primary key
role_name vc20 /nn /unique
description clob
question_catalogs
id number generated by default on null as identity constraint question_catalogs_id_pk primary key
name vc100 /nn
description clob
created_by num /references users
created_at date /default sysdate
updated_at date /default sysdate
question_categories
id number generated by default on null as identity constraint question_categories_id_pk primary key
catalog_id num /references question_catalogs
name vc100 /nn
description clob
questions
id number generated by default on null as identity constraint questions_id_pk primary key
category_id num /references question_categories
question_text clob /nn
evaluation_type vc20
threshold num
result_handling vc20
example_0_percent clob
example_100_percent clob
reference_documents
id number generated by default on null as identity constraint reference_documents_id_pk primary key
name vc100 /nn
description clob
file_path vc500
uploaded_by num /references users
upload_date date /default sysdate
mime_type vc100
projects
id number generated by default on null as identity constraint projects_id_pk primary key
name vc100 /nn
description clob
catalog_id num /references question_catalogs
created_by num /references users
created_at date /default sysdate
status vc20 /check PENDING, IN_PROGRESS, COMPLETED
progress num /default 0
completed_at date
notification_email vc100
project_documents
id number generated by default on null as identity constraint project_documents_id_pk primary key
project_id num /references projects
original_file blob
translated_file blob
filename vc255 /nn
mime_type vc100
uploaded_at date /default sysdate
results
id number generated by default on null as identity constraint results_id_pk primary key
project_id num /references projects
question_id num /references questions
doc_id num /references project_documents
answer clob
score num
result_type vc20 /check OK, UNKLAR, NOK
deviation num /default 0
warning num /default 0
comment clob
evaluated_at date /default sysdate
Non-default options:
# settings = {"apex":"Y","auditcols":"Y","db":"23","drop":"Y","prefix":"Dc","pk":"IDENTITY","rowversion":"Y"}
*/

View File

@@ -0,0 +1,498 @@
DECLARE
-- IDs
v_doc_type_id dc_document_types.id%TYPE;
v_catalog_id dc_question_catalogs.id%TYPE;
v_cat1_id dc_question_categories.id%TYPE;
v_cat2_id dc_question_categories.id%TYPE;
v_cat3_id dc_question_categories.id%TYPE;
v_cat4_id dc_question_categories.id%TYPE;
v_cat5_id dc_question_categories.id%TYPE;
v_cat6_id dc_question_categories.id%TYPE;
BEGIN
-- ============================================================
-- 1. DOKUMENTENTYP
-- ============================================================
INSERT INTO dc_document_types (
name, description,
row_version, created, created_by, updated, updated_by
) VALUES (
'AGB',
'Allgemeine Geschäftsbedingungen vorformulierte Vertragsbedingungen gemäß §305 BGB',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
)
RETURNING id INTO v_doc_type_id;
-- ============================================================
-- 2. FRAGENKATALOG
-- ============================================================
INSERT INTO dc_question_catalogs (
created_by_user, based_on_reference, document_type_id,
name, description,
row_version, created, created_by, updated, updated_by
) VALUES (
NULL, NULL, v_doc_type_id,
'AGB-Prüfkatalog (BGB §305310)',
'Prüfung von Allgemeinen Geschäftsbedingungen auf Konformität mit dem deutschen AGB-Recht. '
|| 'Grundlage: §§305310 BGB sowie aktuelle BGH-Rechtsprechung.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
)
RETURNING id INTO v_catalog_id;
-- ============================================================
-- 3. KATEGORIEN
-- ============================================================
INSERT INTO dc_question_categories (
catalog_id, name, description,
row_version, created, created_by, updated, updated_by
) VALUES (
v_catalog_id,
'Einbeziehung & Transparenz',
'Prüfung ob die AGB wirksam einbezogen wurden und dem Transparenzgebot (§307 Abs. 1 S. 2 BGB) entsprechen.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
)
RETURNING id INTO v_cat1_id;
INSERT INTO dc_question_categories (
catalog_id, name, description,
row_version, created, created_by, updated, updated_by
) VALUES (
v_catalog_id,
'Haftung & Gewährleistung',
'Prüfung von Haftungsbeschränkungen und Gewährleistungsregelungen auf Zulässigkeit nach §309 Nr. 7/8 BGB.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
)
RETURNING id INTO v_cat2_id;
INSERT INTO dc_question_categories (
catalog_id, name, description,
row_version, created, created_by, updated, updated_by
) VALUES (
v_catalog_id,
'Zahlungs- & Lieferbedingungen',
'Prüfung von Zahlungsfristen, Verzugszinsen, Lieferfristen und Eigentumsvorbehalt.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
)
RETURNING id INTO v_cat3_id;
INSERT INTO dc_question_categories (
catalog_id, name, description,
row_version, created, created_by, updated, updated_by
) VALUES (
v_catalog_id,
'Laufzeit & Kündigung',
'Prüfung von Vertragslaufzeiten, Kündigungsfristen und automatischer Verlängerung gemäß §309 Nr. 9 BGB.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
)
RETURNING id INTO v_cat4_id;
INSERT INTO dc_question_categories (
catalog_id, name, description,
row_version, created, created_by, updated, updated_by
) VALUES (
v_catalog_id,
'Datenschutz & Compliance',
'Prüfung der Datenschutzklauseln auf Konformität mit DSGVO und BDSG.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
)
RETURNING id INTO v_cat5_id;
INSERT INTO dc_question_categories (
catalog_id, name, description,
row_version, created, created_by, updated, updated_by
) VALUES (
v_catalog_id,
'Verbotene Klauseln §307309 BGB',
'Prüfung auf explizit verbotene oder unangemessen benachteiligende Klauseln.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
)
RETURNING id INTO v_cat6_id;
-- ============================================================
-- 4. FRAGEN
-- ============================================================
------------------------------------------------------------
-- KATEGORIE 1: Einbeziehung & Transparenz
------------------------------------------------------------
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat1_id,
'Wird dem Vertragspartner vor oder bei Vertragsschluss ausdrücklich auf die Geltung der AGB hingewiesen (§305 Abs. 2 Nr. 1 BGB)?',
'SCORE', 50, 'ABWEICHUNG',
'Kein Hinweis auf AGB vorhanden; AGB nur im Footer der Website verlinkt ohne ausdrücklichen Hinweis beim Vertragsschluss.',
'AGB sind ausdrücklich im Vertragsangebot genannt, z. B.: „Es gelten unsere AGB in der jeweils gültigen Fassung, abrufbar unter [URL]."',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat1_id,
'Wird dem Vertragspartner die zumutbare Möglichkeit verschafft, vom Inhalt der AGB Kenntnis zu nehmen (§305 Abs. 2 Nr. 2 BGB)?',
'SCORE', 50, 'ABWEICHUNG',
'AGB nur auf Anfrage erhältlich; kein direkter Link, kein Aushang, keine Übergabe.',
'Vollständiger Text der AGB ist abrufbar unter einem direkt verlinkten URL oder wird als Anhang mitgesendet.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat1_id,
'Sind alle Klauseln klar, verständlich und ohne Fachsprache formuliert (Transparenzgebot §307 Abs. 1 S. 2 BGB)?',
'SCORE', 60, 'ABWEICHUNG',
'Klauseln enthalten unverständliche Fachbegriffe ohne Erläuterung; Satzgefüge sind verschachtelt und mehrdeutig.',
'Klauseln sind in einfacher Sprache formuliert, kurze Sätze, wichtige Begriffe werden erklärt.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat1_id,
'Enthält das Dokument eine Versionsnummer oder ein Datum, das die aktuell gültige Fassung eindeutig kennzeichnet?',
'SCORE', 70, 'HINWEIS',
'Kein Datum, keine Versionsnummer vorhanden.',
'Fußzeile enthält: „Stand: Januar 2025, Version 3.1"',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat1_id,
'Wird geregelt, wie Änderungen der AGB dem Vertragspartner mitgeteilt werden und wann diese wirksam werden?',
'SCORE', 50, 'ABWEICHUNG',
'Keine Regelung zu AGB-Änderungen; stillschweigende Zustimmung durch Weiterbenutzung des Dienstes wird unterstellt.',
'Änderungen werden per E-Mail angekündigt mit einer Einspruchsfrist von mindestens 6 Wochen; Widerspruchsrecht ist ausdrücklich genannt.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
------------------------------------------------------------
-- KATEGORIE 2: Haftung & Gewährleistung
------------------------------------------------------------
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat2_id,
'Wird die Haftung für Vorsatz und grobe Fahrlässigkeit vollständig ausgeschlossen (§309 Nr. 7a BGB unzulässig)?',
'SCORE', 50, 'ABWEICHUNG',
'„Jegliche Haftung, einschließlich Vorsatz und grober Fahrlässigkeit, wird hiermit ausgeschlossen."',
'Haftungsausschluss gilt nur für leichte Fahrlässigkeit; Vorsatz und grobe Fahrlässigkeit bleiben ausdrücklich unberührt.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat2_id,
'Wird die Haftung für Körper- und Gesundheitsschäden ausgeschlossen (§309 Nr. 7b BGB unzulässig)?',
'SCORE', 50, 'ABWEICHUNG',
'„Eine Haftung für Personen- und Sachschäden jeglicher Art ist ausgeschlossen."',
'Haftung für Personenschäden ist explizit ausgenommen: „Die Haftungsbeschränkung gilt nicht für Schäden an Leben, Körper oder Gesundheit."',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat2_id,
'Werden gesetzliche Gewährleistungsrechte des Käufers vollständig ausgeschlossen (§309 Nr. 8b BGB unzulässig bei Neuwaren)?',
'SCORE', 50, 'ABWEICHUNG',
'„Gewährleistungsansprüche sind vollständig ausgeschlossen."',
'Gewährleistungsfrist wird auf das gesetzliche Minimum (2 Jahre bei Neuwaren) gesetzt; vollständiger Ausschluss ist nicht vorhanden.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat2_id,
'Wird die Haftung für die Verletzung wesentlicher Vertragspflichten (Kardinalpflichten) unangemessen eingeschränkt?',
'SCORE', 50, 'ABWEICHUNG',
'„Jegliche Haftung des Anbieters ist auf 50 € begrenzt, unabhängig von der Art der Pflichtverletzung."',
'Haftungsbegrenzung gilt nicht für die Verletzung wesentlicher Vertragspflichten; bei deren Verletzung haftet der Anbieter auf den typischerweise vorhersehbaren Schaden.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
------------------------------------------------------------
-- KATEGORIE 3: Zahlungs- & Lieferbedingungen
------------------------------------------------------------
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat3_id,
'Sind die vereinbarten Zahlungsfristen klar und eindeutig definiert?',
'SCORE', 60, 'HINWEIS',
'Keine Angabe zur Zahlungsfrist; Fälligkeit unklar.',
'Zahlung ist innerhalb von 14 Tagen nach Rechnungsstellung ohne Abzug fällig.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat3_id,
'Werden unangemessen hohe Verzugszinsen oder Vertragsstrafen bei Zahlungsverzug festgelegt (§309 Nr. 6 BGB)?',
'SCORE', 50, 'ABWEICHUNG',
'„Bei Zahlungsverzug werden Zinsen in Höhe von 15 % p. a. sowie eine Bearbeitungsgebühr von 50 € pro Mahnung berechnet."',
'Verzugszinsen entsprechen dem gesetzlichen Zinssatz (§288 BGB); keine unverhältnismäßigen Mahngebühren.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat3_id,
'Sind Lieferfristen verbindlich oder werden nur unverbindliche Richtwerte ohne Konsequenzen bei Überschreitung angegeben?',
'SCORE', 50, 'HINWEIS',
'„Lieferzeiten sind unverbindlich; eine Überschreitung berechtigt nicht zur Stornierung."',
'Lieferfristen sind verbindlich; bei Überschreitung um mehr als 14 Tage hat der Käufer ein Rücktrittsrecht.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat3_id,
'Wird ein erweiterter Eigentumsvorbehalt vereinbart, der über die gelieferte Ware hinausgeht (z. B. Kontokorrentvorbehalt)?',
'SCORE', 50, 'HINWEIS',
'„Das Eigentum verbleibt beim Verkäufer bis zur vollständigen Bezahlung aller gegenwärtigen und zukünftigen Forderungen aus der Geschäftsbeziehung."',
'Einfacher Eigentumsvorbehalt: Eigentum geht nach vollständiger Bezahlung des jeweiligen Kaufpreises über.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
------------------------------------------------------------
-- KATEGORIE 4: Laufzeit & Kündigung
------------------------------------------------------------
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat4_id,
'Beträgt die Erstlaufzeit bei Dauerschuldverhältnissen mehr als zwei Jahre (§309 Nr. 9a BGB unzulässig)?',
'SCORE', 50, 'ABWEICHUNG',
'„Der Vertrag hat eine Mindestlaufzeit von 36 Monaten."',
'Mindestlaufzeit beträgt maximal 24 Monate.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat4_id,
'Verlängert sich der Vertrag bei Nichtkündigung um mehr als ein Jahr automatisch (§309 Nr. 9b BGB unzulässig)?',
'SCORE', 50, 'ABWEICHUNG',
'„Wird der Vertrag nicht 3 Monate vor Ablauf gekündigt, verlängert er sich automatisch um 24 Monate."',
'Automatische Verlängerung beträgt maximal 12 Monate; Kündigungsfrist höchstens 3 Monate.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat4_id,
'Beträgt die Kündigungsfrist für den Vertragspartner mehr als drei Monate (§309 Nr. 9c BGB unzulässig)?',
'SCORE', 50, 'ABWEICHUNG',
'„Der Vertrag kann mit einer Frist von 6 Monaten zum Vertragsende gekündigt werden."',
'Kündigungsfrist beträgt maximal 3 Monate zum Ende der Vertragslaufzeit.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat4_id,
'Ist das Recht zur außerordentlichen Kündigung aus wichtigem Grund explizit oder durch Generalausschluss eingeschränkt?',
'SCORE', 50, 'ABWEICHUNG',
'„Eine Kündigung ist ausschließlich zum regulären Vertragsende möglich."',
'Das Recht zur fristlosen Kündigung aus wichtigem Grund gemäß §314 BGB bleibt ausdrücklich unberührt.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
------------------------------------------------------------
-- KATEGORIE 5: Datenschutz & Compliance
------------------------------------------------------------
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat5_id,
'Wird eine Rechtsgrundlage für die Verarbeitung personenbezogener Daten gemäß Art. 6 DSGVO genannt?',
'SCORE', 70, 'ABWEICHUNG',
'Keine Angabe zur Rechtsgrundlage der Datenverarbeitung in den AGB.',
'Datenverarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung); weitere Grundlagen sind aufgeführt.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat5_id,
'Wird der Verantwortliche gemäß Art. 4 Nr. 7 DSGVO mit vollständigen Kontaktdaten genannt?',
'SCORE', 70, 'ABWEICHUNG',
'Kein Verantwortlicher genannt; nur allgemeiner Firmenname ohne Adresse.',
'Name, Anschrift, E-Mail und ggf. Datenschutzbeauftragter sind vollständig angegeben.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat5_id,
'Werden die Betroffenenrechte (Auskunft, Löschung, Berichtigung, Widerspruch) gemäß Art. 1521 DSGVO kommuniziert?',
'SCORE', 60, 'ABWEICHUNG',
'Kein Hinweis auf Betroffenenrechte.',
'Alle Betroffenenrechte sind aufgeführt mit Hinweis auf den Kontaktweg zur Ausübung.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat5_id,
'Wird eine Weitergabe von Daten an Dritte oder eine Übermittlung in Drittländer außerhalb der EU geregelt?',
'SCORE', 60, 'HINWEIS',
'Keine Regelung zur Datenweitergabe; Drittlandübermittlung unerwähnt.',
'Datenweitergabe an Dritte ist aufgelistet; Drittlandübermittlung erfolgt nur auf Basis von Standardvertragsklauseln gemäß Art. 46 DSGVO.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
------------------------------------------------------------
-- KATEGORIE 6: Verbotene Klauseln §307309 BGB
------------------------------------------------------------
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat6_id,
'Enthält die AGB eine Klausel, die dem Verwender einseitig das Recht einräumt, vereinbarte Leistungen zu ändern (§308 Nr. 4 BGB)?',
'SCORE', 50, 'ABWEICHUNG',
'„Wir behalten uns vor, unsere Leistungen jederzeit und ohne Ankündigung zu ändern oder einzustellen."',
'Leistungsänderungen sind nur aus sachlich gerechtfertigtem Grund zulässig; dem Kunden steht ein Sonderkündigungsrecht zu.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat6_id,
'Wird dem Vertragspartner das Recht zur Aufrechnung mit unbestrittenen oder rechtskräftig festgestellten Forderungen entzogen (§309 Nr. 3 BGB)?',
'SCORE', 50, 'ABWEICHUNG',
'„Eine Aufrechnung gegen Forderungen des Anbieters ist ausgeschlossen."',
'Aufrechnung ist zulässig mit unbestrittenen oder rechtskräftig festgestellten Gegenforderungen.',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat6_id,
'Wird der Gerichtsstand ausschließlich auf den Sitz des Verwenders festgelegt, obwohl der Vertragspartner Verbraucher ist (§29c ZPO)?',
'SCORE', 50, 'ABWEICHUNG',
'„Ausschließlicher Gerichtsstand für alle Streitigkeiten ist München."',
'Gerichtsstandvereinbarung gilt nur für Kaufleute; bei Verbrauchern gilt der gesetzliche Gerichtsstand (Wohnort des Verbrauchers).',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat6_id,
'Wird die Anwendung deutschen Rechts ausdrücklich geregelt, und werden verbraucherschützende Vorschriften des Herkunftslandes des Kunden ausgehebelt?',
'SCORE', 50, 'HINWEIS',
'„Es gilt ausschließlich deutsches Recht unter vollständigem Ausschluss des UN-Kaufrechts sowie aller ausländischen Schutzvorschriften."',
'Rechtswahl gilt für Kaufleute; Verbraucher genießen den Schutz der zwingenden Vorschriften ihres Heimatlandes (Art. 6 Rom-I-VO).',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
INSERT INTO dc_questions (
category_id, question_text, evaluation_type, threshold, result_handling,
example_0_percent, example_100_percent,
row_version, created, created_by, updated, updated_by
) VALUES (
v_cat6_id,
'Enthält die AGB eine salvatorische Klausel für den Fall der Unwirksamkeit einzelner Bestimmungen?',
'SCORE', 70, 'HINWEIS',
'Keine salvatorische Klausel vorhanden; Unwirksamkeit einzelner Klauseln könnte gesamte AGB gefährden.',
'„Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt."',
1, SYSDATE, 'SYSTEM', SYSDATE, 'SYSTEM'
);
COMMIT;
DBMS_OUTPUT.PUT_LINE('Erfolgreich angelegt:');
DBMS_OUTPUT.PUT_LINE(' Dokumententyp-ID : ' || v_doc_type_id);
DBMS_OUTPUT.PUT_LINE(' Katalog-ID : ' || v_catalog_id);
DBMS_OUTPUT.PUT_LINE(' Kategorie 1 ID : ' || v_cat1_id || ' (Einbeziehung & Transparenz)');
DBMS_OUTPUT.PUT_LINE(' Kategorie 2 ID : ' || v_cat2_id || ' (Haftung & Gewährleistung)');
DBMS_OUTPUT.PUT_LINE(' Kategorie 3 ID : ' || v_cat3_id || ' (Zahlungs- & Lieferbedingungen)');
DBMS_OUTPUT.PUT_LINE(' Kategorie 4 ID : ' || v_cat4_id || ' (Laufzeit & Kündigung)');
DBMS_OUTPUT.PUT_LINE(' Kategorie 5 ID : ' || v_cat5_id || ' (Datenschutz & Compliance)');
DBMS_OUTPUT.PUT_LINE(' Kategorie 6 ID : ' || v_cat6_id || ' (Verbotene Klauseln §307309 BGB)');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
DBMS_OUTPUT.PUT_LINE('FEHLER: ' || SQLERRM);
RAISE;
END;
/

View File

@@ -0,0 +1,737 @@
-- =============================================================================
-- ORDS REST Services: Dokumenten-Check
-- Modul: frigosped.dc
-- Base-Path: /api/dc/
-- =============================================================================
-- Voraussetzungen:
-- - Oracle Database 23c mit installiertem ORDS (>= 23.x)
-- - Script wird als Schema-Owner ausgeführt (hat ORDS_METADATA-Rolle)
-- - Datenbankschema bereits angelegt via "Datenmodell DocumentCheck.sql"
--
-- Deployment:
-- sqlplus user/pass@db @"Scripts/ORDS REST Services.sql"
--
-- Das Skript ist idempotent: bestehende Modul-Definition wird gelöscht und
-- neu erstellt. Bestehende Daten bleiben unberührt.
--
-- Endpunkt-Übersicht:
-- 1. GET /api/dc/catalogs/ Alle Fragenkataloge
-- 2. GET /api/dc/catalogs/:id/questions/ Fragen eines Katalogs (flach)
-- 3. GET /api/dc/projects/:id Projektdetail
-- 4. POST /api/dc/projects/:id/start → IN_PROGRESS
-- 5. PUT /api/dc/projects/:id/progress Fortschritt aktualisieren
-- 6. POST /api/dc/projects/:id/complete → COMPLETED
-- 7. GET /api/dc/projects/:id/documents/ Dokument-Liste (kein BLOB)
-- 8. GET /api/dc/documents/:id/file BLOB-Download
-- 9. PUT /api/dc/documents/:id/texts OCR-Text + Übersetzung schreiben
-- 10. POST /api/dc/projects/:id/results Prüfergebnis einfügen
-- 11. DELETE /api/dc/projects/:id/results Alle Ergebnisse löschen
-- =============================================================================
-- =============================================================================
-- BLOCK 1: Modul, Templates und Handler
-- =============================================================================
BEGIN
-- -------------------------------------------------------------------------
-- Idempotenz: Modul löschen falls vorhanden
-- -------------------------------------------------------------------------
BEGIN
ORDS.DELETE_MODULE(p_module_name => 'frigosped.dc');
EXCEPTION
WHEN OTHERS THEN NULL;
END;
-- -------------------------------------------------------------------------
-- Modul definieren
-- -------------------------------------------------------------------------
ORDS.DEFINE_MODULE(
p_module_name => 'frigosped.dc',
p_base_path => '/api/dc/',
p_items_per_page => 0, -- Pagination deaktiviert (Quarkus steuert selbst)
p_status => 'PUBLISHED',
p_comments => 'Dokumenten-Check Backend-API fuer den Quarkus-Verarbeitungsserver'
);
-- ===========================================================================
-- TEMPLATE 1: catalogs/
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/',
p_priority => 0,
p_etag_type => 'HASH',
p_comments => 'Alle Fragenkataloge mit Dokumenttyp-Name'
);
-- 1. GET /api/dc/catalogs/
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/',
p_method => 'GET',
p_source_type => ORDS.SOURCE_TYPE_COLLECTION_FEED,
p_items_per_page => 0,
p_comments => 'Gibt alle Kataloge mit Dokumenttyp-Name zurueck',
p_source => q'[
SELECT
c.id,
c.name,
c.description,
c.document_type_id,
dt.name AS document_type_name,
c.row_version,
c.created,
c.updated
FROM dc_question_catalogs c
JOIN dc_document_types dt ON dt.id = c.document_type_id
ORDER BY c.name
]'
);
-- ===========================================================================
-- TEMPLATE 2: catalogs/:catalog_id/questions/
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/:catalog_id/questions/',
p_priority => 0,
p_etag_type => 'HASH',
p_comments => 'Alle Fragen eines Katalogs (flach mit Kategorie-Info)'
);
-- 2. GET /api/dc/catalogs/:catalog_id/questions/
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/:catalog_id/questions/',
p_method => 'GET',
p_source_type => ORDS.SOURCE_TYPE_COLLECTION_FEED,
p_items_per_page => 0,
p_comments => 'Flacher Join: Kategorie + Frage-Spalten, nach Kategorie/ID sortiert',
p_source => q'[
SELECT
q.id AS question_id,
cat.id AS category_id,
cat.name AS category_name,
cat.description AS category_description,
q.question_text,
q.evaluation_type,
q.threshold,
q.result_handling,
q.example_0_percent,
q.example_100_percent,
q.row_version
FROM dc_questions q
JOIN dc_question_categories cat ON cat.id = q.category_id
WHERE cat.catalog_id = :catalog_id
ORDER BY cat.name, q.id
]'
);
-- ===========================================================================
-- TEMPLATE 3: projects/:project_id (kein Trailing-Slash = Einzel-Ressource)
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id',
p_priority => 0,
p_etag_type => 'HASH',
p_comments => 'Einzelnes Projekt'
);
-- 3. GET /api/dc/projects/:project_id
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id',
p_method => 'GET',
p_source_type => ORDS.SOURCE_TYPE_COLLECTION_ITEM,
p_items_per_page => 1,
p_comments => 'Projektdetail als einzelnes JSON-Objekt; 404 wenn nicht gefunden',
p_source => q'[
SELECT
p.id,
p.name,
p.description,
p.catalog_id,
p.created_by_user,
p.status,
p.progress,
p.completed_at,
p.notification_email,
p.row_version,
p.created,
p.updated
FROM dc_projects p
WHERE p.id = :project_id
]'
);
-- ===========================================================================
-- TEMPLATE 4: projects/:project_id/start
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/start',
p_priority => 0,
p_comments => 'Projekt-Status auf IN_PROGRESS setzen'
);
-- 4. POST /api/dc/projects/:project_id/start
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/start',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => '200 OK; 409 wenn Status nicht PENDING; 404 wenn Projekt fehlt',
p_source => q'[
DECLARE
v_status dc_projects.status%TYPE;
BEGIN
SELECT status
INTO v_status
FROM dc_projects
WHERE id = :project_id
FOR UPDATE NOWAIT;
IF v_status != 'PENDING' THEN
:status_code := 409;
HTP.P('{"error":"Projekt ist nicht im Status PENDING",'
|| '"current_status":"' || v_status || '"}');
RETURN;
END IF;
UPDATE dc_projects
SET status = 'IN_PROGRESS',
progress = 0
WHERE id = :project_id;
:status_code := 200;
HTP.P('{"project_id":' || :project_id
|| ',"status":"IN_PROGRESS"}');
EXCEPTION
WHEN NO_DATA_FOUND THEN
:status_code := 404;
HTP.P('{"error":"Projekt nicht gefunden","project_id":' || :project_id || '}');
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 5: projects/:project_id/progress
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/progress',
p_priority => 0,
p_comments => 'Verarbeitungsfortschritt aktualisieren'
);
-- 5. PUT /api/dc/projects/:project_id/progress
-- Body: {"progress": 45}
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/progress',
p_method => 'PUT',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Setzt progress (0-100); 400 bei ungueltigem Wert',
p_source => q'[
DECLARE
v_progress NUMBER;
v_body CLOB;
BEGIN
v_body := :body_text;
v_progress := TO_NUMBER(JSON_VALUE(v_body, '$.progress'));
IF v_progress IS NULL OR v_progress < 0 OR v_progress > 100 THEN
:status_code := 400;
HTP.P('{"error":"progress muss eine Zahl zwischen 0 und 100 sein"}');
RETURN;
END IF;
UPDATE dc_projects
SET progress = v_progress
WHERE id = :project_id;
IF SQL%ROWCOUNT = 0 THEN
:status_code := 404;
HTP.P('{"error":"Projekt nicht gefunden","project_id":' || :project_id || '}');
RETURN;
END IF;
:status_code := 200;
HTP.P('{"project_id":' || :project_id
|| ',"progress":' || v_progress || '}');
EXCEPTION
WHEN VALUE_ERROR THEN
:status_code := 400;
HTP.P('{"error":"progress muss eine gueltige Zahl sein"}');
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 6: projects/:project_id/complete
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/complete',
p_priority => 0,
p_comments => 'Projekt auf COMPLETED setzen'
);
-- 6. POST /api/dc/projects/:project_id/complete
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/complete',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Setzt status=COMPLETED, completed_at=SYSDATE, progress=100',
p_source => q'[
DECLARE
v_status dc_projects.status%TYPE;
v_now DATE := SYSDATE;
BEGIN
SELECT status
INTO v_status
FROM dc_projects
WHERE id = :project_id
FOR UPDATE NOWAIT;
IF v_status = 'COMPLETED' THEN
:status_code := 409;
HTP.P('{"error":"Projekt ist bereits COMPLETED","project_id":' || :project_id || '}');
RETURN;
END IF;
UPDATE dc_projects
SET status = 'COMPLETED',
completed_at = v_now,
progress = 100
WHERE id = :project_id;
:status_code := 200;
HTP.P('{"project_id":' || :project_id
|| ',"status":"COMPLETED"'
|| ',"completed_at":"' || TO_CHAR(v_now, 'YYYY-MM-DD"T"HH24:MI:SS') || '"'
|| '}');
EXCEPTION
WHEN NO_DATA_FOUND THEN
:status_code := 404;
HTP.P('{"error":"Projekt nicht gefunden","project_id":' || :project_id || '}');
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 7: projects/:project_id/documents/
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/documents/',
p_priority => 0,
p_etag_type => 'HASH',
p_comments => 'Dokument-Liste eines Projekts (ohne BLOB-Payload)'
);
-- 7. GET /api/dc/projects/:project_id/documents/
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/documents/',
p_method => 'GET',
p_source_type => ORDS.SOURCE_TYPE_COLLECTION_FEED,
p_items_per_page => 0,
p_comments => 'Metadaten aller Dokumente; has_original_text/has_translated_text als 0/1',
p_source => q'[
SELECT
d.id,
d.filename,
d.mime_type,
d.document_type_id,
d.catalog_id,
d.uploaded_at,
CASE WHEN d.original_text IS NOT NULL THEN 1 ELSE 0 END AS has_original_text,
CASE WHEN d.translated_text IS NOT NULL THEN 1 ELSE 0 END AS has_translated_text,
d.row_version,
d.created,
d.updated
FROM dc_project_documents d
WHERE d.project_id = :project_id
ORDER BY d.uploaded_at
]'
);
-- ===========================================================================
-- TEMPLATE 8: documents/:doc_id/file
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/file',
p_priority => 0,
p_comments => 'BLOB-Download: original_file mit korrektem Content-Type'
);
-- 8. GET /api/dc/documents/:doc_id/file
-- SOURCE_TYPE_MEDIA: Pflicht-Aliase: content, content_type, filename
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/file',
p_method => 'GET',
p_source_type => ORDS.SOURCE_TYPE_MEDIA,
p_comments => 'Streamt original_file BLOB; ORDS setzt Content-Type aus mime_type-Spalte',
p_source => q'[
SELECT
d.original_file AS content,
d.mime_type AS content_type,
d.filename AS filename,
d.updated AS last_modified
FROM dc_project_documents d
WHERE d.id = :doc_id
]'
);
-- ===========================================================================
-- TEMPLATE 9: documents/:doc_id/texts
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/texts',
p_priority => 0,
p_comments => 'OCR-Ergebnis und Uebersetzung zurueckschreiben'
);
-- 9. PUT /api/dc/documents/:doc_id/texts
-- Body: {"original_text": "## ...", "translated_text": "## ..."}
-- Beide Felder sind optional: NULL-Wert ueberschreibt nicht (nur wenn im Body vorhanden)
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/texts',
p_method => 'PUT',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Schreibt original_text und/oder translated_text als CLOB',
p_source => q'[
DECLARE
v_body CLOB;
v_original_text CLOB;
v_translated CLOB;
v_rows INTEGER;
BEGIN
v_body := :body_text;
-- RETURNING CLOB erlaubt Werte groesser als 32767 Bytes (wichtig fuer OCR-Output)
v_original_text := JSON_VALUE(v_body, '$.original_text' RETURNING CLOB);
v_translated := JSON_VALUE(v_body, '$.translated_text' RETURNING CLOB);
UPDATE dc_project_documents
SET original_text = CASE
WHEN JSON_EXISTS(v_body, '$.original_text')
THEN v_original_text
ELSE original_text
END,
translated_text = CASE
WHEN JSON_EXISTS(v_body, '$.translated_text')
THEN v_translated
ELSE translated_text
END
WHERE id = :doc_id;
v_rows := SQL%ROWCOUNT;
IF v_rows = 0 THEN
:status_code := 404;
HTP.P('{"error":"Dokument nicht gefunden","doc_id":' || :doc_id || '}');
RETURN;
END IF;
:status_code := 200;
HTP.P('{"doc_id":' || :doc_id || ',"updated":true}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 10+11: projects/:project_id/results
-- (POST = Einfuegen, DELETE = Alle loeschen)
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/results',
p_priority => 0,
p_comments => 'Prüfergebnisse: POST einfuegen, DELETE alle loeschen'
);
-- 10. POST /api/dc/projects/:project_id/results
-- Body: {
-- "question_id": 5,
-- "doc_id": 101,
-- "answer": "Ja, §305 BGB ist erfuellt.",
-- "score": 85,
-- "result_type": "OK",
-- "deviation": 0,
-- "warning": 0,
-- "the_comment": "Klare Einbeziehungsklausel vorhanden"
-- }
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/results',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => '201 Created mit Location-Header; 400 bei fehlendem question_id/doc_id/result_type',
p_source => q'[
DECLARE
v_body CLOB;
v_question_id NUMBER;
v_doc_id NUMBER;
v_answer CLOB;
v_score NUMBER;
v_result_type VARCHAR2(20);
v_deviation NUMBER;
v_warning NUMBER;
v_comment CLOB;
v_new_id NUMBER;
BEGIN
v_body := :body_text;
v_question_id := TO_NUMBER(JSON_VALUE(v_body, '$.question_id'));
v_doc_id := TO_NUMBER(JSON_VALUE(v_body, '$.doc_id'));
v_answer := JSON_VALUE(v_body, '$.answer' RETURNING CLOB);
v_score := TO_NUMBER(JSON_VALUE(v_body, '$.score'));
v_result_type := JSON_VALUE(v_body, '$.result_type');
v_deviation := NVL(TO_NUMBER(JSON_VALUE(v_body, '$.deviation')), 0);
v_warning := NVL(TO_NUMBER(JSON_VALUE(v_body, '$.warning')), 0);
v_comment := JSON_VALUE(v_body, '$.the_comment' RETURNING CLOB);
-- Pflichtfelder prüfen
IF v_question_id IS NULL OR v_doc_id IS NULL THEN
:status_code := 400;
HTP.P('{"error":"question_id und doc_id sind Pflichtfelder"}');
RETURN;
END IF;
IF v_result_type NOT IN ('OK', 'UNKLAR', 'NOK') THEN
:status_code := 400;
HTP.P('{"error":"result_type muss OK, UNKLAR oder NOK sein",'
|| '"received":"' || NVL(v_result_type, 'NULL') || '"}');
RETURN;
END IF;
INSERT INTO dc_results (
project_id,
question_id,
doc_id,
answer,
score,
result_type,
deviation,
warning,
the_comment,
evaluated_at
) VALUES (
:project_id,
v_question_id,
v_doc_id,
v_answer,
v_score,
v_result_type,
v_deviation,
v_warning,
v_comment,
SYSDATE
)
RETURNING id INTO v_new_id;
-- Location-Header gemaess REST-Konvention
ORDS.SET_RESPONSE_HEADER(
'Location',
'/api/dc/projects/' || :project_id || '/results/' || v_new_id
);
:status_code := 201;
HTP.P('{"id":' || v_new_id
|| ',"project_id":' || :project_id
|| ',"question_id":' || v_question_id
|| ',"doc_id":' || v_doc_id
|| ',"result_type":"' || v_result_type || '"'
|| ',"score":' || NVL(TO_CHAR(v_score), 'null')
|| '}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- 11. DELETE /api/dc/projects/:project_id/results
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/results',
p_method => 'DELETE',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Loescht alle Ergebnisse eines Projekts (fuer Re-Processing)',
p_source => q'[
DECLARE
v_deleted INTEGER;
BEGIN
DELETE FROM dc_results
WHERE project_id = :project_id;
v_deleted := SQL%ROWCOUNT;
:status_code := 200;
HTP.P('{"project_id":' || :project_id
|| ',"deleted_count":' || v_deleted || '}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
COMMIT;
END;
/
-- =============================================================================
-- BLOCK 2: Auth / Privilege (fuer Produktion)
-- =============================================================================
-- In der Entwicklung kann dieser Block auskommentiert bleiben das Modul
-- ist dann ohne Auth-Pruefung erreichbar (STATUS = PUBLISHED genuegt).
--
-- Fuer Produktion einkommentieren und danach den OAuth2-Client anlegen:
--
-- SELECT client_id, client_secret
-- FROM user_ords_clients
-- WHERE name = 'quarkus_dc_backend';
--
-- Den client_id/client_secret in die Quarkus application.properties eintragen.
-- =============================================================================
/*
BEGIN
-- Rolle anlegen (falls noch nicht vorhanden)
BEGIN
ORDS.CREATE_ROLE(p_role_name => 'dc_api_user');
EXCEPTION
WHEN OTHERS THEN NULL;
END;
-- Privilege loeschen falls vorhanden
BEGIN
ORDS.DROP_PRIVILEGE(p_name => 'dc.api.privilege');
EXCEPTION
WHEN OTHERS THEN NULL;
END;
-- Privilege definieren: mappt Rolle auf alle /api/dc/-Endpunkte
ORDS.DEFINE_PRIVILEGE(
p_privilege_name => 'dc.api.privilege',
p_roles => ORDS_TYPES.T_ORDS_VARCHARS('dc_api_user'),
p_patterns => ORDS_TYPES.T_ORDS_VARCHARS('/api/dc/*'),
p_module_name => 'frigosped.dc',
p_label => 'Dokumenten-Check API',
p_description => 'Zugriff auf alle /api/dc/-Endpunkte',
p_comments => 'Wird dem OAuth2-Client quarkus_dc_backend zugewiesen'
);
COMMIT;
END;
/
-- OAuth2-Client fuer den Quarkus-Server (einmalig ausfuehren)
BEGIN
OAUTH.CREATE_CLIENT(
p_name => 'quarkus_dc_backend',
p_grant_type => 'CLIENT_CREDENTIALS',
p_owner => 'Frigosped',
p_description => 'Quarkus-Verarbeitungsserver Service-Account',
p_redirect_uri => NULL,
p_support_email => 'admin@frigosped.de',
p_privilege_names => 'dc.api.privilege'
);
COMMIT;
END;
/
*/
-- =============================================================================
-- Test-Aufrufe (curl)
-- =============================================================================
-- Ersetze <BASE> und <SCHEMA> mit deinen ORDS-Werten, z.B.:
-- BASE="https://apex.example.com/ords/FRIGOSPED_APP"
--
-- Ohne Auth (Entwicklung):
--
-- # 1. Alle Kataloge
-- curl -sS "$BASE/api/dc/catalogs/" | jq .
--
-- # 2. Fragen fuer Katalog 1
-- curl -sS "$BASE/api/dc/catalogs/1/questions/" | jq .
--
-- # 3. Projektdetail
-- curl -sS "$BASE/api/dc/projects/42" | jq .
--
-- # 4. Projekt starten
-- curl -sS -X POST "$BASE/api/dc/projects/42/start" | jq .
--
-- # 5. Fortschritt setzen
-- curl -sS -X PUT -H "Content-Type: application/json" \
-- -d '{"progress":35}' "$BASE/api/dc/projects/42/progress" | jq .
--
-- # 6. Projekt abschliessen
-- curl -sS -X POST "$BASE/api/dc/projects/42/complete" | jq .
--
-- # 7. Dokumente auflisten
-- curl -sS "$BASE/api/dc/projects/42/documents/" | jq .
--
-- # 8. Datei herunterladen
-- curl -sS -o original.pdf "$BASE/api/dc/documents/101/file"
--
-- # 9. OCR-Text + Uebersetzung zurueckschreiben
-- curl -sS -X PUT -H "Content-Type: application/json" \
-- -d '{"original_text":"## Vertrag\n\nAbsatz 1...",
-- "translated_text":"## Vertrag (DE)\n\nAbsatz 1..."}' \
-- "$BASE/api/dc/documents/101/texts" | jq .
--
-- # 10. Ergebnis einfuegen
-- curl -sS -X POST -H "Content-Type: application/json" \
-- -d '{"question_id":5,"doc_id":101,"answer":"Ja, §305 erfuellt.",
-- "score":85,"result_type":"OK","deviation":0,"warning":0,
-- "the_comment":"Klare Einbeziehungsklausel vorhanden"}' \
-- "$BASE/api/dc/projects/42/results" | jq .
--
-- # 11. Alle Ergebnisse loeschen (Re-Processing)
-- curl -sS -X DELETE "$BASE/api/dc/projects/42/results" | jq .
-- =============================================================================

5
claude_code.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
export ANTHROPIC_AUTH_TOKEN="ollama"
export ANTHROPIC_API_KEY=""
export ANTHROPIC_BASE_URL="http://gx10.aquantico.lan:11434"
claude --verbose --model qwen3.6:35b-a3b-q4_K_M

4
dc-backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
target/
.mvn/
*.md
dev.sh

44
dc-backend/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
# =============================================================================
# Stage 1: Build
# =============================================================================
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
# Dependencies zuerst cachen (Layer-Cache bei Code-Änderungen stabil)
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q
# =============================================================================
# Stage 2: Runtime
# =============================================================================
FROM eclipse-temurin:21-jre
WORKDIR /app
# Quarkus Fast-JAR Layout kopieren
COPY --from=build /build/target/quarkus-app/lib/ ./lib/
COPY --from=build /build/target/quarkus-app/*.jar ./
COPY --from=build /build/target/quarkus-app/app/ ./app/
COPY --from=build /build/target/quarkus-app/quarkus/ ./quarkus/
EXPOSE 8090
# Alle Konfigurationswerte können als Env-Variablen überschrieben werden:
#
# DC_API_KEY API-Key für eingehende Anfragen
# DC_AI_BASE_URL Ollama-Endpunkt
# DC_AI_API_KEY Ollama X-API-KEY
# DC_AI_MAIN_MODEL Hauptmodell (Übersetzung + Auswertung)
# DC_AI_OCR_MODEL OCR-Modell
# QUARKUS_REST_CLIENT_ORDS_API_URL ORDS-Basis-URL
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -sf http://localhost:8090/check/health || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar quarkus-run.jar"]

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# Build dc-backend Docker-Image und push auf Registry
# =============================================================================
REGISTRY="registry.express-o.org"
IMAGE_NAME="frigosped/dc-backend"
TAG="${1:-latest}"
FULL_IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== dc-backend Build & Push ==="
echo "Image : $FULL_IMAGE"
echo ""
# --- Build ---
echo "→ Build Image..."
podman build \
--format docker \
--tag "$FULL_IMAGE" \
--label "build.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "build.version=$TAG" \
"$SCRIPT_DIR"
# Bei explizitem Tag zusätzlich 'latest' setzen
if [ "$TAG" != "latest" ]; then
podman tag "$FULL_IMAGE" "$REGISTRY/$IMAGE_NAME:latest"
echo "→ Auch getaggt als: $REGISTRY/$IMAGE_NAME:latest"
fi
# --- Push ---
echo "→ Push $FULL_IMAGE ..."
podman push "$FULL_IMAGE"
if [ "$TAG" != "latest" ]; then
echo "→ Push $REGISTRY/$IMAGE_NAME:latest ..."
podman push "$REGISTRY/$IMAGE_NAME:latest"
fi
echo ""
echo "✓ Fertig: $FULL_IMAGE"

5
dc-backend/dev.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
export PATH="$JAVA_HOME/bin:$PATH"
cd "$(dirname "$0")"
exec quarkus dev

View File

@@ -0,0 +1,88 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: dc-backend
namespace: ai-env
labels:
app: dc-backend
spec:
replicas: 1
selector:
matchLabels:
app: dc-backend
template:
metadata:
labels:
app: dc-backend
spec:
containers:
- name: dc-backend
image: registry.express-o.org/frigosped/dc-backend:latest
imagePullPolicy: Always
ports:
- containerPort: 8090
env:
# ORDS-Verbindung (Service im selben Namespace)
- name: QUARKUS_REST_CLIENT_ORDS_API_URL
value: "http://ords:8080/ords/FRIGOSPED_APP"
# API-Key für eingehende Anfragen (DC_API_KEY)
- name: DC_API_KEY
valueFrom:
secretKeyRef:
name: dc-backend-secrets
key: api-key
optional: true
# Ollama KI-Endpunkt
- name: DC_AI_BASE_URL
value: "https://ollama.aquantico.de"
- name: DC_AI_API_KEY
valueFrom:
secretKeyRef:
name: dc-backend-secrets
key: ollama-api-key
optional: true
livenessProbe:
httpGet:
path: /check/health
port: 8090
initialDelaySeconds: 20
periodSeconds: 30
failureThreshold: 3
readinessProbe:
httpGet:
path: /check/health
port: 8090
initialDelaySeconds: 15
periodSeconds: 10
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: dc-backend
namespace: ai-env
labels:
app: dc-backend
spec:
selector:
app: dc-backend
ports:
- port: 8090
targetPort: 8090
type: ClusterIP
---
apiVersion: v1
kind: Secret
metadata:
name: dc-backend-secrets
namespace: ai-env
type: Opaque
stringData:
api-key: ""
ollama-api-key: "324GF44-50AA-4B57-9386-K435DLJ764DFR"

18
dc-backend/logs.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export KUBECONFIG="$SCRIPT_DIR/../env/frigo-dev.yaml"
POD=$(kubectl get pod -n ai-env -l app=dc-backend \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [ -z "$POD" ]; then
echo "Kein laufender dc-backend Pod gefunden. Alle Pods:"
kubectl get pods -n ai-env -l app=dc-backend
exit 1
fi
echo "=== Logs: $POD ==="
kubectl logs -f "$POD" -n ai-env

146
dc-backend/pom.xml Normal file
View File

@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.frigosped</groupId>
<artifactId>dc-backend</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<quarkus.platform.version>3.15.1</quarkus.platform.version>
<langchain4j.version>0.36.2</langchain4j.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-bom</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- ================================================================ -->
<!-- Quarkus REST Server -->
<!-- ================================================================ -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<!-- MicroProfile REST Client für ORDS-Aufrufe -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>
<!-- Config -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-config-yaml</artifactId>
</dependency>
<!-- OpenAPI / Swagger UI (optional, für Entwicklung) -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
<!-- ================================================================ -->
<!-- LangChain4J (roh, ohne Quarkus-Extension volle Kontrolle -->
<!-- über Modell-Konfiguration und Custom-Headers) -->
<!-- ================================================================ -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- ================================================================ -->
<!-- Dokumentenverarbeitung -->
<!-- ================================================================ -->
<!-- PDF → Seitenbild (für KI-OCR) -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
<!-- DOCX → Markdown -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
<!-- ODT → Text -->
<dependency>
<groupId>org.odftoolkit</groupId>
<artifactId>odfdom-java</artifactId>
<version>0.12.0</version>
</dependency>
<!-- ================================================================ -->
<!-- Tests -->
<!-- ================================================================ -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,110 @@
package de.frigosped.dc.ai;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.ollama.OllamaChatModel;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Named;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.Duration;
import java.util.Map;
/**
* CDI-Producer für die zwei KI-Modelle.
*
* Beide Modelle sprechen denselben Ollama-kompatiblen Endpunkt an,
* unterscheiden sich aber in Modellname und Timeout.
* Der X-API-KEY-Header wird über customHeaders gesetzt.
*
* Einsatz:
* @Inject @Named("main") ChatLanguageModel mainModel → Auswertung + Übersetzung
* @Inject @Named("ocr") ChatLanguageModel ocrModel → PDF-OCR (Vision)
*/
@ApplicationScoped
public class AiModelProducer {
@ConfigProperty(name = "dc.ai.base-url")
String baseUrl;
@ConfigProperty(name = "dc.ai.api-key")
String apiKey;
@ConfigProperty(name = "dc.ai.main.model")
String mainModelName;
@ConfigProperty(name = "dc.ai.main.timeout", defaultValue = "300s")
String mainTimeout;
@ConfigProperty(name = "dc.ai.main.max-retries", defaultValue = "2")
int mainMaxRetries;
@ConfigProperty(name = "dc.ai.main.temperature", defaultValue = "0.1")
double mainTemperature;
@ConfigProperty(name = "dc.ai.ocr.model")
String ocrModelName;
@ConfigProperty(name = "dc.ai.ocr.timeout", defaultValue = "120s")
String ocrTimeout;
@ConfigProperty(name = "dc.ai.ocr.max-retries", defaultValue = "2")
int ocrMaxRetries;
@ConfigProperty(name = "dc.ai.log-requests", defaultValue = "false")
boolean logRequests;
@ConfigProperty(name = "dc.ai.log-responses", defaultValue = "false")
boolean logResponses;
/**
* Hauptmodell: Auswertung der Fragen + Übersetzung
* Modell: gpt-oss:20b
*/
@Produces
@ApplicationScoped
@Named("main")
public ChatLanguageModel mainModel() {
return OllamaChatModel.builder()
.baseUrl(baseUrl)
.modelName(mainModelName)
.temperature(mainTemperature)
.timeout(parseDuration(mainTimeout))
.maxRetries(mainMaxRetries)
.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
*/
@Produces
@ApplicationScoped
@Named("ocr")
public ChatLanguageModel ocrModel() {
return OllamaChatModel.builder()
.baseUrl(baseUrl)
.modelName(ocrModelName)
.timeout(parseDuration(ocrTimeout))
.maxRetries(ocrMaxRetries)
.customHeaders(Map.of("X-API-KEY", apiKey))
.logRequests(logRequests)
.logResponses(logResponses)
.build();
}
/**
* Parst Timeout-Strings wie "300s", "5m", "120s" in Duration.
*/
private Duration parseDuration(String value) {
if (value.endsWith("s")) {
return Duration.ofSeconds(Long.parseLong(value.replace("s", "")));
} else if (value.endsWith("m")) {
return Duration.ofMinutes(Long.parseLong(value.replace("m", "")));
}
return Duration.ofSeconds(Long.parseLong(value));
}
}

View File

@@ -0,0 +1,125 @@
package de.frigosped.dc.client;
import de.frigosped.dc.model.*;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/**
* MicroProfile REST Client für die ORDS /api/dc/-Endpunkte.
*
* Basis-URL wird in application.properties gesetzt:
* quarkus.rest-client.ords-api.url=http://host/ords/SCHEMA
*
* Optionaler Bearer-Token für Produktion:
* dc.ords.bearer-token=eyJ...
* → @ClientHeaderParam(name = "Authorization", value = "${dc.ords.bearer-token}")
*/
@RegisterRestClient(configKey = "ords-api")
@Path("/api/dc")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface OrdsClient {
// =========================================================================
// Fragenkatalog (Lesend)
// =========================================================================
/**
* GET /api/dc/catalogs/:catalogId/questions/
* Liefert alle Fragen (mit Kategorie-Info) für einen Katalog.
*/
@GET
@Path("/catalogs/{catalogId}/questions/")
OrdsQuestionList getQuestions(@PathParam("catalogId") long catalogId);
// =========================================================================
// Projekte (Status-Steuerung)
// =========================================================================
/**
* GET /api/dc/projects/:projectId
* Projektdetails laden (Status, catalog_id, notification_email, ...).
*/
@GET
@Path("/projects/{projectId}")
OrdsProject getProject(@PathParam("projectId") long projectId);
/**
* POST /api/dc/projects/:projectId/start
* Setzt Status auf IN_PROGRESS. 409 wenn nicht PENDING.
*/
@POST
@Path("/projects/{projectId}/start")
Response startProject(@PathParam("projectId") long projectId);
/**
* PUT /api/dc/projects/:projectId/progress
* Aktualisiert den Fortschritt (0100).
*/
@PUT
@Path("/projects/{projectId}/progress")
Response updateProgress(@PathParam("projectId") long projectId,
ProgressRequest body);
/**
* POST /api/dc/projects/:projectId/complete
* Setzt Status auf COMPLETED + completed_at = SYSDATE.
*/
@POST
@Path("/projects/{projectId}/complete")
Response completeProject(@PathParam("projectId") long projectId);
// =========================================================================
// Dokumente
// =========================================================================
/**
* GET /api/dc/projects/:projectId/documents/
* Liste aller Dokumente (Metadaten, kein BLOB).
*/
@GET
@Path("/projects/{projectId}/documents/")
OrdsDocumentList getDocuments(@PathParam("projectId") long projectId);
/**
* GET /api/dc/documents/:docId/file
* Download des Original-BLOBs.
* Content-Type wird von ORDS aus mime_type-Spalte gesetzt.
*/
@GET
@Path("/documents/{docId}/file")
@Produces(MediaType.WILDCARD)
Response downloadFile(@PathParam("docId") long docId);
/**
* PUT /api/dc/documents/:docId/texts
* Schreibt original_text und/oder translated_text zurück.
*/
@PUT
@Path("/documents/{docId}/texts")
Response updateTexts(@PathParam("docId") long docId, TextsRequest body);
// =========================================================================
// Ergebnisse
// =========================================================================
/**
* POST /api/dc/projects/:projectId/results
* Einzelnes Prüfergebnis speichern.
*/
@POST
@Path("/projects/{projectId}/results")
Response saveResult(@PathParam("projectId") long projectId,
ResultRequest body);
/**
* DELETE /api/dc/projects/:projectId/results
* Alle Ergebnisse löschen (vor Re-Processing).
*/
@DELETE
@Path("/projects/{projectId}/results")
Response deleteResults(@PathParam("projectId") long projectId);
}

View File

@@ -0,0 +1,93 @@
package de.frigosped.dc.client;
import jakarta.annotation.Priority;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.client.ClientResponseFilter;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
* Loggt jede HTTP-Anfrage und -Antwort des ORDS REST Clients:
* - Vollständige URL (Methode + URI)
* - Request-Body
* - Response-Status + Body
*/
@Provider
@Priority(Javax.ws.rs.Priorities.AUTHENTICATION + 1)
public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFilter {
private static final Logger LOG = Logger.getLogger(OrdsLoggingFilter.class);
@Override
public void filter(ClientRequestContext ctx) throws IOException {
String method = ctx.getMethod();
String uri = ctx.getUri().toString();
String entity = readRequest(ctx);
LOG.infof("=== ORDS %s %s ===", method, uri);
if (entity != null && !entity.isBlank()) {
LOG.infof("REQUEST: %s", entity);
}
}
@Override
public void filter(ClientRequestContext requestCtx, ClientResponseContext responseCtx) throws IOException {
String body = readResponse(responseCtx);
LOG.infof("RESPONSE: HTTP %d%s",
responseCtx.getStatus(),
body != null ? " " + body : "");
LOG.infof("=== ORDS %s %s END ===", requestCtx.getMethod(), requestCtx.getUri());
}
private String readRequest(ClientRequestContext ctx) {
Object entity = ctx.getEntity();
if (entity == null) return null;
String body;
if (entity instanceof byte[] bytes) {
body = new String(bytes, StandardCharsets.UTF_8);
} else if (entity instanceof String str) {
body = str;
} else if (entity instanceof List<?> list) {
body = list.toString();
} else if (entity instanceof Object[] arr) {
body = Arrays.toString(arr);
} else {
body = entity.toString();
}
if (body.isBlank()) return null;
// Entity neu setzen — Stream nicht verbrauchen
ctx.setEntity(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)),
ctx.getHeaders().getFirst("Content-Type", MediaType.APPLICATION_OCTET_STREAM),
Long.valueOf(body.length()));
return body;
}
private String readResponse(ClientResponseContext ctx) throws IOException {
if (ctx.getEntityStream() == null) return null;
byte[] raw = ctx.getEntityStream().readAllBytes();
if (raw.length == 0) return null;
// Stream neu setzen für nachfolgende Consumer (z.B. Response.readEntity)
ctx.setEntityStream(new ByteArrayInputStream(raw));
if (raw.length > 4000) {
return String.format("[%.4k bytes, trunc. zu 4000]", raw.length);
}
String mediaType = ctx.getMediaType() != null ? ctx.getMediaType().toString() : "";
if (mediaType.contains("application/json") || mediaType.contains("text/") || mediaType.isEmpty()) {
return new String(raw, StandardCharsets.UTF_8);
}
return String.format("[%d bytes binary]", raw.length);
}
}

View File

@@ -0,0 +1,37 @@
package de.frigosped.dc.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Deserialisiertes JSON-Ergebnis aus der KI-Auswertung.
* Die KI gibt dieses Objekt als JSON-String zurück.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class EvaluationResult {
/** Antwort auf Deutsch (Zitate in Originalsprache + Übersetzung) */
private String answer;
/** Erfüllungsgrad 0100 */
private Integer score;
/** OK | UNKLAR | NOK */
private String resultType;
/** Kommentar auf Deutsch */
private String theComment;
public EvaluationResult() {}
public String getAnswer() { return answer; }
public void setAnswer(String v) { this.answer = v; }
public Integer getScore() { return score; }
public void setScore(Integer v) { this.score = v; }
public String getResultType() { return resultType; }
public void setResultType(String v) { this.resultType = v; }
public String getTheComment() { return theComment; }
public void setTheComment(String v) { this.theComment = v; }
}

View File

@@ -0,0 +1,57 @@
package de.frigosped.dc.model;
import java.time.LocalDateTime;
/**
* DTO für ORDS-Endpunkt GET /api/dc/projects/:id/documents/
* Kein BLOB; has_original_text / has_translated_text kommen als 0/1.
*/
public class OrdsDocument {
private Long id;
private String filename;
private String mimeType;
private Long documentTypeId;
private Long catalogId;
private LocalDateTime uploadedAt;
private Integer hasOriginalText; // 0 oder 1
private Integer hasTranslatedText; // 0 oder 1
private Integer rowVersion;
private LocalDateTime created;
private LocalDateTime updated;
public OrdsDocument() {}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getFilename() { return filename; }
public void setFilename(String filename) { this.filename = filename; }
public String getMimeType() { return mimeType; }
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
public Long getDocumentTypeId() { return documentTypeId; }
public void setDocumentTypeId(Long v) { this.documentTypeId = v; }
public Long getCatalogId() { return catalogId; }
public void setCatalogId(Long catalogId) { this.catalogId = catalogId; }
public LocalDateTime getUploadedAt() { return uploadedAt; }
public void setUploadedAt(LocalDateTime v) { this.uploadedAt = v; }
public Integer getHasOriginalText() { return hasOriginalText; }
public void setHasOriginalText(Integer v) { this.hasOriginalText = v; }
public Integer getHasTranslatedText() { return hasTranslatedText; }
public void setHasTranslatedText(Integer v) { this.hasTranslatedText = v; }
public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; }
public LocalDateTime getCreated() { return created; }
public void setCreated(LocalDateTime v) { this.created = v; }
public LocalDateTime getUpdated() { return updated; }
public void setUpdated(LocalDateTime v) { this.updated = v; }
}

View File

@@ -0,0 +1,25 @@
package de.frigosped.dc.model;
import java.util.List;
/**
* ORDS COLLECTION_FEED-Antwort für Dokumente.
* {"items":[...], "hasMore":false, "count":N}
*/
public class OrdsDocumentList {
private List<OrdsDocument> items;
private Boolean hasMore;
private Integer count;
public OrdsDocumentList() {}
public List<OrdsDocument> getItems() { return items; }
public void setItems(List<OrdsDocument> items) { this.items = items; }
public Boolean getHasMore() { return hasMore; }
public void setHasMore(Boolean hasMore) { this.hasMore = hasMore; }
public Integer getCount() { return count; }
public void setCount(Integer count) { this.count = count; }
}

View File

@@ -0,0 +1,61 @@
package de.frigosped.dc.model;
import java.time.LocalDateTime;
/**
* DTO für den ORDS-Endpunkt GET /api/dc/projects/:project_id
* (COLLECTION_ITEM einzelnes JSON-Objekt, snake_case von ORDS)
*/
public class OrdsProject {
private Long id;
private String name;
private String description;
private Long catalogId;
private Long createdByUser;
private String status; // PENDING | IN_PROGRESS | COMPLETED
private Integer progress; // 0100
private LocalDateTime completedAt;
private String notificationEmail;
private Integer rowVersion;
private LocalDateTime created;
private LocalDateTime updated;
public OrdsProject() {}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String d) { this.description = d; }
public Long getCatalogId() { return catalogId; }
public void setCatalogId(Long catalogId) { this.catalogId = catalogId; }
public Long getCreatedByUser() { return createdByUser; }
public void setCreatedByUser(Long v) { this.createdByUser = v; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Integer getProgress() { return progress; }
public void setProgress(Integer progress) { this.progress = progress; }
public LocalDateTime getCompletedAt() { return completedAt; }
public void setCompletedAt(LocalDateTime v) { this.completedAt = v; }
public String getNotificationEmail() { return notificationEmail; }
public void setNotificationEmail(String v) { this.notificationEmail = v; }
public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; }
public LocalDateTime getCreated() { return created; }
public void setCreated(LocalDateTime v) { this.created = v; }
public LocalDateTime getUpdated() { return updated; }
public void setUpdated(LocalDateTime v) { this.updated = v; }
}

View File

@@ -0,0 +1,55 @@
package de.frigosped.dc.model;
/**
* DTO für ORDS-Endpunkt GET /api/dc/catalogs/:id/questions/
* Flache Darstellung: Kategorie + Frage in einer Zeile.
*/
public class OrdsQuestion {
private Long questionId;
private Long categoryId;
private String categoryName;
private String categoryDescription;
private String questionText;
private String evaluationType; // z.B. SCORE, BINARY
private Integer threshold; // Schwellwert 0100
private String resultHandling; // ABWEICHUNG | HINWEIS
private String example0Percent; // Beispiel: Anforderung nicht erfüllt
private String example100Percent; // Beispiel: Anforderung voll erfüllt
private Integer rowVersion;
public OrdsQuestion() {}
public Long getQuestionId() { return questionId; }
public void setQuestionId(Long v) { this.questionId = v; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long v) { this.categoryId = v; }
public String getCategoryName() { return categoryName; }
public void setCategoryName(String v) { this.categoryName = v; }
public String getCategoryDescription() { return categoryDescription; }
public void setCategoryDescription(String v) { this.categoryDescription = v; }
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 Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; }
}

View File

@@ -0,0 +1,25 @@
package de.frigosped.dc.model;
import java.util.List;
/**
* ORDS COLLECTION_FEED-Antwort für Fragen.
* {"items":[...], "hasMore":false, "count":N}
*/
public class OrdsQuestionList {
private List<OrdsQuestion> items;
private Boolean hasMore;
private Integer count;
public OrdsQuestionList() {}
public List<OrdsQuestion> getItems() { return items; }
public void setItems(List<OrdsQuestion> items) { this.items = items; }
public Boolean getHasMore() { return hasMore; }
public void setHasMore(Boolean hasMore) { this.hasMore = hasMore; }
public Integer getCount() { return count; }
public void setCount(Integer count) { this.count = count; }
}

View File

@@ -0,0 +1,19 @@
package de.frigosped.dc.model;
/**
* Request-Body für PUT /api/dc/projects/:id/progress
* {"progress": 45}
*/
public class ProgressRequest {
private Integer progress;
public ProgressRequest() {}
public ProgressRequest(int progress) {
this.progress = progress;
}
public Integer getProgress() { return progress; }
public void setProgress(Integer v) { this.progress = v; }
}

View File

@@ -0,0 +1,55 @@
package de.frigosped.dc.model;
/**
* Request-Body für POST /api/dc/projects/:id/results
*/
public class ResultRequest {
private Long questionId;
private Long docId;
private String answer;
private Integer score;
private String resultType; // OK | UNKLAR | NOK
private Integer deviation; // 0 oder 1
private Integer warning; // 0 oder 1
private String theComment;
public ResultRequest() {}
public ResultRequest(Long questionId, Long docId, String answer,
Integer score, String resultType,
Integer deviation, Integer warning, String theComment) {
this.questionId = questionId;
this.docId = docId;
this.answer = answer;
this.score = score;
this.resultType = resultType;
this.deviation = deviation;
this.warning = warning;
this.theComment = theComment;
}
public Long getQuestionId() { return questionId; }
public void setQuestionId(Long v) { this.questionId = v; }
public Long getDocId() { return docId; }
public void setDocId(Long v) { this.docId = v; }
public String getAnswer() { return answer; }
public void setAnswer(String v) { this.answer = v; }
public Integer getScore() { return score; }
public void setScore(Integer v) { this.score = v; }
public String getResultType() { return resultType; }
public void setResultType(String v) { this.resultType = v; }
public Integer getDeviation() { return deviation; }
public void setDeviation(Integer v) { this.deviation = v; }
public Integer getWarning() { return warning; }
public void setWarning(Integer v) { this.warning = v; }
public String getTheComment() { return theComment; }
public void setTheComment(String v) { this.theComment = v; }
}

View File

@@ -0,0 +1,24 @@
package de.frigosped.dc.model;
/**
* Request-Body für PUT /api/dc/documents/:id/texts
* {"original_text": "...", "translated_text": "..."}
*/
public class TextsRequest {
private String originalText;
private String translatedText;
public TextsRequest() {}
public TextsRequest(String originalText, String translatedText) {
this.originalText = originalText;
this.translatedText = translatedText;
}
public String getOriginalText() { return originalText; }
public void setOriginalText(String v) { this.originalText = v; }
public String getTranslatedText() { return translatedText; }
public void setTranslatedText(String v) { this.translatedText = v; }
}

View File

@@ -0,0 +1,82 @@
package de.frigosped.dc.resource;
import de.frigosped.dc.service.CheckOrchestrationService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* REST-Endpunkte des Dokumenten-Check-Backend.
*
* POST /check/{projectId}
* → Startet die asynchrone Verarbeitung eines Projekts.
* → Gibt sofort 202 Accepted zurück; Fortschritt über ORDS-Endpunkte ablesbar.
*
* GET /check/health
* → Einfacher Health-Check.
*/
@Path("/check")
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = "Dokumenten-Check", description = "KI-gestützte Dokumentenprüfung")
public class CheckResource {
@Inject
CheckOrchestrationService orchestrationService;
// =========================================================================
// POST /check/{projectId}
// =========================================================================
@POST
@Path("/{projectId}")
@Operation(
summary = "Startet die Dokumentenprüfung für ein Projekt",
description = """
Triggert den vollständigen Prüfablauf asynchron:
1. OCR / Konvertierung der Dokumente (KI)
2. Übersetzung ins Deutsche (KI)
3. Auswertung des Fragenkatalogs (KI)
4. Ergebnisse werden in ORDS gespeichert
Das Projekt muss im Status PENDING sein.
Fortschritt: GET {ords}/api/dc/projects/{id}
"""
)
public Response startCheck(@PathParam("projectId") long projectId) {
// Asynchron starten Fire & Forget.
// Der aufrufende Thread (z.B. APEX-Button) bekommt sofort 202.
// Der Verarbeitungs-Thread läuft im ForkJoin-CommonPool.
CompletableFuture
.runAsync(() -> orchestrationService.process(projectId))
.exceptionally(ex -> {
// Der Orchestrator fängt intern; dies ist eine letzte Absicherung.
// Logging ist dort bereits vorhanden.
return null;
});
return Response
.accepted(Map.of(
"message", "Verarbeitung gestartet",
"project_id", projectId,
"hint", "Fortschritt: GET /api/dc/projects/" + projectId
))
.build();
}
// =========================================================================
// GET /check/health
// =========================================================================
@GET
@Path("/health")
@Operation(summary = "Health-Check", description = "Gibt UP zurück wenn der Service läuft.")
public Response health() {
return Response.ok(Map.of("status", "UP", "service", "dc-backend")).build();
}
}

View File

@@ -0,0 +1,56 @@
package de.frigosped.dc.security;
import jakarta.annotation.Priority;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.util.Map;
import java.util.Optional;
/**
* Prüft den X-API-KEY-Header bei allen Anfragen.
* Konfiguration: dc.api.key (Env: DC_API_KEY)
* Wenn kein Key konfiguriert ist, läuft der Service ohne Auth (Dev-Modus).
*/
@Provider
@Priority(Priorities.AUTHENTICATION)
public class ApiKeyFilter implements ContainerRequestFilter {
private static final Logger LOG = Logger.getLogger(ApiKeyFilter.class);
private static final String HEADER = "X-API-KEY";
@ConfigProperty(name = "dc.api.key")
Optional<String> apiKey;
@Override
public void filter(ContainerRequestContext ctx) {
// Health-Endpunkt immer durchlassen (Docker-Healthcheck)
if (ctx.getUriInfo().getPath().endsWith("/health")) {
return;
}
// Kein Key konfiguriert → Auth deaktiviert (Dev-Modus)
if (apiKey.isEmpty() || apiKey.get().isBlank()) {
LOG.warnf("DC_API_KEY nicht gesetzt Anfrage ohne Auth durchgelassen: %s",
ctx.getUriInfo().getPath());
return;
}
String provided = ctx.getHeaderString(HEADER);
if (provided == null || !provided.equals(apiKey.get())) {
LOG.warnf("Ungültiger oder fehlender API-Key für: %s", ctx.getUriInfo().getPath());
ctx.abortWith(Response
.status(Response.Status.UNAUTHORIZED)
.type(MediaType.APPLICATION_JSON)
.entity(Map.of("error", "Ungültiger oder fehlender API-Key",
"header", HEADER))
.build());
}
}
}

View File

@@ -0,0 +1,221 @@
package de.frigosped.dc.service;
import de.frigosped.dc.client.OrdsClient;
import de.frigosped.dc.model.*;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Orchestriert den kompletten Prüf-Ablauf für ein Projekt:
*
* 1. Projekt laden → IN_PROGRESS setzen
* 2. Dokumente herunterladen → OCR / Konvertierung → Texte speichern
* 3. Texte ins Deutsche übersetzen → gespeichert
* 4. Fragenkatalog für jeden Dokument-Text auswerten
* 5. Ergebnisse Frage × Dokument in DB speichern
* 6. Projekt auf COMPLETED setzen
*
* Wird von CheckResource.startCheck() asynchron (CompletableFuture) aufgerufen.
*/
@ApplicationScoped
public class CheckOrchestrationService {
private static final Logger LOG = Logger.getLogger(CheckOrchestrationService.class);
@RestClient
OrdsClient ordsClient;
@Inject
DocumentProcessingService documentProcessingService;
@Inject
EvaluationService evaluationService;
// =========================================================================
// Haupt-Einstiegspunkt
// =========================================================================
public void process(long projectId) {
LOG.infof("=== Starte Verarbeitung Projekt %d ===", projectId);
try {
// 1. Projekt laden
OrdsProject project = ordsClient.getProject(projectId);
LOG.infof("Projekt: '%s', Status: %s, Katalog: %d",
project.getName(), project.getStatus(), project.getCatalogId());
// Absicherung: nur PENDING starten
if (!"PENDING".equals(project.getStatus())) {
LOG.warnf("Projekt %d ist nicht PENDING (ist: %s) Abbruch",
projectId, project.getStatus());
return;
}
// 2. Status → IN_PROGRESS
Response startResp = ordsClient.startProject(projectId);
if (startResp.getStatus() == 409) {
LOG.warnf("Projekt %d konnte nicht gestartet werden (409) Abbruch", projectId);
return;
}
// 3. Dokumente + Fragen laden
List<OrdsDocument> documents = ordsClient.getDocuments(projectId).getItems();
List<OrdsQuestion> questions = ordsClient
.getQuestions(project.getCatalogId()).getItems();
LOG.infof("Projekt %d: %d Dokument(e), %d Frage(n)",
projectId, documents.size(), questions.size());
if (documents.isEmpty()) {
LOG.warnf("Projekt %d hat keine Dokumente markiere als abgeschlossen", projectId);
ordsClient.completeProject(projectId);
return;
}
// 4. Fortschrittsberechnung
// Pro Dokument: 1 Schritt OCR/Übersetzung + questions.size() Auswertungsschritte
int totalSteps = documents.size() * (1 + questions.size());
int[] stepHolder = {0}; // Array für Verwendung in Lambda
// 5. Alte Ergebnisse löschen (ermöglicht Re-Processing)
Response delResp = ordsClient.deleteResults(projectId);
LOG.debugf("Alte Ergebnisse gelöscht: %s", delResp.getStatus());
// 6. Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS)
Map<Long, String> originalTexts = new HashMap<>();
// ─── PHASE A: OCR + Übersetzung ───────────────────────────────
for (OrdsDocument doc : documents) {
LOG.infof("Verarbeite Dokument %d: %s", doc.getId(), doc.getFilename());
String originalText = "";
String translatedText = "";
try {
// Datei herunterladen
Response fileResp = ordsClient.downloadFile(doc.getId());
if (fileResp.getStatus() != 200) {
LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)",
doc.getId(), fileResp.getStatus());
} else {
byte[] fileBytes = fileResp.readEntity(byte[].class);
// OCR / Konvertierung → Markdown
originalText = documentProcessingService.extractText(
fileBytes, doc.getMimeType(), doc.getFilename());
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
(long) doc.getId(), (long) originalText.length());
// Übersetzung ins Deutsche
translatedText = evaluationService.translate(originalText);
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
(long) doc.getId(), (long) translatedText.length());
// Texte in ORDS zurückschreiben
ordsClient.updateTexts(doc.getId(),
new TextsRequest(originalText, translatedText));
}
} catch (Exception e) {
LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
}
originalTexts.put(doc.getId(), originalText);
// Fortschritt aktualisieren
stepHolder[0]++;
pushProgress(projectId, stepHolder[0], totalSteps);
}
// ─── PHASE B: Fragen auswerten ────────────────────────────────
for (OrdsDocument doc : documents) {
String originalText = originalTexts.getOrDefault(doc.getId(), "");
if (originalText.isBlank()) {
LOG.warnf("Kein Text für Dokument %d überspringe Auswertung", doc.getId());
stepHolder[0] += questions.size();
pushProgress(projectId, stepHolder[0], totalSteps);
continue;
}
for (OrdsQuestion question : questions) {
try {
LOG.debugf("Auswertung: Dok %d × Frage %d",
doc.getId(), question.getQuestionId());
EvaluationResult result = evaluationService.evaluate(question, originalText);
// Abweichung / Hinweis aus Schwellwert + result_handling berechnen
int deviation = 0;
int warning = 0;
if (result.getScore() != null && question.getThreshold() != null
&& result.getScore() < question.getThreshold()) {
if ("ABWEICHUNG".equalsIgnoreCase(question.getResultHandling())) {
deviation = 1;
} else if ("HINWEIS".equalsIgnoreCase(question.getResultHandling())) {
warning = 1;
}
}
ResultRequest rr = new ResultRequest(
question.getQuestionId(),
doc.getId(),
result.getAnswer(),
result.getScore(),
result.getResultType(),
deviation,
warning,
result.getTheComment()
);
Response saveResp = ordsClient.saveResult(projectId, rr);
if (saveResp.getStatus() != 201) {
LOG.warnf("Ergebnis speichern: HTTP %d (Frage %d, Dok %d)",
saveResp.getStatus(), question.getQuestionId(), doc.getId());
}
} catch (Exception e) {
LOG.errorf(e, "Auswertungsfehler: Frage %d / Dokument %d",
question.getQuestionId(), doc.getId());
}
stepHolder[0]++;
pushProgress(projectId, stepHolder[0], totalSteps);
}
}
// 7. Abschließen
ordsClient.completeProject(projectId);
LOG.infof("=== Projekt %d erfolgreich abgeschlossen ===", projectId);
} catch (Exception e) {
// Kritischer Fehler: Projekt bleibt in IN_PROGRESS.
// APEX-Oberfläche / Admin muss manuell zurücksetzen.
LOG.errorf(e, "=== Kritischer Fehler bei Projekt %d bleibt IN_PROGRESS ===",
projectId);
}
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/**
* Sendet den aktuellen Fortschritt an ORDS (0100%).
* Fehler werden nur geloggt, kein Abbruch.
*/
private void pushProgress(long projectId, int step, int total) {
int pct = total > 0 ? Math.min(step * 100 / total, 99) : 0;
try {
ordsClient.updateProgress(projectId, new ProgressRequest(pct));
} catch (Exception e) {
LOG.debugf("Fortschritt konnte nicht gesetzt werden (%d%%): %s", pct, e.getMessage());
}
}
}

View File

@@ -0,0 +1,270 @@
package de.frigosped.dc.service;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatLanguageModel;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.poi.xwpf.usermodel.*;
import org.jboss.logging.Logger;
import org.odftoolkit.odfdom.doc.OdfTextDocument;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
* Konvertiert hochgeladene Dokumente in Markdown-Text.
*
* Unterstützte Formate:
* - PDF → KI-OCR (jede Seite als Bild an quen3.5:9b)
* - DOCX → Apache POI → Markdown
* - ODT → ODF Toolkit → Markdown
* - TXT → direkt als Markdown
*/
@ApplicationScoped
public class DocumentProcessingService {
private static final Logger LOG = Logger.getLogger(DocumentProcessingService.class);
/** 200 DPI: gutes Gleichgewicht zwischen Qualität und Dateigröße für OCR */
private static final float OCR_DPI = 200f;
@Inject
@Named("ocr")
ChatLanguageModel ocrModel;
// =========================================================================
// Öffentliche API
// =========================================================================
/**
* Extrahiert den Textinhalt eines Dokuments als Markdown.
*
* @param fileBytes rohe Datei-Bytes (aus BLOB)
* @param mimeType MIME-Typ des Dokuments
* @param filename Dateiname (Fallback für MIME-Typ-Erkennung)
* @return Markdown-String
*/
public String extractText(byte[] fileBytes, String mimeType, String filename) {
String effectiveMime = resolveMimeType(mimeType, filename);
LOG.debugf("Extrahiere Text: %s (%s)", filename, effectiveMime);
try {
return switch (effectiveMime) {
case "application/pdf"
-> extractFromPdf(fileBytes);
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword"
-> extractFromDocx(fileBytes);
case "application/vnd.oasis.opendocument.text"
-> extractFromOdt(fileBytes);
case "text/plain", "text/markdown"
-> new String(fileBytes, java.nio.charset.StandardCharsets.UTF_8);
default -> {
LOG.warnf("Unbekannter MIME-Typ '%s' versuche als Text", effectiveMime);
yield new String(fileBytes, java.nio.charset.StandardCharsets.UTF_8);
}
};
} catch (Exception e) {
LOG.errorf(e, "Fehler bei Textextraktion (%s)", filename);
throw new RuntimeException("Textextraktion fehlgeschlagen: " + e.getMessage(), e);
}
}
// =========================================================================
// PDF: Seiten rendern + KI-OCR
// =========================================================================
private String extractFromPdf(byte[] fileBytes) throws Exception {
LOG.debug("Starte PDF-OCR...");
StringBuilder result = new StringBuilder();
try (PDDocument pdf = Loader.loadPDF(fileBytes)) {
PDFRenderer renderer = new PDFRenderer(pdf);
int pageCount = pdf.getNumberOfPages();
LOG.debugf("PDF hat %d Seite(n)", pageCount);
for (int i = 0; i < pageCount; i++) {
LOG.debugf("OCR Seite %d/%d", i + 1, pageCount);
BufferedImage image = renderer.renderImageWithDPI(i, OCR_DPI, ImageType.RGB);
// Bild in PNG-Base64 umwandeln
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", baos);
String base64Image = Base64.getEncoder().encodeToString(baos.toByteArray());
String pageText = ocrPage(base64Image, i + 1, pageCount);
result.append(pageText).append("\n\n");
}
}
return result.toString().trim();
}
/**
* Sendet eine PDF-Seite als Base64-Bild an das Vision-Modell.
*/
private String ocrPage(String base64Image, int pageNum, int totalPages) {
UserMessage message = UserMessage.from(
ImageContent.from(base64Image, "image/png"),
TextContent.from(
"Du bist ein OCR-Spezialist. " +
"Extrahiere den gesamten Text aus diesem Dokument-Bild (Seite " + pageNum
+ " von " + totalPages + ").\n\n" +
"Regeln:\n" +
"- Formatiere den Text als Markdown\n" +
"- Überschriften als # / ## / ###\n" +
"- Tabellen als Markdown-Tabellen\n" +
"- Listen als - oder 1. 2. 3.\n" +
"- Behalte den Originaltext exakt (keine Korrekturen, keine Zusammenfassungen)\n" +
"- Antworte NUR mit dem extrahierten Markdown, ohne Präambel"
)
);
return ocrModel.generate(List.of(message)).content().text();
}
// =========================================================================
// DOCX → Markdown (Apache POI)
// =========================================================================
private String extractFromDocx(byte[] fileBytes) throws Exception {
LOG.debug("Konvertiere DOCX → Markdown...");
StringBuilder md = new StringBuilder();
try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(fileBytes))) {
for (IBodyElement element : doc.getBodyElements()) {
if (element instanceof XWPFParagraph para) {
md.append(paragraphToMarkdown(para)).append("\n");
} else if (element instanceof XWPFTable table) {
md.append(tableToMarkdown(table)).append("\n");
}
}
}
return md.toString().trim();
}
private String paragraphToMarkdown(XWPFParagraph para) {
String text = para.getText();
if (text == null || text.isBlank()) return "";
String style = para.getStyle();
if (style == null) return text;
// Überschriften
if (style.equalsIgnoreCase("Heading1") || style.equalsIgnoreCase("berschrift1"))
return "# " + text;
if (style.equalsIgnoreCase("Heading2") || style.equalsIgnoreCase("berschrift2"))
return "## " + text;
if (style.equalsIgnoreCase("Heading3") || style.equalsIgnoreCase("berschrift3"))
return "### " + text;
if (style.equalsIgnoreCase("Heading4") || style.equalsIgnoreCase("berschrift4"))
return "#### " + text;
// Nummerierte Liste
if (para.getNumIlvl() != null) {
return "- " + text;
}
return text;
}
private String tableToMarkdown(XWPFTable table) {
StringBuilder sb = new StringBuilder();
List<XWPFTableRow> rows = table.getRows();
if (rows.isEmpty()) return "";
// Header-Zeile
XWPFTableRow header = rows.get(0);
sb.append("| ");
header.getTableCells().forEach(c -> sb.append(c.getText()).append(" | "));
sb.append("\n|");
header.getTableCells().forEach(c -> sb.append("---|"));
sb.append("\n");
// Daten-Zeilen
for (int i = 1; i < rows.size(); i++) {
sb.append("| ");
rows.get(i).getTableCells().forEach(c -> sb.append(c.getText()).append(" | "));
sb.append("\n");
}
return sb.toString();
}
// =========================================================================
// ODT → Markdown (ODF Toolkit)
// =========================================================================
private String extractFromOdt(byte[] fileBytes) throws Exception {
LOG.debug("Konvertiere ODT → Markdown...");
StringBuilder md = new StringBuilder();
try (OdfTextDocument odfDoc = OdfTextDocument.loadDocument(
new ByteArrayInputStream(fileBytes))) {
NodeList paragraphs = odfDoc.getContentDom()
.getElementsByTagName("text:p");
for (int i = 0; i < paragraphs.getLength(); i++) {
Node node = paragraphs.item(i);
String text = node.getTextContent();
if (text != null && !text.isBlank()) {
// Stil-Attribut auslesen
Node styleAttr = node.getAttributes()
.getNamedItem("text:style-name");
String style = styleAttr != null ? styleAttr.getNodeValue() : "";
if (style.startsWith("Heading_20_1") || style.startsWith("Heading1"))
md.append("# ").append(text).append("\n");
else if (style.startsWith("Heading_20_2") || style.startsWith("Heading2"))
md.append("## ").append(text).append("\n");
else if (style.startsWith("Heading_20_3") || style.startsWith("Heading3"))
md.append("### ").append(text).append("\n");
else
md.append(text).append("\n");
}
}
}
return md.toString().trim();
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/**
* Leitet MIME-Typ aus Dateiendung ab, wenn der gespeicherte MIME-Typ
* fehlt oder generic ist.
*/
private String resolveMimeType(String mimeType, String filename) {
if (mimeType != null && !mimeType.isBlank()
&& !mimeType.equals("application/octet-stream")) {
return mimeType;
}
if (filename == null) return "application/octet-stream";
String lower = filename.toLowerCase();
if (lower.endsWith(".pdf")) return "application/pdf";
if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
if (lower.endsWith(".doc")) return "application/msword";
if (lower.endsWith(".odt")) return "application/vnd.oasis.opendocument.text";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".md")) return "text/markdown";
return "application/octet-stream";
}
}

View File

@@ -0,0 +1,241 @@
package de.frigosped.dc.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.frigosped.dc.model.EvaluationResult;
import de.frigosped.dc.model.OrdsQuestion;
import dev.langchain4j.data.message.AiMessage;
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 jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.jboss.logging.Logger;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* KI-gestützte Auswertung:
* 1. Übersetzung eines Dokuments ins Deutsche
* 2. Prüfung des Dokuments gegen eine einzelne Frage
*
* Verwendet das Hauptmodell (gpt-oss:20b).
*/
@ApplicationScoped
public class EvaluationService {
private static final Logger LOG = Logger.getLogger(EvaluationService.class);
/** Regex zum Extrahieren von JSON aus der KI-Antwort (toleriert Markdown-Codeblöcke) */
private static final Pattern JSON_PATTERN =
Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```|([{][\\s\\S]*[}])",
Pattern.DOTALL);
@Inject
@Named("main")
ChatLanguageModel mainModel;
@Inject
ObjectMapper objectMapper;
// =========================================================================
// Übersetzung
// =========================================================================
/**
* Übersetzt einen Markdown-Text aus der Quellsprache ins Deutsche.
* Wenn der Text bereits auf Deutsch ist, wird er unverändert zurückgegeben.
*
* @param originalText Markdown-Text in Originalsprache
* @return Markdown-Text auf Deutsch
*/
public String translate(String originalText) {
if (originalText == null || originalText.isBlank()) return "";
LOG.debugf("Starte Übersetzung (%d Zeichen)...", originalText.length());
List<ChatMessage> messages = List.of(
SystemMessage.from(
"Du bist ein professioneller Übersetzer für rechtliche und logistische Dokumente.\n" +
"Übersetze den folgenden Text ins Deutsche.\n" +
"Regeln:\n" +
"- Behalte die Markdown-Formatierung exakt bei\n" +
"- Fachbegriffe korrekt übersetzen\n" +
"- Wenn der Text bereits auf Deutsch ist, gib ihn unverändert zurück\n" +
"- Antworte NUR mit dem übersetzten Text, ohne Kommentar oder Präambel"
),
UserMessage.from(originalText)
);
Response<AiMessage> response = mainModel.generate(messages);
return response.content().text().trim();
}
// =========================================================================
// Fragen-Auswertung
// =========================================================================
/**
* Wertet eine einzelne Frage gegen den Dokument-Text aus.
*
* Der Schwellwert (threshold) und die Abweichungs-Logik werden
* im Orchestrator berechnet die KI liefert nur score + answer + comment.
*
* @param question Frage aus dem Katalog (inkl. Beispiele, Schwellwert)
* @param documentText Originaltext des Dokuments (Markdown)
* @return Strukturiertes Auswertungsergebnis
*/
public EvaluationResult evaluate(OrdsQuestion question, String documentText) {
LOG.debugf("Werte Frage %d aus: %s",
question.getQuestionId(),
abbreviate(question.getQuestionText(), 80));
String systemPrompt = buildEvaluationSystem();
String userPrompt = buildEvaluationUser(question, documentText);
List<ChatMessage> messages = List.of(
SystemMessage.from(systemPrompt),
UserMessage.from(userPrompt)
);
Response<AiMessage> response = mainModel.generate(messages);
String rawText = response.content().text();
return parseEvaluationResult(rawText, question);
}
// =========================================================================
// Prompt-Builder
// =========================================================================
private String buildEvaluationSystem() {
return """
Du bist ein Experte für Transportrecht, AGB-Recht und Vertragsanalyse.
Du prüfst Transport- und Speditionsdokumente gegen einen Fragenkatalog.
Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Objekt kein Text davor oder danach.
Das JSON-Objekt muss folgende Felder enthalten:
{
"answer": "<Deine Antwort auf Deutsch. Zitate aus dem Dokument in Originalsprache angeben und danach in Klammern übersetzen>",
"score": <Zahl 0 bis 100, wie gut das Dokument die Anforderung erfüllt>,
"result_type": "<OK wenn score >= Schwellwert, UNKLAR wenn grenzwertig, NOK wenn klar nicht erfüllt>",
"the_comment": "<Kurzer fachlicher Kommentar auf Deutsch, max. 3 Sätze>"
}
""";
}
private String buildEvaluationUser(OrdsQuestion question, String documentText) {
StringBuilder sb = new StringBuilder();
sb.append("## Zu prüfendes Dokument\n\n");
sb.append(documentText).append("\n\n");
sb.append("---\n\n");
sb.append("## Prüffrage (Kategorie: ").append(question.getCategoryName()).append(")\n\n");
sb.append(question.getQuestionText()).append("\n\n");
if (question.getThreshold() != null) {
sb.append("**Schwellwert:** ").append(question.getThreshold())
.append("% unterhalb gilt die Anforderung als nicht erfüllt\n\n");
}
if (question.getExample0Percent() != null && !question.getExample0Percent().isBlank()) {
sb.append("**Beispiel: 0% Erfüllung (nicht ok):**\n")
.append(question.getExample0Percent()).append("\n\n");
}
if (question.getExample100Percent() != null && !question.getExample100Percent().isBlank()) {
sb.append("**Beispiel: 100% Erfüllung (vollständig ok):**\n")
.append(question.getExample100Percent()).append("\n\n");
}
sb.append("---\n\nAntworte jetzt mit dem JSON-Objekt:");
return sb.toString();
}
// =========================================================================
// JSON-Parsing mit Fallback
// =========================================================================
/**
* Parst das KI-Ergebnis zu einem EvaluationResult.
* Toleriert Markdown-Codeblöcke und leichte Formatierungsfehler.
*/
private EvaluationResult parseEvaluationResult(String rawText, OrdsQuestion question) {
try {
String json = extractJson(rawText);
// Jackson liest snake_case dank quarkus.jackson.property-naming-strategy=SNAKE_CASE
EvaluationResult result = objectMapper.readValue(json, EvaluationResult.class);
// Validierung + Defaults
if (result.getScore() == null) result.setScore(0);
if (result.getResultType() == null || !isValidResultType(result.getResultType())) {
result.setResultType(deriveResultType(result.getScore(), question.getThreshold()));
}
if (result.getAnswer() == null) result.setAnswer("Keine Antwort erhalten.");
return result;
} catch (Exception e) {
LOG.warnf("JSON-Parsing fehlgeschlagen für Frage %d, verwende Fallback. Fehler: %s",
question.getQuestionId(), e.getMessage());
LOG.debugf("Rohe KI-Antwort: %s", rawText);
// Fallback: UNKLAR mit dem Rohtext als Antwort
EvaluationResult fallback = new EvaluationResult();
fallback.setAnswer(rawText != null ? rawText.trim() : "Parsing-Fehler");
fallback.setScore(0);
fallback.setResultType("UNKLAR");
fallback.setTheComment("Automatische Auswertung konnte nicht strukturiert werden.");
return fallback;
}
}
/**
* Extrahiert den JSON-Teil aus der KI-Antwort
* (toleriert ```json ... ``` Blöcke oder reines JSON).
*/
private String extractJson(String text) {
if (text == null || text.isBlank()) {
throw new IllegalArgumentException("Leere KI-Antwort");
}
// Versuch 1: JSON in ```-Block
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();
}
}
// Versuch 2: Alles ab erstem { bis letztem }
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
return text.substring(start, end + 1);
}
throw new IllegalArgumentException("Kein JSON-Objekt in KI-Antwort gefunden");
}
private boolean isValidResultType(String type) {
return "OK".equals(type) || "UNKLAR".equals(type) || "NOK".equals(type);
}
private String deriveResultType(int score, Integer threshold) {
if (threshold == null) threshold = 70;
if (score >= threshold) return "OK";
if (score >= threshold / 2) return "UNKLAR";
return "NOK";
}
private String abbreviate(String s, int maxLen) {
if (s == null) return "";
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "";
}
}

View File

@@ -0,0 +1,64 @@
# =============================================================================
# Dokumenten-Check Backend Konfiguration
# =============================================================================
quarkus.application.name=dc-backend
quarkus.http.port=8090
# =============================================================================
# API-Key Absicherung (Env: DC_API_KEY)
# Leer = Auth deaktiviert (nur für lokale Entwicklung)
# =============================================================================
dc.api.key=${DC_API_KEY:}
# =============================================================================
# KI-Modelle (Ollama-kompatibler Endpunkt)
# =============================================================================
dc.ai.base-url=https://ollama.aquantico.de
dc.ai.api-key=324GF44-50AA-4B57-9386-K435DLJ764DFR
# Hauptmodell: Auswertung + Übersetzung
dc.ai.main.model=qwen3.6:35b-a3b-q4_K_M
dc.ai.main.timeout=300s
dc.ai.main.max-retries=2
dc.ai.main.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
dc.ai.ocr.max-retries=2
# =============================================================================
# ORDS REST Client
# =============================================================================
# Basis-URL des ORDS-Servers (Schema-Pfad anpassen)
quarkus.rest-client.ords-api.url=http://ords:8080/ords/ai_dev
quarkus.rest-client.ords-api.scope=jakarta.inject.Singleton
quarkus.rest-client.ords-api.connect-timeout=10000
quarkus.rest-client.ords-api.read-timeout=60000
# ORDS-Authentifizierung (OAuth2 Bearer Token leer = kein Auth in Dev)
dc.ords.bearer-token=
# =============================================================================
# Logging
# =============================================================================
quarkus.log.level=INFO
quarkus.log.category."de.frigosped".level=DEBUG
# KI-Kommunikation mitloggen (nur in Dev)
%dev.dc.ai.log-requests=true
%dev.dc.ai.log-responses=true
%prod.dc.ai.log-requests=false
%prod.dc.ai.log-responses=false
# =============================================================================
# Jackson: snake_case für ORDS-Responses
# =============================================================================
quarkus.jackson.property-naming-strategy=SNAKE_CASE
# =============================================================================
# OpenAPI
# =============================================================================
quarkus.smallrye-openapi.path=/openapi
quarkus.swagger-ui.always-include=true

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,64 @@
# =============================================================================
# Dokumenten-Check Backend Konfiguration
# =============================================================================
quarkus.application.name=dc-backend
quarkus.http.port=8090
# =============================================================================
# API-Key Absicherung (Env: DC_API_KEY)
# Leer = Auth deaktiviert (nur für lokale Entwicklung)
# =============================================================================
dc.api.key=${DC_API_KEY:}
# =============================================================================
# KI-Modelle (Ollama-kompatibler Endpunkt)
# =============================================================================
dc.ai.base-url=https://ollama.aquantico.de
dc.ai.api-key=324GF44-50AA-4B57-9386-K435DLJ764DFR
# Hauptmodell: Auswertung + Übersetzung
dc.ai.main.model=gpt-oss:20b
dc.ai.main.timeout=300s
dc.ai.main.max-retries=2
dc.ai.main.temperature=0.1
# OCR-Modell: Bildtext-Extraktion aus PDF-Seiten
dc.ai.ocr.model=quen3.5:9b
dc.ai.ocr.timeout=120s
dc.ai.ocr.max-retries=2
# =============================================================================
# ORDS REST Client
# =============================================================================
# Basis-URL des ORDS-Servers (Schema-Pfad anpassen)
quarkus.rest-client.ords-api.url=http://localhost:8080/ords/FRIGOSPED_APP
quarkus.rest-client.ords-api.scope=jakarta.inject.Singleton
quarkus.rest-client.ords-api.connect-timeout=10000
quarkus.rest-client.ords-api.read-timeout=60000
# ORDS-Authentifizierung (OAuth2 Bearer Token leer = kein Auth in Dev)
dc.ords.bearer-token=
# =============================================================================
# Logging
# =============================================================================
quarkus.log.level=INFO
quarkus.log.category."de.frigosped".level=DEBUG
# KI-Kommunikation mitloggen (nur in Dev)
%dev.dc.ai.log-requests=true
%dev.dc.ai.log-responses=true
%prod.dc.ai.log-requests=false
%prod.dc.ai.log-responses=false
# =============================================================================
# Jackson: snake_case für ORDS-Responses
# =============================================================================
quarkus.jackson.property-naming-strategy=SNAKE_CASE
# =============================================================================
# OpenAPI
# =============================================================================
quarkus.smallrye-openapi.path=/openapi
quarkus.swagger-ui.always-include=true

View File

@@ -0,0 +1,4 @@
de/frigosped/dc/service/CheckOrchestrationService.class
de/frigosped/dc/service/EvaluationService.class
de/frigosped/dc/service/DocumentProcessingService.class
de/frigosped/dc/security/ApiKeyFilter.class

View File

@@ -0,0 +1,16 @@
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/EvaluationResult.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocument.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsProject.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ResultRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/TextsRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CheckResource.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/security/ApiKeyFilter.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java

View File

@@ -0,0 +1,12 @@
de/frigosped/dc/model/OrdsQuestion.class
de/frigosped/dc/model/OrdsQuestionList.class
de/frigosped/dc/model/ProgressRequest.class
de/frigosped/dc/client/OrdsClient.class
de/frigosped/dc/ai/AiModelProducer.class
de/frigosped/dc/model/OrdsDocument.class
de/frigosped/dc/model/OrdsProject.class
de/frigosped/dc/model/OrdsDocumentList.class
de/frigosped/dc/model/TextsRequest.class
de/frigosped/dc/model/ResultRequest.class
de/frigosped/dc/model/EvaluationResult.class
de/frigosped/dc/resource/CheckResource.class

View File

@@ -0,0 +1,15 @@
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/EvaluationResult.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocument.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsProject.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ResultRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/TextsRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CheckResource.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java

33
dc-backend/update.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export KUBECONFIG="$SCRIPT_DIR/../env/frigo-dev.yaml"
TAG="${1:-latest}"
echo "=== dc-backend Update ==="
echo ""
# --- Build & Push ---
echo "→ [1/3] Build & Push Image (Tag: $TAG)..."
bash "$SCRIPT_DIR/build-and-push.sh" "$TAG"
# --- Apply Kubernetes Manifests ---
echo ""
echo "→ [2/3] Kubernetes Manifests anwenden..."
kubectl apply -f "$SCRIPT_DIR/k8s/deployment.yaml"
# --- Rolling Restart (zieht neues 'latest'-Image) ---
echo ""
echo "→ [3/3] Rolling Restart..."
kubectl rollout restart deployment/dc-backend -n ai-env
echo ""
echo "→ Warte auf Rollout..."
kubectl rollout status deployment/dc-backend -n ai-env --timeout=120s
echo ""
echo "✓ Deployment abgeschlossen."
echo ""
kubectl get pods -n ai-env -l app=dc-backend

10
env/aidev_env.bat vendored Normal file
View File

@@ -0,0 +1,10 @@
@echo off
set dbuser=wksp_ai
set dbpasswd=s!)löyx209aasgtrasdasiudkash5235FDSXljLJ
set dbsid=freepdb1
set dbhost=10.75.10.171
set dbport=32710
set dbconnect=%dbuser%/%dbpasswd%@%dbhost%:%dbport%/%dbsid%
set NLS_LANG=GERMAN_GERMANY.AL32UTF8
chcp 65001

21
env/frigo-dev.yaml vendored Normal file
View File

@@ -0,0 +1,21 @@
apiVersion: v1
kind: Config
clusters:
- name: "local"
cluster:
server: "https://rancherdev.express-o.org/k8s/clusters/local"
insecure-skip-tls-verify: true
users:
- name: "local"
user:
token: "kubeconfig-u-zugnvqkby7rgbsc:7f4n9tplsd9r2wpdl5bx2n72wwkmfvpvsfg56z8b88s2hq986rqsr4"
contexts:
- name: "local"
context:
user: "local"
cluster: "local"
current-context: "local"

0
env/history.log vendored Normal file
View File