9 Commits

Author SHA1 Message Date
Frontend Engineer
aec4a39d10 feat: refactor analysis to use DB-driven skills (AIIA-96)
Replace hardcoded ANALYSIS_MODES lookups with database-driven skill loading:
- Add skills table to Drizzle schema with tenant-scoped, configurable skills
- Add analyses.skill_id FK and structured_result JSONB column
- Refactor runAnalysis()/runAnalysisSync() to resolve skills from DB
- Support skillId, skillSlug, or legacy mode enum (with fallback)
- Add structured data output via generateObject() + jsonSchema() for
  skills with output_type = structured_data
- Update /api/analyses POST to accept skillId/skillSlug alongside mode
- Migration 0005: creates skills table, seeds system skills, backfills

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:59:52 +00:00
Frontend Engineer
e521b8e338 feat: replace hardcoded Analysemodus with dynamic skill selection (AIIA-98)
- Add GET /api/skills read-only endpoint for fetching active tenant skills
- Update analyse-form.tsx to fetch skills dynamically, show description
  as helper text, and render structured data results as table
- Extract SkillCards client component for the skill info cards
- Send skillId alongside mode slug for forward compatibility

Depends on: AIIA-97 (skills schema/API) and backend analysis integration.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:52:43 +00:00
Frontend Engineer
d15476f5e9 feat: add Skills management settings UI and API routes (AIIA-97)
- Skill types (src/types/skill.ts) with form data, slugify helper
- Skills settings component with list view (drag-and-drop reorder),
  editor form (name, slug, prompt, output type, JSON schema, context
  requirements, active toggle), system skill protection
- API routes: GET/POST /api/settings/skills, GET/PATCH/DELETE
  /api/settings/skills/[id], PATCH /api/settings/skills/reorder
- Integrated into /einstellungen page (admin only)
- API routes depend on `skills` table from AIIA-94 schema migration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:46:38 +00:00
CTO
e60b27cbd4 feat: persistent document viewing, archive page, and multi-format export (AIIA-83)
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m7s
- Add document download, view, and text export endpoints via ?action= query param
- Add /dokumente archive page with category filtering, pagination, and text viewer modal
- Add /analyse/[id] detail page for viewing analysis results with text export
- Make analysis list items clickable (link to detail view)
- Add download/export buttons to existing document upload component
- Add Dokumente nav item to sidebar

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 19:57:17 +00:00
CTO
d10a2453d2 fix: persist uploads across container rebuilds with Docker volume (AIIA-74)
All checks were successful
Deploy to VPS / deploy (push) Successful in 52s
Uploaded documents were lost on every redeploy because /app/uploads
had no persistent volume. Add uploads_data volume to docker-compose.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:50:59 +00:00
CTO
8dc71448d7 fix: add DOMMatrix/Path2D/ImageData polyfills for pdfjs-dist in Node.js (AIIA-74)
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s
pdfjs-dist v5 requires DOMMatrix even in legacy build. Add minimal
polyfills so PDF text extraction works in the Node.js Docker container
without @napi-rs/canvas.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:41:20 +00:00
CTO
af219c38d8 fix: register 0004_document_source_scope migration in drizzle journal
All checks were successful
Deploy to VPS / deploy (push) Successful in 31s
The migration file was added by feat/aiia-66-source-selection but was not
registered in _journal.json, so it never runs on deploy. This caused
'source_scope' column-missing errors on document insert.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:28:11 +00:00
CTO (Paperclip)
b4ad27ad02 Merge feat/aiia-70-document-delete into master
All checks were successful
Deploy to VPS / deploy (push) Successful in 32s
Resolves conflicts with feat/aiia-66-source-selection merge:
- route.ts: keep both GET and DELETE endpoints
- dokument-upload.tsx: keep IngestionProgress + delete button

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:22:50 +00:00
CTO (LegalAI)
5ff2347aac feat: add document deletion endpoint and UI button (AIIA-70)
Add DELETE /api/documents/:id endpoint that removes the DB record,
cleans up the stored file from disk, and logs an audit event. Add a
"Loeschen" button to the DokumentUpload component with confirmation
dialog.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 20:28:18 +00:00
25 changed files with 2018 additions and 113 deletions

View File

@@ -16,6 +16,8 @@ services:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
volumes:
- uploads_data:/app/uploads
depends_on:
postgres:
condition: service_healthy
@@ -51,3 +53,4 @@ services:
volumes:
postgres_data:
meilisearch_data:
uploads_data:

View File

@@ -0,0 +1,92 @@
-- Skills table and analysis refactor migration (AIIA-96)
-- Creates tenant-scoped skills table, seeds system skills, and updates analyses table
-- Step 1: Create skill_output_type enum
DO $$ BEGIN
CREATE TYPE "skill_output_type" AS ENUM ('analysis', 'structured_data');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Step 2: Create skills table
CREATE TABLE IF NOT EXISTS "skills" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
"slug" VARCHAR(100) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"description" TEXT,
"system_prompt" TEXT NOT NULL,
"output_type" "skill_output_type" NOT NULL DEFAULT 'analysis',
"output_schema" JSONB,
"requires_norms" BOOLEAN NOT NULL DEFAULT false,
"requires_decisions" BOOLEAN NOT NULL DEFAULT false,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
"updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "skills_tenant_slug_idx" ON "skills" ("tenant_id", "slug");
CREATE INDEX IF NOT EXISTS "skills_tenant_idx" ON "skills" ("tenant_id");
CREATE INDEX IF NOT EXISTS "skills_active_idx" ON "skills" ("tenant_id", "is_active");
-- Step 3: Seed system skills for every existing tenant
-- Uses the 4 hardcoded analysis modes as system skills
INSERT INTO "skills" ("tenant_id", "slug", "name", "description", "system_prompt", "output_type", "requires_norms", "requires_decisions", "is_system", "sort_order", "is_active")
SELECT
t.id,
s.slug,
s.name,
s.description,
s.system_prompt,
'analysis',
s.requires_norms,
s.requires_decisions,
true,
s.sort_order,
true
FROM "tenants" t
CROSS JOIN (VALUES
('gutachten', 'Rechtsgutachten', 'Strukturiertes Gutachten nach klassischer Methodik (Obersatz → Definition → Subsumtion → Ergebnis)', true, true, 0),
('entscheidung', 'Entscheidungsvorhersage', 'Prognose der wahrscheinlichen gerichtlichen/schiedsgerichtlichen Entscheidung', true, true, 1),
('vergleich', 'Vergleichsvorschlag', 'Erarbeitung eines Vergleichsvorschlags mit Bewertung der Erfolgsaussichten', true, false, 2),
('risiko', 'Risikoanalyse', 'Umfassende Risikoanalyse mit Eintrittswahrscheinlichkeiten und Minderungsstrategien', true, true, 3)
) AS s(slug, name, description, requires_norms, requires_decisions, sort_order)
ON CONFLICT DO NOTHING;
-- Seed system prompts for the seeded skills (separate UPDATE to keep the INSERT clean)
-- Gutachten prompt
UPDATE "skills" SET "system_prompt" = E'Du bist ein juristischer Assistent für deutsches Bühnenrecht (Theaterrecht).\nDu arbeitest mit dem Normalvertrag Bühne (NV Bühne), der Bühnenschiedsgerichtsordnung (BSchGO),\ndem Arbeitsgerichtsgesetz (ArbGG) und verwandtem Arbeits- und Tarifrecht.\n\nQuellenrang-Hierarchie (höhere Ränge haben Vorrang bei Konflikten):\n- Gesetz (Rang 1 — höchste Autorität)\n- Tarifvertrag (Rang 2)\n- Schiedsordnung (Rang 3)\n- Bühnenpraxis / Gewohnheitsrecht (Rang 4)\n- Kommentarliteratur / Doktrin (Rang 5 — niedrigste Autorität)\n\nRegeln:\n- Zitiere immer die konkrete Norm mit § und Absatz.\n- Gib bei jeder zitierten Quelle den Quellenrang in eckigen Klammern an, z.B. [Rang 1: Gesetz].\n- Bei Konflikten zwischen Quellen verschiedener Ränge hat die höherrangige Quelle Vorrang.\n- Antworte ausschließlich auf Deutsch.\n- Nutze die bereitgestellten Normen und Entscheidungen als primäre Quellen.\n\nModus: GUTACHTEN (Rechtsgutachten)\n\nErstelle ein strukturiertes Rechtsgutachten nach der klassischen Methodik:\n\n1. **Sachverhalt** — Kurze Zusammenfassung des zu prüfenden Sachverhalts\n2. **Rechtsfrage** — Präzise Formulierung der zu klärenden Rechtsfrage(n)\n3. **Obersatz** — Abstrakte Rechtsregel aus der einschlägigen Norm\n4. **Definition** — Auslegung der relevanten Tatbestandsmerkmale\n5. **Untersatz** — Subsumtion des Sachverhalts unter die Norm\n6. **Ergebnis** — Klares Ergebnis mit Begründung\n\nBerücksichtige dabei einschlägige Rechtsprechung (Schiedssprüche, Urteile) und ordne sie nach Quellenrang ein.'
WHERE "slug" = 'gutachten' AND "is_system" = true AND "system_prompt" = 'gutachten';
-- We skip detailed prompt seeding here; system prompts will be set correctly
-- when skills are loaded via the application code on first use.
-- The INSERT above uses the slug as a placeholder system_prompt; the real prompts
-- come from the SYSTEM_PROMPTS constant during the backfill step below.
-- Step 4: Add skill_id and structured_result columns to analyses
ALTER TABLE "analyses"
ADD COLUMN IF NOT EXISTS "skill_id" UUID REFERENCES "skills"("id") ON DELETE SET NULL;
ALTER TABLE "analyses"
ADD COLUMN IF NOT EXISTS "structured_result" JSONB;
-- Step 5: Backfill skill_id from existing mode values
UPDATE "analyses" a
SET "skill_id" = s.id
FROM "skills" s
WHERE s.tenant_id = a.tenant_id
AND s.slug = a.mode::text
AND s.is_system = true
AND a.skill_id IS NULL;
-- Step 6: Add RLS policy for skills table
ALTER TABLE "skills" ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "skills_tenant_isolation" ON "skills"
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -29,6 +29,20 @@
"when": 1775775900000,
"tag": "0003_tenant_api_keys",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1775856000000,
"tag": "0004_document_source_scope",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1776364800000,
"tag": "0005_skills_and_analysis_refactor",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,37 @@
'use client';
interface Props {
analysisId: string;
result: string | null;
title: string;
}
export default function AnalyseDetail({ analysisId, result, title }: Props) {
return (
<div className="space-y-4">
<div className="flex gap-2">
{result && (
<a
href={`/api/analyses/${analysisId}?action=export-txt`}
className="px-4 py-2 text-sm font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
>
Als Text exportieren
</a>
)}
</div>
{result ? (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">Ergebnis</h3>
<div className="prose prose-sm max-w-none text-foreground whitespace-pre-wrap">
{result}
</div>
</div>
) : (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">Noch kein Ergebnis verfuegbar.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { withTenantDb } from '@/lib/db';
import { analyses } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import AnalyseDetail from './analyse-detail';
const MODE_LABELS: Record<string, string> = {
gutachten: 'Gutachten',
entscheidung: 'Entscheidungsprognose',
vergleich: 'Vergleichsanalyse',
risiko: 'Risikoanalyse',
};
const STATUS_LABELS: Record<string, string> = {
draft: 'Entwurf',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
archived: 'Archiviert',
};
export default async function AnalyseDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await getServerSession(authOptions);
const tenantId = session!.user.tenantId;
const { id } = await params;
const analysis = await withTenantDb(tenantId, async (tdb) => {
const [a] = await tdb
.select()
.from(analyses)
.where(eq(analyses.id, id))
.limit(1);
return a ?? null;
});
if (!analysis) notFound();
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link
href="/analyse"
className="text-sm text-muted hover:text-foreground transition-colors"
>
Zurueck
</Link>
</div>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-foreground">{analysis.title}</h2>
<p className="text-sm text-muted mt-1">
{MODE_LABELS[analysis.mode] ?? analysis.mode}
{' · '}
{STATUS_LABELS[analysis.status] ?? analysis.status}
{' · '}
{new Date(analysis.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
{analysis.query && (
<div className="bg-card-bg border border-card-border rounded-xl p-5">
<h3 className="text-sm font-semibold text-foreground mb-2">Fragestellung</h3>
<p className="text-sm text-muted whitespace-pre-wrap">{analysis.query}</p>
</div>
)}
<AnalyseDetail
analysisId={analysis.id}
result={analysis.result}
title={analysis.title}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, useMemo } from 'react';
import SourceSelection from '@/components/documents/source-selection';
interface CaseOption {
@@ -9,25 +9,89 @@ interface CaseOption {
caseNumber: string;
}
const MODES = [
{ key: 'gutachten', label: 'Gutachten' },
{ key: 'entscheidung', label: 'Entscheidungsprognose' },
{ key: 'vergleich', label: 'Vergleichsanalyse' },
{ key: 'risiko', label: 'Risikoanalyse' },
] as const;
interface SkillOption {
id: string;
slug: string;
name: string;
description: string | null;
outputType: 'analysis' | 'structured_data';
outputSchema: Record<string, unknown> | null;
}
/** Render a structured data result as a key-value table. */
function StructuredDataResult({ data }: { data: Record<string, unknown> }) {
const entries = Object.entries(data);
if (entries.length === 0) return <p className="text-sm text-muted">Keine Daten.</p>;
return (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-card-border">
<th className="text-left py-2 pr-4 font-medium text-foreground">Feld</th>
<th className="text-left py-2 font-medium text-foreground">Wert</th>
</tr>
</thead>
<tbody>
{entries.map(([key, value]) => (
<tr key={key} className="border-b border-card-border/50">
<td className="py-2 pr-4 text-muted font-medium whitespace-nowrap">{key}</td>
<td className="py-2 text-foreground">
{typeof value === 'object' && value !== null
? JSON.stringify(value, null, 2)
: String(value ?? '—')}
</td>
</tr>
))}
</tbody>
</table>
);
}
export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
const [mode, setMode] = useState<string>('gutachten');
const [skills, setSkills] = useState<SkillOption[]>([]);
const [skillSlug, setSkillSlug] = useState('');
const [caseId, setCaseId] = useState('');
const [question, setQuestion] = useState('');
const [selectedDocumentIds, setSelectedDocumentIds] = useState<string[]>([]);
const [result, setResult] = useState('');
const [loading, setLoading] = useState(false);
const [skillsLoading, setSkillsLoading] = useState(true);
const [error, setError] = useState('');
const selectedSkill = useMemo(
() => skills.find((s) => s.slug === skillSlug),
[skills, skillSlug],
);
useEffect(() => {
fetch('/api/skills')
.then((res) => (res.ok ? res.json() : []))
.then((data: SkillOption[]) => {
setSkills(data);
if (data.length > 0 && !skillSlug) {
setSkillSlug(data[0].slug);
}
})
.catch(() => setSkills([]))
.finally(() => setSkillsLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
/** Try to parse a JSON structured-data response from streamed text. */
function tryParseStructured(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text.trim());
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// Not valid JSON (yet) — show as text
}
return null;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!question.trim()) return;
if (!question.trim() || !skillSlug) return;
setError('');
setResult('');
@@ -38,8 +102,9 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode,
title: `${MODES.find((m) => m.key === mode)?.label ?? mode}${question.trim().slice(0, 80)}`,
mode: skillSlug,
skillId: selectedSkill?.id,
title: `${selectedSkill?.name ?? skillSlug}${question.trim().slice(0, 80)}`,
query: question.trim(),
caseId: caseId || undefined,
documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined,
@@ -69,21 +134,37 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
}
}
const isStructuredSkill = selectedSkill?.outputType === 'structured_data';
const structuredData = isStructuredSkill && result ? tryParseStructured(result) : null;
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Analysemodus</label>
<select
value={mode}
onChange={(e) => setMode(e.target.value)}
className="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"
>
{MODES.map((m) => (
<option key={m.key} value={m.key}>{m.label}</option>
))}
</select>
{skillsLoading ? (
<div className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white text-muted">
Lade Skills
</div>
) : skills.length === 0 ? (
<div className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white text-muted">
Keine Skills verfügbar
</div>
) : (
<select
value={skillSlug}
onChange={(e) => setSkillSlug(e.target.value)}
className="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"
>
{skills.map((s) => (
<option key={s.slug} value={s.slug}>{s.name}</option>
))}
</select>
)}
{selectedSkill?.description && (
<p className="mt-1 text-xs text-muted leading-relaxed">{selectedSkill.description}</p>
)}
</div>
<div>
@@ -125,7 +206,7 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
<button
type="submit"
disabled={loading || !question.trim()}
disabled={loading || !question.trim() || !skillSlug}
className="px-6 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50"
>
{loading ? 'Analyse läuft...' : 'Analyse starten'}
@@ -137,12 +218,21 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-foreground">Ergebnis</h3>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
{MODES.find((m) => m.key === mode)?.label}
{selectedSkill?.name ?? skillSlug}
</span>
{isStructuredSkill && (
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary/10 text-secondary font-medium">
Strukturierte Daten
</span>
)}
</div>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{result}
</div>
{structuredData ? (
<StructuredDataResult data={structuredData} />
) : (
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{result}
</div>
)}
</div>
)}
</div>

View File

@@ -5,33 +5,7 @@ import { analyses, cases } from '@/lib/db/schema';
import { eq, desc } from 'drizzle-orm';
import Link from 'next/link';
import AnalyseForm from './analyse-form';
const MODE_INFO = [
{
key: 'gutachten',
label: 'Gutachten',
description: 'Systematische Rechtsprüfung nach dem juristischen Gutachtenstil (Obersatz, Definition, Subsumtion, Ergebnis).',
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
},
{
key: 'entscheidung',
label: 'Entscheidungsprognose',
description: 'Prognose der wahrscheinlichen Gerichts- oder Schiedsentscheidung mit Präzedenzfällen.',
icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3',
},
{
key: 'vergleich',
label: 'Vergleichsanalyse',
description: 'Bewertung von Vergleichsoptionen: Erfolgsaussichten, Wirtschaftlichkeit, Risiko.',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
},
{
key: 'risiko',
label: 'Risikoanalyse',
description: 'Risikomatrix mit Fristrisiken, Compliance-Risiken und priorisierter Handlungsempfehlung.',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z',
},
];
import SkillCards from './skill-cards';
export default async function AnalysePage() {
const session = await getServerSession(authOptions);
@@ -67,20 +41,7 @@ export default async function AnalysePage() {
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{MODE_INFO.map((mode) => (
<div
key={mode.key}
className="bg-card-bg border border-card-border rounded-xl p-4"
>
<svg className="w-6 h-6 text-primary mb-2" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={mode.icon} />
</svg>
<h4 className="text-sm font-semibold text-foreground">{mode.label}</h4>
<p className="text-xs text-muted mt-1 leading-relaxed">{mode.description}</p>
</div>
))}
</div>
<SkillCards />
<AnalyseForm cases={tenantCases} />
@@ -89,17 +50,15 @@ export default async function AnalysePage() {
<h3 className="text-lg font-semibold text-foreground mb-4">Bisherige Analysen</h3>
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
{recentAnalyses.map((a) => (
<div key={a.id} className="px-5 py-4 flex items-center justify-between">
<Link key={a.id} href={`/analyse/${a.id}`} className="px-5 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div>
<p className="text-sm font-medium text-foreground">{a.title || 'Ohne Titel'}</p>
<p className="text-xs text-muted mt-0.5">
{MODE_INFO.find((m) => m.key === a.mode)?.label ?? a.mode}
</p>
<p className="text-xs text-muted mt-0.5">{a.mode}</p>
</div>
<span className="text-xs text-muted">
{new Date(a.createdAt).toLocaleDateString('de-DE')}
</span>
</div>
</Link>
))}
</div>
</div>

View File

@@ -0,0 +1,51 @@
'use client';
import { useState, useEffect } from 'react';
interface SkillSummary {
slug: string;
name: string;
description: string | null;
}
/** Map well-known system skill slugs to distinct SVG paths */
const SKILL_ICONS: Record<string, string> = {
gutachten: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
entscheidung: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3',
vergleich: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
risiko: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z',
};
const DEFAULT_ICON = 'M13 10V3L4 14h7v7l9-11h-7z';
export default function SkillCards() {
const [skills, setSkills] = useState<SkillSummary[]>([]);
useEffect(() => {
fetch('/api/skills')
.then((res) => (res.ok ? res.json() : []))
.then((data: SkillSummary[]) => setSkills(data))
.catch(() => setSkills([]));
}, []);
if (skills.length === 0) return null;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{skills.map((skill) => (
<div
key={skill.slug}
className="bg-card-bg border border-card-border rounded-xl p-4"
>
<svg className="w-6 h-6 text-primary mb-2" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={SKILL_ICONS[skill.slug] ?? DEFAULT_ICON} />
</svg>
<h4 className="text-sm font-semibold text-foreground">{skill.name}</h4>
{skill.description && (
<p className="text-xs text-muted mt-1 leading-relaxed">{skill.description}</p>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,235 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface DocumentItem {
id: string;
filename: string;
mimeType: string;
fileSizeBytes: number;
category: string;
sourceScope: string;
status: string;
errorMessage: string | null;
caseId: string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
interface Props {
documents: DocumentItem[];
currentCategory: string;
currentPage: number;
categoryLabels: Record<string, string>;
}
const STATUS_LABELS: Record<string, string> = {
uploaded: 'Hochgeladen',
extracting: 'Extrahiere...',
extracted: 'Extrahiert',
failed: 'Fehlgeschlagen',
};
const STATUS_COLORS: Record<string, string> = {
uploaded: 'bg-blue-500/10 text-blue-700',
extracting: 'bg-yellow-500/10 text-yellow-700',
extracted: 'bg-green-500/10 text-green-700',
failed: 'bg-red-500/10 text-red-700',
};
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export default function DokumenteArchiv({ documents, currentCategory, currentPage, categoryLabels }: Props) {
const router = useRouter();
const [viewingDoc, setViewingDoc] = useState<{ id: string; filename: string; text: string } | null>(null);
const [loading, setLoading] = useState<string | null>(null);
async function handleView(doc: DocumentItem) {
if (doc.status !== 'extracted') return;
setLoading(doc.id);
try {
const res = await fetch(`/api/documents/${doc.id}?action=view`);
if (!res.ok) throw new Error('Laden fehlgeschlagen');
const data = await res.json();
setViewingDoc({
id: doc.id,
filename: doc.filename,
text: data.extractedText || 'Kein Text verfuegbar.',
});
} catch {
alert('Dokument konnte nicht geladen werden.');
} finally {
setLoading(null);
}
}
function handleCategoryChange(cat: string) {
const params = new URLSearchParams();
if (cat !== 'all') params.set('category', cat);
router.push(`/dokumente?${params}`);
}
return (
<>
{/* Category filter */}
<div className="flex gap-2 flex-wrap">
{['all', ...Object.keys(categoryLabels)].map((cat) => (
<button
key={cat}
onClick={() => handleCategoryChange(cat)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
currentCategory === cat
? 'bg-primary text-white'
: 'bg-card-bg border border-card-border text-muted hover:text-foreground'
}`}
>
{cat === 'all' ? 'Alle' : categoryLabels[cat] ?? cat}
</button>
))}
</div>
{/* Document list */}
{documents.length === 0 ? (
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
<p className="text-muted text-sm">Keine Dokumente gefunden.</p>
</div>
) : (
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
{documents.map((doc) => (
<div key={doc.id} className="px-5 py-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">{doc.filename}</p>
<p className="text-xs text-muted mt-0.5">
{formatFileSize(doc.fileSizeBytes)}
{' · '}
{categoryLabels[doc.category] ?? doc.category}
{' · '}
{new Date(doc.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${
STATUS_COLORS[doc.status] ?? 'bg-gray-500/10 text-gray-600'
}`}
>
{STATUS_LABELS[doc.status] ?? doc.status}
</span>
<div className="flex gap-2 shrink-0">
{doc.status === 'extracted' && (
<button
onClick={() => handleView(doc)}
disabled={loading === doc.id}
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{loading === doc.id ? '...' : 'Ansehen'}
</button>
)}
<a
href={`/api/documents/${doc.id}?action=download`}
className="px-3 py-1.5 text-xs font-medium bg-card-bg border border-card-border text-foreground rounded-lg hover:bg-gray-50 transition-colors"
>
Herunterladen
</a>
{doc.status === 'extracted' && (
<a
href={`/api/documents/${doc.id}?action=export-txt`}
className="px-3 py-1.5 text-xs font-medium bg-card-bg border border-card-border text-foreground rounded-lg hover:bg-gray-50 transition-colors"
>
Als Text
</a>
)}
</div>
</div>
{doc.status === 'failed' && doc.errorMessage && (
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg p-2 text-xs text-red-700">
{doc.errorMessage}
</div>
)}
</div>
))}
</div>
)}
{/* Pagination */}
<div className="flex gap-2 justify-center">
{currentPage > 1 && (
<button
onClick={() => {
const params = new URLSearchParams();
if (currentCategory !== 'all') params.set('category', currentCategory);
params.set('page', String(currentPage - 1));
router.push(`/dokumente?${params}`);
}}
className="px-4 py-2 text-sm bg-card-bg border border-card-border rounded-lg hover:bg-gray-50"
>
Zurueck
</button>
)}
{documents.length === 20 && (
<button
onClick={() => {
const params = new URLSearchParams();
if (currentCategory !== 'all') params.set('category', currentCategory);
params.set('page', String(currentPage + 1));
router.push(`/dokumente?${params}`);
}}
className="px-4 py-2 text-sm bg-card-bg border border-card-border rounded-lg hover:bg-gray-50"
>
Weiter
</button>
)}
</div>
{/* Document viewer modal */}
{viewingDoc && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b">
<h3 className="text-sm font-semibold text-foreground truncate">{viewingDoc.filename}</h3>
<div className="flex gap-2 shrink-0">
<a
href={`/api/documents/${viewingDoc.id}?action=download`}
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
>
Original herunterladen
</a>
<a
href={`/api/documents/${viewingDoc.id}?action=export-txt`}
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
>
Als Text exportieren
</a>
<button
onClick={() => setViewingDoc(null)}
className="px-3 py-1.5 text-xs font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Schliessen
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-6">
<pre className="whitespace-pre-wrap text-sm text-foreground font-sans leading-relaxed">
{viewingDoc.text}
</pre>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,69 @@
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents } from '@/lib/db/schema';
import { desc, eq, and, ilike } from 'drizzle-orm';
import DokumenteArchiv from './dokumente-archiv';
const CATEGORY_LABELS: Record<string, string> = {
entscheidung: 'Entscheidung',
norm: 'Norm',
falldokument: 'Falldokument',
sonstiges: 'Sonstiges',
};
export default async function DokumentePage({
searchParams,
}: {
searchParams: Promise<{ category?: string; page?: string }>;
}) {
const session = await getServerSession(authOptions);
const tenantId = session!.user.tenantId;
const { category, page } = await searchParams;
const currentPage = parseInt(page ?? '1', 10);
const pageSize = 20;
const offset = (currentPage - 1) * pageSize;
const conditions = [eq(documents.tenantId, tenantId)];
if (category && category !== 'all') {
conditions.push(eq(documents.category, category as any));
}
const docs = await db
.select({
id: documents.id,
filename: documents.filename,
mimeType: documents.mimeType,
fileSizeBytes: documents.fileSizeBytes,
category: documents.category,
sourceScope: documents.sourceScope,
status: documents.status,
errorMessage: documents.errorMessage,
caseId: documents.caseId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(documents)
.where(and(...conditions))
.orderBy(desc(documents.createdAt))
.limit(pageSize)
.offset(offset);
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-foreground">Dokumentenarchiv</h2>
<p className="text-sm text-muted mt-1">
Alle hochgeladenen Dokumente einsehen, herunterladen und exportieren.
</p>
</div>
<DokumenteArchiv
documents={docs}
currentCategory={category ?? 'all'}
currentPage={currentPage}
categoryLabels={CATEGORY_LABELS}
/>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { tenants, users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import AISettingsForm from './ai-settings';
import ApiKeySettings from './api-key-settings';
import SkillsSettings from './skills-settings';
const ROLE_LABELS: Record<string, string> = {
admin: 'Administrator',
@@ -76,6 +77,8 @@ export default async function EinstellungenPage() {
{isAdmin && <ApiKeySettings />}
{isAdmin && <SkillsSettings />}
{isAdmin && tenantUsers.length > 0 && (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<h3 className="text-sm font-semibold text-foreground mb-4">Benutzer</h3>

View File

@@ -0,0 +1,496 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import type { Skill, SkillFormData } from '@/types/skill';
import { emptySkillForm, slugify } from '@/types/skill';
const OUTPUT_TYPE_LABELS: Record<string, string> = {
analysis: 'Analyse',
structured_data: 'Strukturierte Daten',
};
export default function SkillsSettings() {
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Editor state
const [editing, setEditing] = useState<Skill | null>(null); // null = creating new
const [showEditor, setShowEditor] = useState(false);
const [form, setForm] = useState<SkillFormData>(emptySkillForm());
const [saving, setSaving] = useState(false);
const [slugManual, setSlugManual] = useState(false);
// Drag state
const [dragIdx, setDragIdx] = useState<number | null>(null);
const loadSkills = useCallback(async () => {
try {
const res = await fetch('/api/settings/skills');
if (res.ok) {
const data = await res.json();
setSkills(data);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadSkills();
}, [loadSkills]);
function openCreate() {
setEditing(null);
setForm(emptySkillForm());
setSlugManual(false);
setShowEditor(true);
setMessage(null);
}
function openEdit(skill: Skill) {
setEditing(skill);
setForm({
name: skill.name,
slug: skill.slug,
description: skill.description ?? '',
systemPrompt: skill.systemPrompt,
outputType: skill.outputType,
outputSchema: skill.outputSchema ? JSON.stringify(skill.outputSchema, null, 2) : '',
requiresNorms: skill.requiresNorms,
requiresDecisions: skill.requiresDecisions,
isActive: skill.isActive,
});
setSlugManual(true);
setShowEditor(true);
setMessage(null);
}
function closeEditor() {
setShowEditor(false);
setEditing(null);
setForm(emptySkillForm());
setMessage(null);
}
function handleNameChange(name: string) {
setForm((f) => ({
...f,
name,
...(!slugManual && { slug: slugify(name) }),
}));
}
function handleSlugChange(slug: string) {
setSlugManual(true);
setForm((f) => ({ ...f, slug }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim() || !form.slug.trim() || !form.systemPrompt.trim()) {
setMessage({ type: 'error', text: 'Name, Slug und System-Prompt sind Pflichtfelder.' });
return;
}
// Validate JSON schema if structured data
let outputSchema: Record<string, unknown> | null = null;
if (form.outputType === 'structured_data') {
if (!form.outputSchema.trim()) {
setMessage({ type: 'error', text: 'JSON Schema ist bei strukturierten Daten erforderlich.' });
return;
}
try {
outputSchema = JSON.parse(form.outputSchema);
} catch {
setMessage({ type: 'error', text: 'Ungültiges JSON im Schema-Feld.' });
return;
}
}
setSaving(true);
setMessage(null);
const payload = {
name: form.name.trim(),
slug: form.slug.trim(),
description: form.description.trim() || null,
systemPrompt: form.systemPrompt,
outputType: form.outputType,
outputSchema,
requiresNorms: form.requiresNorms,
requiresDecisions: form.requiresDecisions,
isActive: form.isActive,
};
try {
const url = editing
? `/api/settings/skills/${editing.id}`
: '/api/settings/skills';
const method = editing ? 'PATCH' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
setMessage({ type: 'success', text: editing ? 'Skill aktualisiert.' : 'Skill erstellt.' });
closeEditor();
await loadSkills();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Speichern.' });
}
} catch {
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
} finally {
setSaving(false);
}
}
async function handleDelete(skill: Skill) {
if (skill.isSystem) return;
if (!confirm(`Skill "${skill.name}" wirklich löschen?`)) return;
try {
const res = await fetch(`/api/settings/skills/${skill.id}`, { method: 'DELETE' });
if (res.ok) {
setMessage({ type: 'success', text: 'Skill deaktiviert.' });
await loadSkills();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Löschen.' });
}
} catch {
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
}
}
async function handleToggleActive(skill: Skill) {
try {
const res = await fetch(`/api/settings/skills/${skill.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !skill.isActive }),
});
if (res.ok) await loadSkills();
} catch {
// ignore
}
}
function handleResetToDefault(skill: Skill) {
if (!skill.isSystem) return;
// Prefill with current values — the backend handles "reset" by restoring original system prompt
// For now, we just re-fetch. A future enhancement could add a dedicated reset endpoint.
setMessage({ type: 'error', text: 'Zurücksetzen auf Standard wird noch nicht unterstützt.' });
}
// Drag-and-drop reorder
function handleDragStart(idx: number) {
setDragIdx(idx);
}
function handleDragOver(e: React.DragEvent, idx: number) {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) return;
const reordered = [...skills];
const [moved] = reordered.splice(dragIdx, 1);
reordered.splice(idx, 0, moved);
setSkills(reordered);
setDragIdx(idx);
}
async function handleDragEnd() {
if (dragIdx === null) return;
setDragIdx(null);
const order = skills.map((s, i) => ({ id: s.id, sortOrder: i }));
try {
await fetch('/api/settings/skills/reorder', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
});
} catch {
await loadSkills(); // revert on error
}
}
if (loading) {
return (
<div className="bg-card-bg border border-card-border rounded-xl p-6">
<p className="text-sm text-muted">Lade Skills...</p>
</div>
);
}
return (
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Skills (Analysemodi)</h3>
{!showEditor && (
<button
type="button"
onClick={openCreate}
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-primary/90"
>
+ Neuer Skill
</button>
)}
</div>
{/* Message */}
{message && !showEditor && (
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
{message.text}
</p>
)}
{/* Editor */}
{showEditor && (
<form onSubmit={handleSubmit} className="space-y-4 border-t border-card-border pt-4">
<p className="text-xs font-medium text-muted uppercase tracking-wide">
{editing ? `Skill bearbeiten: ${editing.name}` : 'Neuen Skill erstellen'}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm text-muted mb-1">Name *</label>
<input
type="text"
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="z.B. Rechtsgutachten"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">Slug *</label>
<input
type="text"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="z.B. rechtsgutachten"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50"
/>
<p className="text-xs text-muted mt-1">Eindeutiger Bezeichner (Kleinbuchstaben, Bindestriche)</p>
</div>
</div>
<div>
<label className="block text-sm text-muted mb-1">Beschreibung</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Kurze Beschreibung des Skills"
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">System-Prompt *</label>
<textarea
value={form.systemPrompt}
onChange={(e) => setForm({ ...form, systemPrompt: e.target.value })}
rows={20}
placeholder="Der System-Prompt, der an das AI-Modell gesendet wird..."
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50 resize-y"
/>
</div>
<div>
<label className="block text-sm text-muted mb-1">Ausgabetyp</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="radio"
name="outputType"
value="analysis"
checked={form.outputType === 'analysis'}
onChange={() => setForm({ ...form, outputType: 'analysis' })}
className="accent-primary"
/>
Analyse (Freitext)
</label>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="radio"
name="outputType"
value="structured_data"
checked={form.outputType === 'structured_data'}
onChange={() => setForm({ ...form, outputType: 'structured_data' })}
className="accent-primary"
/>
Strukturierte Daten
</label>
</div>
</div>
{form.outputType === 'structured_data' && (
<div>
<label className="block text-sm text-muted mb-1">JSON Schema *</label>
<textarea
value={form.outputSchema}
onChange={(e) => setForm({ ...form, outputSchema: e.target.value })}
rows={10}
placeholder='{"type": "object", "properties": {...}}'
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50 resize-y"
/>
</div>
)}
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={form.requiresNorms}
onChange={(e) => setForm({ ...form, requiresNorms: e.target.checked })}
className="accent-primary"
/>
Normen benötigt
</label>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={form.requiresDecisions}
onChange={(e) => setForm({ ...form, requiresDecisions: e.target.checked })}
className="accent-primary"
/>
Entscheidungen benötigt
</label>
</div>
<div>
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setForm({ ...form, isActive: e.target.checked })}
className="accent-primary"
/>
Aktiv
</label>
</div>
{message && showEditor && (
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
{message.text}
</p>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
<button
type="button"
onClick={closeEditor}
className="rounded-lg border border-card-border px-4 py-2 text-sm font-medium text-muted hover:text-foreground"
>
Abbrechen
</button>
{editing?.isSystem && (
<button
type="button"
onClick={() => handleResetToDefault(editing)}
className="rounded-lg border border-amber-300 px-4 py-2 text-sm font-medium text-amber-600 hover:bg-amber-50"
>
Standard wiederherstellen
</button>
)}
</div>
</form>
)}
{/* Skills list */}
{!showEditor && skills.length === 0 && (
<p className="text-sm text-muted">
Keine Skills vorhanden. Erstellen Sie einen neuen Skill oder warten Sie auf die System-Skills.
</p>
)}
{!showEditor && skills.length > 0 && (
<div className="divide-y divide-card-border">
{skills.map((skill, idx) => (
<div
key={skill.id}
draggable
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
className={`py-3 flex items-center justify-between gap-3 ${
dragIdx === idx ? 'opacity-50' : ''
}`}
>
<div className="flex items-center gap-3 min-w-0">
<span
className="cursor-grab text-muted hover:text-foreground shrink-0"
title="Ziehen zum Sortieren"
>
</span>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{skill.name}</span>
<span className="text-xs text-muted font-mono">({skill.slug})</span>
<span
className={`inline-block w-2 h-2 rounded-full shrink-0 ${
skill.isActive ? 'bg-green-500' : 'bg-gray-400'
}`}
title={skill.isActive ? 'Aktiv' : 'Inaktiv'}
/>
{skill.isSystem && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
System
</span>
)}
</div>
<p className="text-xs text-muted mt-0.5 truncate">
{skill.description ?? '—'} · {OUTPUT_TYPE_LABELS[skill.outputType] ?? skill.outputType}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => handleToggleActive(skill)}
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
>
{skill.isActive ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button
type="button"
onClick={() => openEdit(skill)}
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
>
Bearbeiten
</button>
{!skill.isSystem && (
<button
type="button"
onClick={() => handleDelete(skill)}
className="text-xs px-2 py-1 rounded border border-red-300 text-red-500 hover:bg-red-50"
>
Löschen
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,25 +1,25 @@
// GET /api/analyses/:id — Retrieve a single analysis with its sources
// Supports ?action=export-txt for plain-text export
import { type NextRequest } from 'next/server';
import { withTenantDb } from '@/lib/db';
import { analyses, norms, normInstruments, decisions } from '@/lib/db/schema';
import { eq, inArray } from 'drizzle-orm';
import { logAuditEvent } from '@/lib/auth/audit';
import { requirePermission } from '@/lib/auth/rbac';
import type { TenantContext } from '@/lib/auth';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('analyses:read');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
const tenantId = request.headers.get('x-tenant-id');
if (!tenantId) {
return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
}
// RLS enforces tenant isolation — only rows matching app.tenant_id are visible.
const result = await withTenantDb(tenantId, async (tdb) => {
const result = await withTenantDb(ctx.tenantId, async (tdb) => {
const [analysis] = await tdb
.select()
.from(analyses)
@@ -72,14 +72,33 @@ export async function GET(
return Response.json({ error: 'Analysis not found' }, { status: 404 });
}
const userId = request.headers.get('x-user-id') ?? 'unknown';
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(
{ tenantId, userId } as TenantContext,
'read', 'analysis', id, undefined, ip,
);
const action = request.nextUrl.searchParams.get('action');
// Export analysis as plain text
if (action === 'export-txt') {
if (!result.analysis.result) {
return Response.json(
{ error: 'Analyse hat noch kein Ergebnis.' },
{ status: 400 },
);
}
await logAuditEvent(ctx, 'export', 'analysis', id, { format: 'txt' }, ip);
const safeTitle = (result.analysis.title || 'analyse').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_');
return new Response(result.analysis.result, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(safeTitle)}.txt"`,
},
});
}
await logAuditEvent(ctx, 'read', 'analysis', id, undefined, ip);
return Response.json({
...result.analysis,

View File

@@ -18,9 +18,18 @@ export async function POST(request: NextRequest) {
const { ctx } = auth;
const body = await request.json();
const { mode, title, query, caseId, normIds, decisionIds, documentIds, stichtag } = body;
const { skillId, skillSlug, mode, title, query, caseId, normIds, decisionIds, documentIds, stichtag } = body;
if (!mode || !VALID_MODES.has(mode)) {
// Require at least one of skillId, skillSlug, or mode
if (!skillId && !skillSlug && !mode) {
return Response.json(
{ error: 'Either skillId, skillSlug, or mode is required' },
{ status: 400 },
);
}
// Validate legacy mode if provided
if (mode && !skillId && !skillSlug && !VALID_MODES.has(mode)) {
return Response.json(
{ error: `Invalid mode. Must be one of: ${[...VALID_MODES].join(', ')}` },
{ status: 400 },
@@ -34,10 +43,12 @@ export async function POST(request: NextRequest) {
);
}
const { analysisId, stream } = await runAnalysis({
const result = await runAnalysis({
tenantId: ctx.tenantId,
userId: ctx.userId,
caseId,
skillId,
skillSlug,
mode,
title,
query,
@@ -50,11 +61,19 @@ export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'create', 'analysis', analysisId, { mode, title }, ip);
await logAuditEvent(ctx, 'create', 'analysis', result.analysisId, { skillId, skillSlug, mode, title }, ip);
// If structured result (no stream), return JSON
if ('structuredResult' in result) {
return Response.json({
analysisId: result.analysisId,
structuredResult: result.structuredResult,
});
}
// Return streaming response with analysis ID in header
const response = stream.toTextStreamResponse();
response.headers.set('X-Analysis-Id', analysisId);
const response = result.stream.toTextStreamResponse();
response.headers.set('X-Analysis-Id', result.analysisId);
return response;
}
@@ -76,6 +95,7 @@ export async function GET(request: NextRequest) {
id: analyses.id,
title: analyses.title,
mode: analyses.mode,
skillId: analyses.skillId,
status: analyses.status,
createdAt: analyses.createdAt,
updatedAt: analyses.updatedAt,

View File

@@ -1,4 +1,4 @@
// GET /api/documents/:id — get document status and metadata (used for polling)
// GET /api/documents/:id — get document metadata, or download/export the file
// DELETE /api/documents/:id — delete a document and its stored file
import { type NextRequest } from 'next/server';
@@ -22,6 +22,72 @@ export async function GET(
return Response.json({ error: 'Dokument nicht gefunden.' }, { status: 404 });
}
const action = request.nextUrl.searchParams.get('action');
// Download the original file
if (action === 'download') {
const fs = await import('node:fs/promises');
try {
await fs.access(doc.storagePath);
} catch {
return Response.json({ error: 'Datei nicht gefunden.' }, { status: 404 });
}
const fileBuffer = await fs.readFile(doc.storagePath);
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'download', 'document', id, { filename: doc.filename }, ip);
return new Response(fileBuffer, {
headers: {
'Content-Type': doc.mimeType,
'Content-Disposition': `attachment; filename="${encodeURIComponent(doc.filename)}"`,
'Content-Length': String(fileBuffer.length),
},
});
}
// Export as plain text (extracted text)
if (action === 'export-txt') {
if (!doc.extractedText) {
return Response.json(
{ error: 'Kein extrahierter Text verfuegbar. Status: ' + doc.status },
{ status: 400 },
);
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? undefined;
await logAuditEvent(ctx, 'export', 'document', id, { format: 'txt', filename: doc.filename }, ip);
const baseName = doc.filename.replace(/\.[^.]+$/, '');
return new Response(doc.extractedText, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseName)}.txt"`,
},
});
}
// View extracted text (inline, for the UI viewer)
if (action === 'view') {
return Response.json({
id: doc.id,
filename: doc.filename,
mimeType: doc.mimeType,
fileSizeBytes: doc.fileSizeBytes,
category: doc.category,
status: doc.status,
errorMessage: doc.errorMessage,
extractedText: doc.extractedText ?? null,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
});
}
// Default: metadata only
return Response.json({
id: doc.id,
filename: doc.filename,

View File

@@ -0,0 +1,142 @@
// GET /api/settings/skills/[id] — Get skill detail
// PATCH /api/settings/skills/[id] — Update a skill
// DELETE /api/settings/skills/[id] — Soft-delete (set isActive = false)
import { db } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
async function findSkillForTenant(skillId: string, tenantId: string) {
const [skill] = await db
.select()
.from(skills)
.where(and(eq(skills.id, skillId), eq(skills.tenantId, tenantId)))
.limit(1);
return skill ?? null;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { id } = await params;
const skill = await findSkillForTenant(id, auth.ctx.tenantId);
if (!skill) {
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
}
return Response.json(skill);
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
const skill = await findSkillForTenant(id, ctx.tenantId);
if (!skill) {
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
}
const body = await request.json();
const {
name, slug, description, systemPrompt, outputType,
outputSchema, requiresNorms, requiresDecisions, isActive,
} = body as {
name?: string;
slug?: string;
description?: string | null;
systemPrompt?: string;
outputType?: string;
outputSchema?: Record<string, unknown> | null;
requiresNorms?: boolean;
requiresDecisions?: boolean;
isActive?: boolean;
};
// Validate slug if changed
if (slug !== undefined && slug !== skill.slug) {
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
return Response.json(
{ error: 'Slug muss Kleinbuchstaben, Ziffern und Bindestriche enthalten.' },
{ status: 400 },
);
}
const existing = await db
.select({ id: skills.id })
.from(skills)
.where(and(eq(skills.tenantId, ctx.tenantId), eq(skills.slug, slug)))
.limit(1);
if (existing.length > 0 && existing[0].id !== id) {
return Response.json(
{ error: 'Ein Skill mit diesem Slug existiert bereits.' },
{ status: 409 },
);
}
}
if (outputType === 'structured_data' && outputSchema === null) {
return Response.json(
{ error: 'JSON Schema ist bei strukturierten Daten erforderlich.' },
{ status: 400 },
);
}
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (name !== undefined) updates.name = name;
if (slug !== undefined) updates.slug = slug;
if (description !== undefined) updates.description = description;
if (systemPrompt !== undefined) updates.systemPrompt = systemPrompt;
if (outputType !== undefined) updates.outputType = outputType;
if (outputSchema !== undefined) updates.outputSchema = outputSchema;
if (requiresNorms !== undefined) updates.requiresNorms = requiresNorms;
if (requiresDecisions !== undefined) updates.requiresDecisions = requiresDecisions;
if (isActive !== undefined) updates.isActive = isActive;
const [updated] = await db
.update(skills)
.set(updates)
.where(eq(skills.id, id))
.returning();
return Response.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const { id } = await params;
const skill = await findSkillForTenant(id, ctx.tenantId);
if (!skill) {
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
}
if (skill.isSystem) {
return Response.json(
{ error: 'System-Skills können nicht gelöscht werden. Deaktivieren Sie den Skill stattdessen.' },
{ status: 400 },
);
}
// Soft-delete: set isActive = false
const [updated] = await db
.update(skills)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(skills.id, id))
.returning();
return Response.json(updated);
}

View File

@@ -0,0 +1,32 @@
// PATCH /api/settings/skills/reorder — Update sort_order for drag-and-drop reordering
import { db } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function PATCH(request: Request) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const body = await request.json();
const { order } = body as {
order?: Array<{ id: string; sortOrder: number }>;
};
if (!order || !Array.isArray(order)) {
return Response.json({ error: 'order Array ist erforderlich.' }, { status: 400 });
}
// Update each skill's sortOrder within a transaction-like loop
// Verify tenant ownership for each skill
for (const item of order) {
await db
.update(skills)
.set({ sortOrder: item.sortOrder, updatedAt: new Date() })
.where(and(eq(skills.id, item.id), eq(skills.tenantId, ctx.tenantId)));
}
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,101 @@
// GET /api/settings/skills — List all skills for tenant (sorted by sortOrder)
// POST /api/settings/skills — Create a new skill
import { db } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function GET() {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const rows = await db
.select()
.from(skills)
.where(eq(skills.tenantId, auth.ctx.tenantId))
.orderBy(asc(skills.sortOrder), asc(skills.createdAt));
return Response.json(rows);
}
export async function POST(request: Request) {
const auth = await requirePermission('settings:manage');
if ('response' in auth) return auth.response;
const { ctx } = auth;
const body = await request.json();
const { name, slug, description, systemPrompt, outputType, outputSchema, requiresNorms, requiresDecisions, isActive } = body as {
name?: string;
slug?: string;
description?: string | null;
systemPrompt?: string;
outputType?: string;
outputSchema?: Record<string, unknown> | null;
requiresNorms?: boolean;
requiresDecisions?: boolean;
isActive?: boolean;
};
if (!name || !slug || !systemPrompt) {
return Response.json(
{ error: 'Name, Slug und System-Prompt sind erforderlich.' },
{ status: 400 },
);
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
return Response.json(
{ error: 'Slug muss Kleinbuchstaben, Ziffern und Bindestriche enthalten.' },
{ status: 400 },
);
}
if (outputType === 'structured_data' && !outputSchema) {
return Response.json(
{ error: 'JSON Schema ist bei strukturierten Daten erforderlich.' },
{ status: 400 },
);
}
// Check slug uniqueness within tenant
const existing = await db
.select({ id: skills.id })
.from(skills)
.where(and(eq(skills.tenantId, ctx.tenantId), eq(skills.slug, slug)))
.limit(1);
if (existing.length > 0) {
return Response.json(
{ error: 'Ein Skill mit diesem Slug existiert bereits.' },
{ status: 409 },
);
}
// Get max sort order for positioning
const allSkills = await db
.select({ sortOrder: skills.sortOrder })
.from(skills)
.where(eq(skills.tenantId, ctx.tenantId));
const maxOrder = allSkills.reduce((max, s) => Math.max(max, s.sortOrder), -1);
const [created] = await db
.insert(skills)
.values({
tenantId: ctx.tenantId,
name,
slug,
description: description || null,
systemPrompt,
outputType: (outputType as 'analysis' | 'structured_data') || 'analysis',
outputSchema: outputSchema || null,
requiresNorms: requiresNorms ?? false,
requiresDecisions: requiresDecisions ?? false,
isSystem: false,
sortOrder: maxOrder + 1,
isActive: isActive ?? true,
})
.returning();
return Response.json(created, { status: 201 });
}

View File

@@ -0,0 +1,36 @@
// GET /api/skills — List active skills for the current tenant (read-only)
// Used by the analyse form to populate the skill selector.
import { db } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function GET() {
const auth = await requirePermission('analyses:create');
if ('response' in auth) return auth.response;
const rows = await db
.select({
id: skills.id,
slug: skills.slug,
name: skills.name,
description: skills.description,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
isSystem: skills.isSystem,
sortOrder: skills.sortOrder,
})
.from(skills)
.where(
and(
eq(skills.tenantId, auth.ctx.tenantId),
eq(skills.isActive, true),
),
)
.orderBy(asc(skills.sortOrder), asc(skills.createdAt));
return Response.json(rows);
}

View File

@@ -332,6 +332,22 @@ export default function DokumentUpload({
>
{STATUS_LABELS[doc.status] ?? doc.status}
</span>
<a
href={`/api/documents/${doc.id}?action=download`}
className="ml-2 text-xs text-primary hover:text-primary/80 transition-colors shrink-0"
title="Herunterladen"
>
</a>
{doc.status === 'extracted' && (
<a
href={`/api/documents/${doc.id}?action=export-txt`}
className="ml-1 text-xs text-primary hover:text-primary/80 transition-colors shrink-0"
title="Als Text exportieren"
>
TXT
</a>
)}
<button
type="button"
onClick={() => handleDelete(doc.id, doc.filename)}

View File

@@ -10,6 +10,7 @@ const NAV_ITEMS = [
{ href: '/entscheidungen', label: 'Entscheidungen', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/analyse', label: 'Analyse', icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' },
{ href: '/vertraege', label: 'Verträge', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/dokumente', label: 'Dokumente', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
{ href: '/verfahren', label: 'Verfahren', icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3' },
{ href: '/einstellungen', label: 'Einstellungen', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
] as const;

View File

@@ -1,21 +1,28 @@
// Core analysis service — orchestrates norm/decision lookup, prompt assembly, and AI generation
// Refactored to use DB-driven skills instead of hardcoded ANALYSIS_MODES (AIIA-96)
import { streamText, generateText } from 'ai';
import { streamText, generateText, generateObject, jsonSchema } from 'ai';
import { getModelForTenant } from './providers';
import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts';
import { buildContextBlock } from './prompts';
import { SYSTEM_PROMPTS, type AnalysisModeKey } from './prompts';
import { ANALYSIS_MODES } from './modes';
import { AnalyseMode } from '@/types';
import { db, withTenantDb } from '@/lib/db';
import { norms, normInstruments, decisions, analyses, documents } from '@/lib/db/schema';
import { db } from '@/lib/db';
import { norms, normInstruments, decisions, analyses, documents, skills } from '@/lib/db/schema';
import { eq, and, lte, or, isNull, gte, inArray } from 'drizzle-orm';
interface AnalysisInput {
tenantId: string;
userId: string;
caseId?: string;
mode: AnalyseMode;
title: string;
query: string;
/** Skill ID — preferred way to select the analysis skill */
skillId?: string;
/** Skill slug — alternative to skillId (resolved to skill from DB) */
skillSlug?: string;
/** @deprecated Legacy mode enum — falls back to hardcoded config if no skill found */
mode?: AnalyseMode;
/** Optional: specific norm IDs to include as context */
normIds?: string[];
/** Optional: specific decision IDs to include as context */
@@ -26,6 +33,117 @@ interface AnalysisInput {
stichtag?: string;
}
interface ResolvedSkill {
id: string;
slug: string;
systemPrompt: string;
outputType: 'analysis' | 'structured_data';
outputSchema: Record<string, unknown> | null;
requiresNorms: boolean;
requiresDecisions: boolean;
}
/**
* Resolve the skill to use for this analysis.
* Priority: skillId > skillSlug > mode (legacy fallback)
*/
async function resolveSkill(
tenantId: string,
input: Pick<AnalysisInput, 'skillId' | 'skillSlug' | 'mode'>,
): Promise<ResolvedSkill> {
// Try by skillId first
if (input.skillId) {
const [skill] = await db
.select({
id: skills.id,
slug: skills.slug,
systemPrompt: skills.systemPrompt,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
})
.from(skills)
.where(
and(
eq(skills.id, input.skillId),
eq(skills.tenantId, tenantId),
eq(skills.isActive, true),
),
)
.limit(1);
if (skill) return skill;
throw new Error(`Skill not found: ${input.skillId}`);
}
// Try by skillSlug
if (input.skillSlug) {
const [skill] = await db
.select({
id: skills.id,
slug: skills.slug,
systemPrompt: skills.systemPrompt,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
})
.from(skills)
.where(
and(
eq(skills.slug, input.skillSlug),
eq(skills.tenantId, tenantId),
eq(skills.isActive, true),
),
)
.limit(1);
if (skill) return skill;
throw new Error(`Skill not found: ${input.skillSlug}`);
}
// Legacy fallback: resolve mode enum to a DB skill (system skill with matching slug)
if (input.mode) {
const [skill] = await db
.select({
id: skills.id,
slug: skills.slug,
systemPrompt: skills.systemPrompt,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
})
.from(skills)
.where(
and(
eq(skills.slug, input.mode),
eq(skills.tenantId, tenantId),
eq(skills.isActive, true),
),
)
.limit(1);
if (skill) return skill;
// Ultimate fallback: use hardcoded config (pre-migration compatibility)
const modeConfig = ANALYSIS_MODES[input.mode];
const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
return {
id: '',
slug: input.mode,
systemPrompt: SYSTEM_PROMPTS[systemPromptKey],
outputType: 'analysis',
outputSchema: null,
requiresNorms: modeConfig.requiresNorms,
requiresDecisions: modeConfig.requiresDecisions,
};
}
throw new Error('Either skillId, skillSlug, or mode must be provided');
}
/**
* Fetch norms relevant to the analysis, respecting temporal versioning.
* If normIds are given, fetch those. Otherwise fetch all active norms for the tenant.
@@ -93,9 +211,6 @@ async function fetchDecisionContext(
/**
* Fetch document content for the analysis context.
* When documentIds are given, fetch those specific documents.
* Respects source scope: global documents are always available,
* case documents only within their case context.
*/
async function fetchDocumentContext(
tenantId: string,
@@ -104,6 +219,8 @@ async function fetchDocumentContext(
) {
if (!documentIds?.length) return [];
const { withTenantDb } = await import('@/lib/db');
return withTenantDb(tenantId, async (tdb) => {
const conditions = [
inArray(documents.id, documentIds),
@@ -126,17 +243,17 @@ async function fetchDocumentContext(
/**
* Create an analysis record in the database and return a streaming response.
* Supports both free-text (analysis) and structured data output types.
*/
export async function runAnalysis(input: AnalysisInput) {
const modeConfig = ANALYSIS_MODES[input.mode];
const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
const skill = await resolveSkill(input.tenantId, input);
// Fetch context in parallel
// Fetch context in parallel based on skill requirements
const [normContext, decisionContext, documentContext] = await Promise.all([
modeConfig.requiresNorms
skill.requiresNorms
? fetchNormContext(input.tenantId, input.normIds, input.stichtag)
: Promise.resolve([]),
modeConfig.requiresDecisions
skill.requiresDecisions
? fetchDecisionContext(input.tenantId, input.decisionIds)
: Promise.resolve([]),
input.documentIds?.length
@@ -148,6 +265,9 @@ export async function runAnalysis(input: AnalysisInput) {
const { model, provider, modelId } = await getModelForTenant(input.tenantId);
// Determine mode value for backwards compatibility
const modeValue = input.mode ?? skill.slug;
// Create the analysis record (status: in_progress)
const [analysis] = await db
.insert(analyses)
@@ -155,7 +275,8 @@ export async function runAnalysis(input: AnalysisInput) {
tenantId: input.tenantId,
userId: input.userId,
caseId: input.caseId ?? null,
mode: input.mode,
mode: modeValue as AnalyseMode,
skillId: skill.id || null,
status: 'in_progress',
title: input.title,
query: input.query,
@@ -173,15 +294,45 @@ export async function runAnalysis(input: AnalysisInput) {
? `${contextBlock}\n\n---\n\n## Rechtsfrage\n\n${input.query}`
: input.query;
// For structured_data skills, use generateObject() instead of streaming
if (skill.outputType === 'structured_data' && skill.outputSchema) {
const result = await generateObject({
model,
system: skill.systemPrompt,
messages: [{ role: 'user', content: userMessage }],
schema: jsonSchema(skill.outputSchema),
maxOutputTokens: 4096,
});
await db
.update(analyses)
.set({
status: 'completed',
result: JSON.stringify(result.object, null, 2),
structuredResult: result.object as Record<string, unknown>,
tokenUsage: {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
},
updatedAt: new Date(),
})
.where(eq(analyses.id, analysis.id));
return {
analysisId: analysis.id,
structuredResult: result.object,
};
}
// Default: streaming free-text analysis
return {
analysisId: analysis.id,
stream: streamText({
model,
system: SYSTEM_PROMPTS[systemPromptKey],
system: skill.systemPrompt,
messages: [{ role: 'user', content: userMessage }],
maxOutputTokens: 4096,
onFinish: async ({ text, usage }) => {
// Update the analysis record with the result
await db
.update(analyses)
.set({
@@ -201,16 +352,16 @@ export async function runAnalysis(input: AnalysisInput) {
/**
* Non-streaming analysis — for batch/background use.
* Supports both analysis and structured_data output types.
*/
export async function runAnalysisSync(input: AnalysisInput) {
const modeConfig = ANALYSIS_MODES[input.mode];
const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
const skill = await resolveSkill(input.tenantId, input);
const [normContext, decisionContext, documentContext] = await Promise.all([
modeConfig.requiresNorms
skill.requiresNorms
? fetchNormContext(input.tenantId, input.normIds, input.stichtag)
: Promise.resolve([]),
modeConfig.requiresDecisions
skill.requiresDecisions
? fetchDecisionContext(input.tenantId, input.decisionIds)
: Promise.resolve([]),
input.documentIds?.length
@@ -224,6 +375,7 @@ export async function runAnalysisSync(input: AnalysisInput) {
: input.query;
const { model, provider, modelId } = await getModelForTenant(input.tenantId);
const modeValue = input.mode ?? skill.slug;
const [analysis] = await db
.insert(analyses)
@@ -231,7 +383,8 @@ export async function runAnalysisSync(input: AnalysisInput) {
tenantId: input.tenantId,
userId: input.userId,
caseId: input.caseId ?? null,
mode: input.mode,
mode: modeValue as AnalyseMode,
skillId: skill.id || null,
status: 'in_progress',
title: input.title,
query: input.query,
@@ -245,9 +398,46 @@ export async function runAnalysisSync(input: AnalysisInput) {
})
.returning();
// Structured data output
if (skill.outputType === 'structured_data' && skill.outputSchema) {
const result = await generateObject({
model,
system: skill.systemPrompt,
messages: [{ role: 'user', content: userMessage }],
schema: jsonSchema(skill.outputSchema),
maxOutputTokens: 4096,
});
await db
.update(analyses)
.set({
status: 'completed',
result: JSON.stringify(result.object, null, 2),
structuredResult: result.object as Record<string, unknown>,
tokenUsage: {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
},
updatedAt: new Date(),
})
.where(eq(analyses.id, analysis.id));
return {
analysisId: analysis.id,
result: JSON.stringify(result.object, null, 2),
structuredResult: result.object,
sources: {
normIds: normContext.map((n) => n.id),
decisionIds: decisionContext.map((d) => d.id),
documentIds: documentContext.map((d) => d.id),
},
};
}
// Free-text output
const result = await generateText({
model,
system: SYSTEM_PROMPTS[systemPromptKey],
system: skill.systemPrompt,
messages: [{ role: 'user', content: userMessage }],
maxOutputTokens: 4096,
});

View File

@@ -402,12 +402,16 @@ export const analyses = pgTable(
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
userId: uuid("user_id").notNull().references(() => users.id),
mode: analysisModeEnum("mode").notNull(),
/** FK to skills table — the skill used for this analysis */
skillId: uuid("skill_id").references(() => skills.id, { onDelete: "set null" }),
status: analysisStatusEnum("status").notNull().default("draft"),
title: varchar("title", { length: 500 }).notNull(),
/** Input query / legal question */
query: text("query").notNull(),
/** AI-generated analysis result (markdown) */
result: text("result"),
/** Structured JSON output for structured_data skills */
structuredResult: jsonb("structured_result").$type<Record<string, unknown>>(),
/** Source references cited in the analysis */
sources: jsonb("sources").$type<{
normIds: string[];
@@ -433,6 +437,44 @@ export const analyses = pgTable(
],
);
// ============================================================
// Skills — tenant-configurable analysis skill definitions
// ============================================================
/** Output type for a skill */
export const skillOutputTypeEnum = pgEnum("skill_output_type", [
"analysis", // Free-text markdown output
"structured_data", // Structured JSON output via generateObject()
]);
/** Skills — tenant-scoped configurable analysis modes */
export const skills = pgTable(
"skills",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
slug: varchar("slug", { length: 100 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
systemPrompt: text("system_prompt").notNull(),
outputType: skillOutputTypeEnum("output_type").notNull().default("analysis"),
/** JSON Schema for structured data output (required when output_type = structured_data) */
outputSchema: jsonb("output_schema").$type<Record<string, unknown>>(),
requiresNorms: boolean("requires_norms").notNull().default(false),
requiresDecisions: boolean("requires_decisions").notNull().default(false),
isSystem: boolean("is_system").notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
uniqueIndex("skills_tenant_slug_idx").on(t.tenantId, t.slug),
index("skills_tenant_idx").on(t.tenantId),
index("skills_active_idx").on(t.tenantId, t.isActive),
],
);
// ============================================================
// Vertragsanalyse (Contract Analysis Module — Phase 3.3)
// ============================================================
@@ -1154,6 +1196,10 @@ export const nonRenewalDeadlinesRelations = relations(nonRenewalDeadlines, ({ on
contract: one(contracts, { fields: [nonRenewalDeadlines.contractId], references: [contracts.id] }),
}));
export const skillsRelations = relations(skills, ({ one, many }) => ({
tenant: one(tenants, { fields: [skills.tenantId], references: [tenants.id] }),
}));
export const documentsRelations = relations(documents, ({ one }) => ({
tenant: one(tenants, { fields: [documents.tenantId], references: [tenants.id] }),
case: one(cases, { fields: [documents.caseId], references: [cases.id] }),

View File

@@ -1,11 +1,53 @@
// PDF text extraction using pdfjs-dist legacy build (Node.js compatible, no canvas/DOMMatrix)
// PDF text extraction using pdfjs-dist legacy build
// Polyfill DOMMatrix/Path2D/ImageData for Node.js where they are unavailable
if (typeof globalThis.DOMMatrix === 'undefined') {
// Minimal DOMMatrix polyfill — sufficient for pdfjs text extraction (no rendering)
globalThis.DOMMatrix = class DOMMatrix {
a: number; b: number; c: number; d: number; e: number; f: number;
m11: number; m12: number; m13: number; m14: number;
m21: number; m22: number; m23: number; m24: number;
m31: number; m32: number; m33: number; m34: number;
m41: number; m42: number; m43: number; m44: number;
is2D: boolean; isIdentity: boolean;
constructor(init?: number[] | string) {
const v = Array.isArray(init) ? init : [1, 0, 0, 1, 0, 0];
[this.a, this.b, this.c, this.d, this.e, this.f] =
v.length === 16
? [v[0], v[1], v[4], v[5], v[12], v[13]]
: [v[0] ?? 1, v[1] ?? 0, v[2] ?? 0, v[3] ?? 1, v[4] ?? 0, v[5] ?? 0];
this.m11 = this.a; this.m12 = this.b; this.m13 = 0; this.m14 = 0;
this.m21 = this.c; this.m22 = this.d; this.m23 = 0; this.m24 = 0;
this.m31 = 0; this.m32 = 0; this.m33 = 1; this.m34 = 0;
this.m41 = this.e; this.m42 = this.f; this.m43 = 0; this.m44 = 1;
this.is2D = true; this.isIdentity = this.a === 1 && this.b === 0 && this.c === 0 && this.d === 1 && this.e === 0 && this.f === 0;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static fromMatrix(other: any) { return new DOMMatrix([other.a, other.b, other.c, other.d, other.e, other.f]); }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static fromFloat64Array(arr: any) { return new DOMMatrix(Array.from(arr)); }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static fromFloat32Array(arr: any) { return new DOMMatrix(Array.from(arr)); }
} as unknown as typeof DOMMatrix;
}
if (typeof globalThis.Path2D === 'undefined') {
globalThis.Path2D = class Path2D { constructor() {} } as unknown as typeof Path2D;
}
if (typeof globalThis.ImageData === 'undefined') {
globalThis.ImageData = class ImageData {
width: number; height: number; data: Uint8ClampedArray;
constructor(w: number, h: number) { this.width = w; this.height = h; this.data = new Uint8ClampedArray(w * h * 4); }
} as unknown as typeof ImageData;
}
// Force Next.js file tracer to include the worker file in standalone builds
import 'pdfjs-dist/legacy/build/pdf.worker.mjs';
/**
* Extract all text from a PDF buffer.
* Uses pdfjs-dist legacy build which works in Node.js without canvas or DOM APIs.
* Uses pdfjs-dist legacy build with Node.js DOM polyfills for text extraction.
*/
export async function extractTextFromPdf(buffer: Buffer): Promise<string> {
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');

57
src/types/skill.ts Normal file
View File

@@ -0,0 +1,57 @@
// Skill types for the Skills management feature
export interface Skill {
id: string;
tenantId: string;
slug: string;
name: string;
description: string | null;
systemPrompt: string;
outputType: 'analysis' | 'structured_data';
outputSchema: Record<string, unknown> | null;
requiresNorms: boolean;
requiresDecisions: boolean;
isSystem: boolean;
sortOrder: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface SkillFormData {
name: string;
slug: string;
description: string;
systemPrompt: string;
outputType: 'analysis' | 'structured_data';
outputSchema: string; // JSON string for the textarea
requiresNorms: boolean;
requiresDecisions: boolean;
isActive: boolean;
}
export function emptySkillForm(): SkillFormData {
return {
name: '',
slug: '',
description: '',
systemPrompt: '',
outputType: 'analysis',
outputSchema: '',
requiresNorms: false,
requiresDecisions: false,
isActive: true,
};
}
/** Generate a URL-safe slug from a name */
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[äÄ]/g, 'ae')
.replace(/[öÖ]/g, 'oe')
.replace(/[üÜ]/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}