From c86ff8d1516ca8aae23661cb38fa16c4b12de115 Mon Sep 17 00:00:00 2001
From: "CTO (LegalAI)"
Date: Thu, 9 Apr 2026 11:46:44 +0000
Subject: [PATCH] feat: Fliesstext-Import fuer Gesetze mit KI-Parsing
Neuer /normen/import Bildschirm: Gesetzestext als Fliesstext einfuegen
oder TXT-Datei hochladen, KI zerlegt automatisch in Paragraphen,
Vorschau mit Bearbeitungsmoeglichkeit, dann Import ins Regelwerk.
- POST /api/norms/parse: AI-gestuetztes Parsing von Gesetzestexten
- /normen/import: Mehrstufiges Frontend (Eingabe -> Vorschau -> Import)
- Link zum Fliesstext-Import auf der Normen-Uebersichtsseite
Co-Authored-By: Paperclip
---
src/app/(dashboard)/normen/import/page.tsx | 482 +++++++++++++++++++++
src/app/(dashboard)/normen/page.tsx | 20 +-
src/app/api/norms/parse/route.ts | 147 +++++++
3 files changed, 643 insertions(+), 6 deletions(-)
create mode 100644 src/app/(dashboard)/normen/import/page.tsx
create mode 100644 src/app/api/norms/parse/route.ts
diff --git a/src/app/(dashboard)/normen/import/page.tsx b/src/app/(dashboard)/normen/import/page.tsx
new file mode 100644
index 0000000..c0d0933
--- /dev/null
+++ b/src/app/(dashboard)/normen/import/page.tsx
@@ -0,0 +1,482 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+
+const NORM_TYPES = [
+ { value: 'gesetz', label: 'Gesetz' },
+ { value: 'tarifvertrag', label: 'Tarifvertrag' },
+ { value: 'schiedsordnung', label: 'Schiedsordnung' },
+ { value: 'verordnung', label: 'Verordnung' },
+ { value: 'satzung', label: 'Satzung' },
+];
+
+const SOURCE_RANKS = [
+ { value: 'gesetz', label: 'Gesetz' },
+ { value: 'tarif', label: 'Tarifvertrag' },
+ { value: 'schiedsordnung', label: 'Schiedsordnung' },
+ { value: 'praxis', label: 'Praxis' },
+ { value: 'kommentar', label: 'Kommentar' },
+];
+
+interface ParsedProvision {
+ paragraph: string;
+ title: string;
+ body: string;
+ validFrom: string;
+ included: boolean;
+}
+
+type Step = 'input' | 'preview' | 'done';
+
+export default function ImportNormPage() {
+ const router = useRouter();
+ const [step, setStep] = useState('input');
+ const [rawText, setRawText] = useState('');
+ const [provisions, setProvisions] = useState([]);
+ const [parsing, setParsing] = useState(false);
+ const [importing, setImporting] = useState(false);
+ const [error, setError] = useState('');
+ const [importResult, setImportResult] = useState<{ instrumentId: string; count: number } | null>(null);
+
+ async function handleFileUpload(e: React.ChangeEvent) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (file.type === 'text/plain' || file.name.endsWith('.txt')) {
+ const text = await file.text();
+ setRawText(text);
+ } else {
+ setError('Nur TXT-Dateien werden aktuell unterstuetzt. Bitte fuegen Sie den Text manuell ein.');
+ }
+ }
+
+ async function handleParse(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ setParsing(true);
+
+ try {
+ const res = await fetch('/api/norms/parse', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: rawText }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ throw new Error(data?.error ?? 'Parsing fehlgeschlagen.');
+ }
+
+ const data = await res.json();
+ const today = new Date().toISOString().split('T')[0];
+ const parsed: ParsedProvision[] = data.provisions.map(
+ (p: { paragraph: string; title: string; body: string }) => ({
+ ...p,
+ validFrom: today,
+ included: true,
+ }),
+ );
+
+ setProvisions(parsed);
+ setStep('preview');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten.');
+ } finally {
+ setParsing(false);
+ }
+ }
+
+ function updateProvision(index: number, field: keyof ParsedProvision, value: string | boolean) {
+ setProvisions((prev) =>
+ prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)),
+ );
+ }
+
+ async function handleImport(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ setImporting(true);
+
+ const form = new FormData(e.currentTarget);
+
+ const instrumentBody = {
+ type: form.get('type'),
+ sourceRank: form.get('sourceRank'),
+ abbreviation: form.get('abbreviation'),
+ fullTitle: form.get('fullTitle'),
+ enactedAt: form.get('enactedAt') || undefined,
+ issuingBody: form.get('issuingBody') || undefined,
+ };
+
+ const selectedProvisions = provisions
+ .filter((p) => p.included)
+ .map((p) => ({
+ paragraph: p.paragraph,
+ title: p.title || undefined,
+ body: p.body,
+ validFrom: p.validFrom,
+ }));
+
+ if (selectedProvisions.length === 0) {
+ setError('Bitte waehlen Sie mindestens einen Paragraphen aus.');
+ setImporting(false);
+ return;
+ }
+
+ try {
+ // Create instrument
+ const instrumentRes = await fetch('/api/norms', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(instrumentBody),
+ });
+
+ if (!instrumentRes.ok) {
+ const data = await instrumentRes.json().catch(() => null);
+ throw new Error(data?.error ?? 'Regelwerk konnte nicht angelegt werden.');
+ }
+
+ const instrumentData = await instrumentRes.json();
+ const instrumentId = instrumentData.instrument.id;
+
+ // Import provisions
+ const importRes = await fetch('/api/norms/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ instrumentId, provisions: selectedProvisions }),
+ });
+
+ if (!importRes.ok) {
+ const data = await importRes.json().catch(() => null);
+ throw new Error(data?.error ?? 'Import fehlgeschlagen.');
+ }
+
+ setImportResult({ instrumentId, count: selectedProvisions.length });
+ setStep('done');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten.');
+ } finally {
+ setImporting(false);
+ }
+ }
+
+ const inputClass =
+ 'w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
+
+ return (
+
+
+
+ Normen
+
+ /
+ Fliesstext-Import
+
+
+
Gesetzestext importieren
+
+ Fuegen Sie einen vollstaendigen Gesetzestext ein. Die KI zerlegt ihn automatisch in einzelne Paragraphen.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Step 1: Input */}
+ {step === 'input' && (
+
+ )}
+
+ {/* Step 2: Preview & Edit */}
+ {step === 'preview' && (
+
+ )}
+
+ {/* Step 3: Done */}
+ {step === 'done' && importResult && (
+
+
+ {importResult.count} Paragraphen erfolgreich importiert.
+
+
+
+
+ Zurueck zur Uebersicht
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/normen/page.tsx b/src/app/(dashboard)/normen/page.tsx
index fb02e2f..b1f4d17 100644
--- a/src/app/(dashboard)/normen/page.tsx
+++ b/src/app/(dashboard)/normen/page.tsx
@@ -43,12 +43,20 @@ export default async function NormenPage() {
Rechtsquellen nach Quellenrang geordnet. Höherrangige Normen gehen vor.
-
- Neues Regelwerk
-
+
+
+ Fliesstext-Import
+
+
+ Neues Regelwerk
+
+
diff --git a/src/app/api/norms/parse/route.ts b/src/app/api/norms/parse/route.ts
new file mode 100644
index 0000000..abec935
--- /dev/null
+++ b/src/app/api/norms/parse/route.ts
@@ -0,0 +1,147 @@
+// POST /api/norms/parse
+// Accepts raw law text (Fließtext) and uses AI to parse it into structured provisions.
+//
+// Body: {
+// text: string, // The full law text to parse
+// tenantId?: string, // Optional tenant for AI provider selection
+// }
+//
+// Returns: {
+// provisions: Array<{
+// paragraph: string,
+// title: string,
+// body: string,
+// }>
+// }
+
+import { generateText } from 'ai';
+import { getModelForTenant, getModel } from '@/lib/ai/providers';
+
+const PARSE_SYSTEM_PROMPT = `Du bist ein Experte fuer deutsches Recht und Gesetzestexte. Deine Aufgabe ist es, einen Fliesstext eines Gesetzes, Tarifvertrags oder einer anderen Rechtsquelle in einzelne Paragraphen zu zerlegen.
+
+Analysiere den Text und extrahiere jeden Paragraphen mit folgenden Feldern:
+- paragraph: Die Paragraphen-Nummer (z.B. "§ 1", "§ 2", "§ 53a"). Verwende das Format "§ X" mit Leerzeichen nach §.
+- title: Die Ueberschrift des Paragraphen (falls vorhanden, sonst leer lassen)
+- body: Der vollstaendige Normtext des Paragraphen (ohne die Paragraphen-Nummer und Ueberschrift)
+
+Wichtige Regeln:
+- Erkenne alle Paragraphen im Text, auch wenn sie unterschiedlich formatiert sind
+- Behalte den originalen Text bei, aendere nichts am Inhalt
+- Wenn ein Paragraph Absaetze hat (z.B. "(1)", "(2)"), gehoeren diese zum body des Paragraphen
+- Praeambeln oder einleitende Texte ohne Paragraphen-Nummer ignorieren
+- Anhaenge, Anlagen oder Schlussformeln ignorieren, sofern sie keine Paragraphen enthalten
+
+Antworte NUR mit einem JSON-Array. Kein erklaerener Text, kein Markdown, nur das JSON-Array:
+[
+ {
+ "paragraph": "§ 1",
+ "title": "Geltungsbereich",
+ "body": "Dieser Vertrag gilt fuer..."
+ }
+]`;
+
+interface ParseRequest {
+ text: string;
+ tenantId?: string;
+}
+
+export async function POST(request: Request) {
+ let body: ParseRequest;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: 'Invalid JSON body.' }, { status: 400 });
+ }
+
+ const { text, tenantId } = body;
+
+ if (!text || typeof text !== 'string' || text.trim().length === 0) {
+ return Response.json(
+ { error: 'text field is required and must be non-empty.' },
+ { status: 400 },
+ );
+ }
+
+ if (text.length > 500_000) {
+ return Response.json(
+ { error: 'Text is too long. Maximum 500,000 characters.' },
+ { status: 400 },
+ );
+ }
+
+ try {
+ let model;
+ if (tenantId) {
+ const result = await getModelForTenant(tenantId);
+ model = result.model;
+ } else {
+ model = getModel();
+ }
+
+ const result = await generateText({
+ model,
+ system: PARSE_SYSTEM_PROMPT,
+ messages: [
+ {
+ role: 'user',
+ content: `Bitte zerlege den folgenden Gesetzestext in einzelne Paragraphen:\n\n${text}`,
+ },
+ ],
+ maxOutputTokens: 16384,
+ });
+
+ // Parse the JSON response from the AI
+ const responseText = result.text.trim();
+
+ // Extract JSON array — handle possible markdown code fences
+ let jsonStr = responseText;
+ const fenceMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/);
+ if (fenceMatch) {
+ jsonStr = fenceMatch[1].trim();
+ }
+
+ let provisions: Array<{ paragraph: string; title: string; body: string }>;
+ try {
+ provisions = JSON.parse(jsonStr);
+ } catch {
+ return Response.json(
+ {
+ error: 'AI returned invalid JSON. Please try again.',
+ rawResponse: responseText.substring(0, 500),
+ },
+ { status: 502 },
+ );
+ }
+
+ if (!Array.isArray(provisions)) {
+ return Response.json(
+ { error: 'AI did not return an array of provisions.' },
+ { status: 502 },
+ );
+ }
+
+ // Validate and clean provisions
+ const cleaned = provisions
+ .filter(
+ (p) =>
+ p &&
+ typeof p.paragraph === 'string' &&
+ typeof p.body === 'string' &&
+ p.paragraph.trim().length > 0 &&
+ p.body.trim().length > 0,
+ )
+ .map((p) => ({
+ paragraph: p.paragraph.trim(),
+ title: (p.title ?? '').trim(),
+ body: p.body.trim(),
+ }));
+
+ return Response.json({ provisions: cleaned });
+ } catch (err) {
+ console.error('Norm parse error:', err);
+ return Response.json(
+ { error: 'AI parsing failed. Please check your AI provider configuration.' },
+ { status: 500 },
+ );
+ }
+}