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:
482
src/app/(dashboard)/normen/import/page.tsx
Normal file
482
src/app/(dashboard)/normen/import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
147
src/app/api/norms/parse/route.ts
Normal file
147
src/app/api/norms/parse/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user