From e521b8e33865f3ccc7d9157aad73bdfcca25297f Mon Sep 17 00:00:00 2001 From: Frontend Engineer Date: Mon, 13 Apr 2026 19:52:43 +0000 Subject: [PATCH 1/2] 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 --- src/app/(dashboard)/analyse/analyse-form.tsx | 140 +++++++++++++++---- src/app/(dashboard)/analyse/page.tsx | 47 +------ src/app/(dashboard)/analyse/skill-cards.tsx | 51 +++++++ src/app/api/skills/route.ts | 36 +++++ 4 files changed, 205 insertions(+), 69 deletions(-) create mode 100644 src/app/(dashboard)/analyse/skill-cards.tsx create mode 100644 src/app/api/skills/route.ts diff --git a/src/app/(dashboard)/analyse/analyse-form.tsx b/src/app/(dashboard)/analyse/analyse-form.tsx index 00fc332..55194b9 100644 --- a/src/app/(dashboard)/analyse/analyse-form.tsx +++ b/src/app/(dashboard)/analyse/analyse-form.tsx @@ -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 | null; +} + +/** Render a structured data result as a key-value table. */ +function StructuredDataResult({ data }: { data: Record }) { + const entries = Object.entries(data); + if (entries.length === 0) return

Keine Daten.

; + + return ( + + + + + + + + + {entries.map(([key, value]) => ( + + + + + ))} + +
FeldWert
{key} + {typeof value === 'object' && value !== null + ? JSON.stringify(value, null, 2) + : String(value ?? '—')} +
+ ); +} export default function AnalyseForm({ cases }: { cases: CaseOption[] }) { - const [mode, setMode] = useState('gutachten'); + const [skills, setSkills] = useState([]); + const [skillSlug, setSkillSlug] = useState(''); const [caseId, setCaseId] = useState(''); const [question, setQuestion] = useState(''); const [selectedDocumentIds, setSelectedDocumentIds] = useState([]); 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 | null { + try { + const parsed = JSON.parse(text.trim()); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } 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 (
- + {skillsLoading ? ( +
+ Lade Skills… +
+ ) : skills.length === 0 ? ( +
+ Keine Skills verfügbar +
+ ) : ( + + )} + {selectedSkill?.description && ( +

{selectedSkill.description}

+ )}
@@ -125,7 +206,7 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
)}
diff --git a/src/app/(dashboard)/analyse/page.tsx b/src/app/(dashboard)/analyse/page.tsx index b1b5823..31812bd 100644 --- a/src/app/(dashboard)/analyse/page.tsx +++ b/src/app/(dashboard)/analyse/page.tsx @@ -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() {

-
- {MODE_INFO.map((mode) => ( -
- - - -

{mode.label}

-

{mode.description}

-
- ))} -
+ @@ -92,9 +53,7 @@ export default async function AnalysePage() {

{a.title || 'Ohne Titel'}

-

- {MODE_INFO.find((m) => m.key === a.mode)?.label ?? a.mode} -

+

{a.mode}

{new Date(a.createdAt).toLocaleDateString('de-DE')} diff --git a/src/app/(dashboard)/analyse/skill-cards.tsx b/src/app/(dashboard)/analyse/skill-cards.tsx new file mode 100644 index 0000000..7395a2f --- /dev/null +++ b/src/app/(dashboard)/analyse/skill-cards.tsx @@ -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 = { + 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([]); + + useEffect(() => { + fetch('/api/skills') + .then((res) => (res.ok ? res.json() : [])) + .then((data: SkillSummary[]) => setSkills(data)) + .catch(() => setSkills([])); + }, []); + + if (skills.length === 0) return null; + + return ( +
+ {skills.map((skill) => ( +
+ + + +

{skill.name}

+ {skill.description && ( +

{skill.description}

+ )} +
+ ))} +
+ ); +} diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts new file mode 100644 index 0000000..bbb3ade --- /dev/null +++ b/src/app/api/skills/route.ts @@ -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); +} From aec4a39d10375e818338465004ed73f7b84fee32 Mon Sep 17 00:00:00 2001 From: Frontend Engineer Date: Mon, 13 Apr 2026 19:59:52 +0000 Subject: [PATCH 2/2] 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 --- drizzle/0005_skills_and_analysis_refactor.sql | 92 +++++++ drizzle/meta/_journal.json | 7 + src/app/api/analyses/route.ts | 32 ++- src/lib/ai/analysis.ts | 234 ++++++++++++++++-- src/lib/db/schema.ts | 46 ++++ 5 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 drizzle/0005_skills_and_analysis_refactor.sql diff --git a/drizzle/0005_skills_and_analysis_refactor.sql b/drizzle/0005_skills_and_analysis_refactor.sql new file mode 100644 index 0000000..9b19c0d --- /dev/null +++ b/drizzle/0005_skills_and_analysis_refactor.sql @@ -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 $$; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c9e2a25..7ca6ff8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/app/api/analyses/route.ts b/src/app/api/analyses/route.ts index f851157..4c6e367 100644 --- a/src/app/api/analyses/route.ts +++ b/src/app/api/analyses/route.ts @@ -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, diff --git a/src/lib/ai/analysis.ts b/src/lib/ai/analysis.ts index 41e35d8..6303f3b 100644 --- a/src/lib/ai/analysis.ts +++ b/src/lib/ai/analysis.ts @@ -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 | 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, +): Promise { + // 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, + 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, + 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, }); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index c7c6b5d..d474827 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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>(), /** 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>(), + 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] }),