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>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -92,9 +53,7 @@ export default async function AnalysePage() {
|
||||
<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')}
|
||||
|
||||
51
src/app/(dashboard)/analyse/skill-cards.tsx
Normal file
51
src/app/(dashboard)/analyse/skill-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/app/api/skills/route.ts
Normal file
36
src/app/api/skills/route.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user