Merge pull request 'feat: Dynamic skill selection for analysis (AIIA-98)' (#4) from feat/aiia-98-dynamic-skill-selection into master
All checks were successful
Deploy to VPS / deploy (push) Successful in 46s

This commit is contained in:
2026-04-13 20:44:20 +00:00
9 changed files with 588 additions and 97 deletions

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

@@ -36,6 +36,13 @@
"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

@@ -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} />
@@ -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')}

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

@@ -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

@@ -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

@@ -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)
// ============================================================
@@ -1155,6 +1197,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] }),