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 <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-09 11:46:44 +00:00
parent 58d96864cc
commit c86ff8d151
3 changed files with 643 additions and 6 deletions

View File

@@ -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<Step>('input');
const [rawText, setRawText] = useState('');
const [provisions, setProvisions] = useState<ParsedProvision[]>([]);
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<HTMLInputElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm text-muted">
<Link href="/normen" className="hover:text-primary transition-colors">
Normen
</Link>
<span>/</span>
<span>Fliesstext-Import</span>
</div>
<h1 className="text-xl font-bold text-foreground">Gesetzestext importieren</h1>
<p className="text-sm text-muted">
Fuegen Sie einen vollstaendigen Gesetzestext ein. Die KI zerlegt ihn automatisch in einzelne Paragraphen.
</p>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 text-sm">
{error}
</div>
)}
{/* Step 1: Input */}
{step === 'input' && (
<form onSubmit={handleParse} className="space-y-6">
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<h2 className="text-sm font-semibold text-foreground">Gesetzestext</h2>
<div>
<label htmlFor="fileUpload" className="block text-sm font-medium text-foreground mb-1">
TXT-Datei hochladen (optional)
</label>
<input
id="fileUpload"
type="file"
accept=".txt,text/plain"
onChange={handleFileUpload}
className="block w-full text-sm text-muted file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border file:border-card-border file:text-sm file:font-medium file:bg-white file:text-foreground hover:file:bg-gray-50 file:cursor-pointer"
/>
</div>
<div>
<label htmlFor="rawText" className="block text-sm font-medium text-foreground mb-1">
Oder Text direkt einfuegen *
</label>
<textarea
id="rawText"
value={rawText}
onChange={(e) => setRawText(e.target.value)}
rows={16}
required
placeholder="Fuegen Sie hier den vollstaendigen Gesetzestext ein..."
className={inputClass + ' font-mono text-xs'}
/>
<p className="mt-1 text-xs text-muted">
{rawText.length.toLocaleString('de-DE')} Zeichen
</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={parsing || rawText.trim().length === 0}
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{parsing ? 'KI analysiert...' : 'Text analysieren'}
</button>
<Link
href="/normen"
className="px-4 py-2.5 border border-card-border rounded-lg text-sm font-medium text-muted hover:text-foreground transition-colors"
>
Abbrechen
</Link>
</div>
</form>
)}
{/* Step 2: Preview & Edit */}
{step === 'preview' && (
<form onSubmit={handleImport} className="space-y-6">
{/* Instrument metadata */}
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<h2 className="text-sm font-semibold text-foreground">Regelwerk-Metadaten</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-foreground mb-1">
Typ *
</label>
<select id="type" name="type" required className={inputClass}>
<option value="">Bitte waehlen...</option>
{NORM_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="sourceRank" className="block text-sm font-medium text-foreground mb-1">
Quellenrang *
</label>
<select id="sourceRank" name="sourceRank" required className={inputClass}>
<option value="">Bitte waehlen...</option>
{SOURCE_RANKS.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="abbreviation" className="block text-sm font-medium text-foreground mb-1">
Abkuerzung *
</label>
<input
id="abbreviation"
name="abbreviation"
type="text"
required
placeholder="z.B. NV Buehne"
className={inputClass}
/>
</div>
<div>
<label htmlFor="fullTitle" className="block text-sm font-medium text-foreground mb-1">
Vollstaendiger Titel *
</label>
<input
id="fullTitle"
name="fullTitle"
type="text"
required
placeholder="z.B. Normalvertrag Buehne"
className={inputClass}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="enactedAt" className="block text-sm font-medium text-foreground mb-1">
Inkrafttreten
</label>
<input id="enactedAt" name="enactedAt" type="date" className={inputClass} />
</div>
<div>
<label htmlFor="issuingBody" className="block text-sm font-medium text-foreground mb-1">
Herausgeber
</label>
<input
id="issuingBody"
name="issuingBody"
type="text"
placeholder="z.B. GDBA/DBV"
className={inputClass}
/>
</div>
</div>
</div>
{/* Parsed provisions preview */}
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">
Erkannte Paragraphen ({provisions.filter((p) => p.included).length} von{' '}
{provisions.length} ausgewaehlt)
</h2>
<button
type="button"
onClick={() => {
setStep('input');
setProvisions([]);
}}
className="px-3 py-1.5 text-xs font-medium text-muted border border-card-border rounded-lg hover:bg-gray-50 transition-colors"
>
Zurueck zum Text
</button>
</div>
{provisions.length === 0 ? (
<p className="text-sm text-muted">
Keine Paragraphen erkannt. Bitte ueberpruefen Sie den Eingabetext.
</p>
) : (
<div className="space-y-3">
{provisions.map((prov, idx) => (
<div
key={idx}
className={`border rounded-lg p-4 space-y-3 transition-colors ${
prov.included
? 'border-card-border bg-white'
: 'border-gray-200 bg-gray-50 opacity-60'
}`}
>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs font-medium text-muted cursor-pointer">
<input
type="checkbox"
checked={prov.included}
onChange={(e) => updateProvision(idx, 'included', e.target.checked)}
className="rounded border-gray-300"
/>
Paragraph {idx + 1}
</label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Bezeichnung
</label>
<input
type="text"
value={prov.paragraph}
onChange={(e) => updateProvision(idx, 'paragraph', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Titel
</label>
<input
type="text"
value={prov.title}
onChange={(e) => updateProvision(idx, 'title', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Gueltig ab
</label>
<input
type="date"
value={prov.validFrom}
onChange={(e) => updateProvision(idx, 'validFrom', e.target.value)}
className={inputClass}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1">
Normtext
</label>
<textarea
value={prov.body}
onChange={(e) => updateProvision(idx, 'body', e.target.value)}
rows={4}
className={inputClass + ' font-mono text-xs'}
/>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={importing || provisions.filter((p) => p.included).length === 0}
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{importing
? 'Wird importiert...'
: `${provisions.filter((p) => p.included).length} Paragraphen importieren`}
</button>
<button
type="button"
onClick={() => {
setStep('input');
setProvisions([]);
}}
className="px-4 py-2.5 border border-card-border rounded-lg text-sm font-medium text-muted hover:text-foreground transition-colors"
>
Abbrechen
</button>
</div>
</form>
)}
{/* Step 3: Done */}
{step === 'done' && importResult && (
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<div className="bg-green-50 border border-green-200 text-green-700 rounded-lg px-4 py-3 text-sm">
{importResult.count} Paragraphen erfolgreich importiert.
</div>
<div className="flex items-center gap-3">
<button
onClick={() => router.push(`/normen/${importResult.instrumentId}`)}
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors"
>
Zum Regelwerk
</button>
<Link
href="/normen"
className="px-4 py-2.5 border border-card-border rounded-lg text-sm font-medium text-muted hover:text-foreground transition-colors"
>
Zurueck zur Uebersicht
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -43,12 +43,20 @@ export default async function NormenPage() {
Rechtsquellen nach Quellenrang geordnet. Höherrangige Normen gehen vor.
</p>
</div>
<Link
href="/normen/new"
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors"
>
Neues Regelwerk
</Link>
<div className="flex items-center gap-2">
<Link
href="/normen/import"
className="px-4 py-2.5 border border-primary text-primary rounded-lg text-sm font-medium hover:bg-primary/5 transition-colors"
>
Fliesstext-Import
</Link>
<Link
href="/normen/new"
className="px-4 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors"
>
Neues Regelwerk
</Link>
</div>
</div>
<div className="flex flex-wrap gap-2">

View File

@@ -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 },
);
}
}