22 Commits

Author SHA1 Message Date
Gitea Actions
f4885805f1 Bump build to 0.9.0.11 [skip ci] 2026-04-15 08:50:32 +00:00
CTO
b8f4427f90 fix: OpenRouter API key save fails with network error
All checks were successful
Deploy to VPS / deploy (push) Successful in 40s
The 0004_add_openrouter_provider.sql migration existed but was never
registered in _journal.json, so the 'openrouter' value was missing from
the api_key_provider PostgreSQL enum. Inserting an OpenRouter key threw
a DB error that was unhandled, causing Next.js to return an HTML 500;
the frontend's res.json() then threw, showing "Netzwerkfehler".

Fixes:
- Add 0004_add_openrouter_provider to _journal.json (idx 7) so the
  migration runs on next deploy and registers 'openrouter' in the enum
- Fix null-label duplicate check: use isNull() instead of passing
  undefined to and(), which incorrectly matched all provider keys
- Wrap DB insert in try/catch to return a proper JSON error instead of
  crashing with an unhandled exception

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 08:50:26 +00:00
Gitea Actions
3366f84137 Bump build to 0.9.0.10 [skip ci] 2026-04-15 08:49:19 +00:00
Gitea Actions
a49e191999 Bump build to 0.9.0.9 [skip ci] 2026-04-15 08:31:16 +00:00
CTO
2594d78613 fix: cast AnalyseMode enum to string literal union for Drizzle pgEnum insert
All checks were successful
Deploy to VPS / deploy (push) Successful in 50s
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 08:31:11 +00:00
Gitea Actions
2feca358cc Bump build to 0.9.0.8 [skip ci] 2026-04-15 08:22:38 +00:00
CTO Agent
41fcc5be42 feat: extend skills with dynamic resolution and RBAC, add API docs
Some checks failed
Deploy to VPS / deploy (push) Failing after 49s
- Add resolveAnalysisMode() to modes/index.ts for dynamic skill lookup
- Extend structured-analysis.ts to use resolveAnalysisMode (supports custom skill prompts and skillId persistence)
- Update structured analyses route to use resolveAnalysisMode instead of hardcoded VALID_MODES set
- Add skills:read/create/edit/delete RBAC permissions to rbac.ts
- Add docs/API_GUIDE.md with full endpoint reference

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 08:22:33 +00:00
Gitea Actions
da2a81f081 Bump build to 0.9.0.7 [skip ci] 2026-04-13 21:58:18 +00:00
CTO
622d0bee34 fix: handle missing ENCRYPTION_KEY in API key save routes and fix openrouter provider type
All checks were successful
Deploy to VPS / deploy (push) Successful in 33s
The encrypt() call threw an unhandled error when ENCRYPTION_KEY env var was missing,
causing a 500 that the frontend displayed as "Netzwerkfehler beim Speichern des Schlüssels".
Now returns a clear error message. Also fixed provider type cast that excluded 'openrouter'.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 21:58:11 +00:00
Gitea Actions
f4ad3da3ec Bump build to 0.9.0.6 [skip ci] 2026-04-13 21:30:37 +00:00
CTO
2caf7d1229 fix: remove broken CROSS JOIN from migration 0005
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s
The CROSS JOIN referenced s.system_prompt which didn't exist in the
VALUES alias, causing the entire migration transaction to fail.
This prevented the skills table from being created at all.

Seed is now handled entirely by migration 0006.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 21:30:33 +00:00
Gitea Actions
f73a97e696 Bump build to 0.9.0.5 [skip ci] 2026-04-13 21:15:34 +00:00
CTO
f0a7d6837b fix: use withTenantDb for skills API routes (RLS fix) and seed default skills
All checks were successful
Deploy to VPS / deploy (push) Successful in 40s
All skills API routes were using `db` directly instead of `withTenantDb`,
causing RLS to block all operations since `app.tenant_id` was never set.
This caused "Netzwerkfehler" when creating/reading skills.

Also fixes the broken seed migration (0005) which referenced a non-existent
column in the CROSS JOIN, preventing default system skills from being inserted.
New migration 0006 properly seeds the 4 default skills with full system prompts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 21:15:24 +00:00
Gitea Actions
b665f530e0 Bump build to 0.9.0.4 [skip ci] 2026-04-13 20:44:23 +00:00
a5e58aa142 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
2026-04-13 20:44:20 +00:00
Gitea Actions
abc6a259f0 Bump build to 0.9.0.3 [skip ci] 2026-04-13 20:43:18 +00:00
445491654b Merge pull request 'feat: Skills management settings UI and API routes (AIIA-97)' (#3) from feat/aiia-97-skills-settings-ui into master
Some checks failed
Deploy to VPS / deploy (push) Failing after 35s
2026-04-13 20:43:16 +00:00
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
Gitea Actions
817a0da714 Bump build to 0.9.0.2 [skip ci] 2026-04-12 20:54:11 +00:00
CTO (LegalAI)
86f4ef9012 feat: add auto-incrementing version number (0.9.0.X) in footer
All checks were successful
Deploy to VPS / deploy (push) Successful in 38s
- Added version.json to track base version and build number
- Updated Gitea Actions workflow to bump build number on each deploy
- Footer now shows version number alongside commit hash
- Version passed as build arg through Docker build pipeline

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 20:54:01 +00:00
CTO (LegalAI)
27132aa383 feat: add OpenRouter as an AI provider (AIIA-86)
All checks were successful
Deploy to VPS / deploy (push) Successful in 41s
Integrate OpenRouter via its OpenAI-compatible API so users can select
and use OpenRouter models alongside existing Anthropic/OpenAI/Ollama
providers. Adds provider to type system, DB enum, API validation,
buildModel switch, and settings UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 20:50:20 +00:00
31 changed files with 1486 additions and 214 deletions

View File

@@ -14,10 +14,24 @@ jobs:
cd /home/remmer/StageAI
git pull origin master
- name: Bump version
run: |
cd /home/remmer/StageAI
BUILD=$(jq '.build' version.json)
NEW_BUILD=$((BUILD + 1))
VERSION=$(jq -r '.version' version.json)
jq --arg b "$NEW_BUILD" '.build = ($b | tonumber)' version.json > version.tmp && mv version.tmp version.json
echo "APP_VERSION=${VERSION}.${NEW_BUILD}" >> $GITHUB_ENV
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
git add version.json
git commit -m "Bump build to ${VERSION}.${NEW_BUILD} [skip ci]"
git push origin master
- name: Build and deploy
run: |
cd /home/remmer/StageAI
export COMMIT_HASH=$(git rev-parse --short HEAD)
docker compose build app
docker compose build --build-arg COMMIT_HASH=$COMMIT_HASH --build-arg APP_VERSION=${{ env.APP_VERSION }} app
docker compose up -d app
echo "Deployed commit: $COMMIT_HASH"
echo "Deployed version: ${{ env.APP_VERSION }} (commit: $COMMIT_HASH)"

View File

@@ -8,7 +8,9 @@ RUN npm ci
FROM base AS builder
WORKDIR /app
ARG COMMIT_HASH=dev
ARG APP_VERSION=0.9.0.1
ENV NEXT_PUBLIC_BUILD_HASH=$COMMIT_HASH
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

View File

@@ -4,6 +4,7 @@ services:
context: .
args:
COMMIT_HASH: ${COMMIT_HASH:-dev}
APP_VERSION: ${APP_VERSION:-0.9.0.1}
ports:
- "3002:3000"
environment:

633
docs/API_GUIDE.md Normal file
View File

@@ -0,0 +1,633 @@
# StageAI API Usage Guide
This guide covers all API endpoints with practical examples for text and office document sources.
## Base URL
```
http://localhost:3000/api
```
---
## 1. Authentication
### Register a New Tenant
Creates a new tenant (law firm) and its first admin user.
```bash
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"name": "Dr. Müller",
"email": "mueller@kanzlei.de",
"password": "securepassword123",
"tenantName": "Kanzlei Müller"
}'
```
**Response (201):**
```json
{
"user": {
"id": "uuid",
"email": "mueller@kanzlei.de",
"name": "Dr. Müller",
"role": "admin"
},
"tenant": {
"id": "uuid",
"name": "Kanzlei Müller",
"slug": "kanzlei-mueller"
}
}
```
### Sign In (Get Session Cookie)
Authentication uses NextAuth.js with the Credentials provider. Sign in to get a session cookie:
```bash
curl -X POST http://localhost:3000/api/auth/callback/credentials \
-H "Content-Type: application/json" \
-d '{"email": "mueller@kanzlei.de", "password": "securepassword123"}' \
-c cookies.txt
```
Use `-b cookies.txt` on subsequent requests to send the session cookie.
### Session Info
```bash
curl http://localhost:3000/api/auth/session -b cookies.txt
```
Returns the current user session including `tenantId`, `userId`, `role`, `email`, and `name`.
> **Note:** Sessions expire after 8 hours. All protected endpoints require a valid session cookie. Tenant isolation is enforced at the database level via Row-Level Security.
---
## 2. Cases (Fälle)
### Create a Case
```bash
curl -X POST http://localhost:3000/api/cases \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"caseNumber": "2024-BSchG-001",
"title": "Nichtverlängerung Solist",
"clientName": "Max Mustermann",
"opposingParty": "Staatstheater Berlin",
"venue": "Bühnenschiedsgericht Berlin",
"status": "active",
"domains": ["nv_buehne", "arbeitsrecht"]
}'
```
**Response (201):**
```json
{
"id": "uuid",
"caseNumber": "2024-BSchG-001",
"title": "Nichtverlängerung Solist",
"clientName": "Max Mustermann",
"status": "active",
"createdAt": "2024-01-15T10:00:00Z"
}
```
### List Cases
```bash
# All cases
curl http://localhost:3000/api/cases -b cookies.txt
# Search + filter
curl "http://localhost:3000/api/cases?q=Mustermann&status=active&limit=10&offset=0" \
-b cookies.txt
```
### Get Case Details
```bash
curl http://localhost:3000/api/cases/{caseId} -b cookies.txt
```
Returns the case along with related analyses and proceedings.
### Update / Delete a Case
```bash
# Update
curl -X PATCH http://localhost:3000/api/cases/{caseId} \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"status": "closed"}'
# Delete
curl -X DELETE http://localhost:3000/api/cases/{caseId} -b cookies.txt
```
---
## 3. Document Upload (Text & Office Sources)
StageAI supports **PDF** and **DOCX** files (max 10 MB). Text is extracted automatically after upload.
### Upload a Generic Document
```bash
curl -X POST http://localhost:3000/api/documents \
-b cookies.txt \
-F "file=@/path/to/urteil.pdf" \
-F "category=entscheidung" \
-F "sourceScope=global"
```
**Parameters:**
| Field | Required | Values |
|-------|----------|--------|
| `file` | Yes | PDF or DOCX file |
| `category` | Yes | `entscheidung`, `norm`, `falldokument`, `sonstiges` |
| `sourceScope` | No | `case` (private to a case) or `global` |
| `caseId` | No | Link to a specific case |
| `decisionId` | No | Link to a specific decision |
| `normInstrumentId` | No | Link to a norm instrument |
**Response (201):**
```json
{
"id": "uuid",
"filename": "urteil.pdf",
"mimeType": "application/pdf",
"category": "entscheidung",
"status": "uploaded",
"createdAt": "2024-01-15T10:30:00Z"
}
```
After upload, text extraction runs asynchronously. Status progresses: `uploaded` -> `extracting` -> `extracted` (or `failed`).
### List Documents
```bash
# All documents
curl "http://localhost:3000/api/documents" -b cookies.txt
# Filter by category and scope
curl "http://localhost:3000/api/documents?category=entscheidung&sourceScope=global&limit=20&offset=0" \
-b cookies.txt
```
---
## 4. Contract Analysis (Vertragsanalyse)
Upload employment contracts (NV Bühne) for AI-powered clause analysis.
### Upload a Contract
```bash
curl -X POST http://localhost:3000/api/contracts \
-b cookies.txt \
-F "file=@/path/to/arbeitsvertrag.pdf" \
-F "caseId=uuid-of-case"
```
**Response (201):**
```json
{
"id": "uuid",
"filename": "arbeitsvertrag.pdf",
"mimeType": "application/pdf",
"status": "uploaded",
"caseId": "uuid-of-case"
}
```
### Trigger Clause Analysis
After uploading, trigger the AI analysis:
```bash
curl -X POST http://localhost:3000/api/contracts/{contractId}/analyze \
-b cookies.txt
```
This extracts text from the document, identifies contract clauses, and compares them against NV Bühne standard clauses. The status progresses: `uploaded` -> `extracting` -> `extracted` -> `analyzing` -> `completed`.
### Get Contract with Analysis Results
```bash
curl http://localhost:3000/api/contracts/{contractId} \
-H "x-tenant-id: your-tenant-id" \
-b cookies.txt
```
**Response (200):**
```json
{
"document": {
"id": "uuid",
"filename": "arbeitsvertrag.pdf",
"status": "completed"
},
"clauses": [
{
"id": "uuid",
"category": "Vergütung",
"extractedText": "Die monatliche Gage beträgt...",
"rating": "standard",
"analysis": "Entspricht der Gagenklasse III NV Bühne.",
"riskScore": 5,
"deviations": []
},
{
"id": "uuid",
"category": "Nichtverlängerung",
"extractedText": "Der Vertrag kann mit einer Frist von...",
"rating": "kritisch",
"analysis": "Die Frist weicht von § 69 NV Bühne ab.",
"riskScore": 85,
"deviations": ["Frist kürzer als tariflich vorgesehen"]
}
]
}
```
**Clause Categories:** Vertragsparteien, Vertragsdauer, Nichtverlängerung, Vergütung, Arbeitszeit, Proben, Gastspiele, Urlaub, Krankheit, Kündigung, Nebentätigkeit, Geheimhaltung, Sonstiges
**Ratings:** `standard` (conforms to NV Bühne), `abweichend` (deviates), `kritisch` (critical deviation), `unbekannt` (unclassifiable)
### List Contracts
```bash
curl "http://localhost:3000/api/contracts?limit=20&offset=0" -b cookies.txt
```
---
## 5. Legal Norms (Normen)
### Create a Norm Instrument
```bash
curl -X POST http://localhost:3000/api/norms \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"type": "tarifvertrag",
"sourceRank": "tarifvertrag",
"abbreviation": "NV Bühne",
"fullTitle": "Normalvertrag Bühne",
"enactedAt": "2023-01-01",
"issuingBody": "GDBA / VdO / DBV"
}'
```
### Import Norm Provisions (Bulk)
```bash
curl -X POST http://localhost:3000/api/norms/import \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"instrumentId": "uuid-of-instrument",
"provisions": [
{
"paragraph": "§ 1",
"title": "Geltungsbereich",
"body": "Dieser Normalvertrag gilt für alle Bühnenmitglieder...",
"validFrom": "2023-01-01",
"domains": ["nv_buehne"]
},
{
"paragraph": "§ 69",
"title": "Nichtverlängerungsmitteilung",
"body": "Die Nichtverlängerungsmitteilung muss bis zum 31. Oktober...",
"validFrom": "2023-01-01",
"domains": ["nv_buehne", "nichtverlängerung"]
}
]
}'
```
### Query Norms (with Temporal Versioning)
Retrieve all paragraphs of an instrument valid on a specific date:
```bash
# Norms valid today (default)
curl http://localhost:3000/api/norms/{instrumentId} -b cookies.txt
# Norms valid on a specific date (Stichtag)
curl "http://localhost:3000/api/norms/{instrumentId}?date=2024-06-15" -b cookies.txt
```
**Response (200):**
```json
{
"instrument": {
"id": "uuid",
"abbreviation": "NV Bühne",
"fullTitle": "Normalvertrag Bühne",
"type": "tarifvertrag"
},
"provisions": [
{
"id": "uuid",
"paragraph": "§ 1",
"title": "Geltungsbereich",
"body": "Dieser Normalvertrag gilt für alle Bühnenmitglieder...",
"validFrom": "2023-01-01",
"validTo": null
}
]
}
```
---
## 6. Decisions (Entscheidungen)
### Create a Decision
```bash
curl -X POST http://localhost:3000/api/decisions \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"type": "schiedsspruch",
"caseReference": "BSchG Berlin 3/2024",
"decisionDate": "2024-03-15",
"court": "Bühnenschiedsgericht Berlin",
"headnote": "Zur Wirksamkeit einer Nichtverlängerungsmitteilung...",
"tenor": "Der Schiedsspruch wird aufgehoben...",
"facts": "Der Kläger ist seit 2018 als Solist...",
"reasoning": "Die Nichtverlängerungsmitteilung ist unwirksam, weil...",
"domains": ["nv_buehne", "nichtverlängerung"],
"keywords": ["Nichtverlängerung", "Solist", "Fristversäumnis"]
}'
```
### Full-Text Search for Decisions
Uses PostgreSQL full-text search with German language support:
```bash
# Search by keyword
curl "http://localhost:3000/api/decisions?q=Vergütung" -b cookies.txt
# Combined filters
curl "http://localhost:3000/api/decisions?q=Nichtverlängerung&court=Bühnenschiedsgericht&type=schiedsspruch&dateFrom=2020-01-01&dateTo=2024-12-31" \
-b cookies.txt
```
### Link Norms to a Decision
```bash
curl -X POST http://localhost:3000/api/decisions/{decisionId}/norms \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"normId": "uuid-of-norm-paragraph",
"applicationType": "angewendet",
"passage": "Rn. 15-18"
}'
```
**Application types:** `angewendet` (applied), `zitiert` (cited), `ausgelegt` (interpreted), `verworfen` (rejected)
---
## 7. AI-Powered Legal Analysis (Analysen)
### Create a Streaming Analysis
```bash
curl -X POST http://localhost:3000/api/analyses \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"mode": "gutachten",
"title": "Wirksamkeit der Nichtverlängerung",
"query": "Ist die Nichtverlängerungsmitteilung vom 15.11.2024 wirksam, wenn der Solist seit 15 Jahren am Haus beschäftigt ist?",
"normIds": ["uuid-of-§69-nv-buehne"],
"decisionIds": ["uuid-of-relevant-decision"],
"documentIds": ["uuid-of-uploaded-document"],
"stichtag": "2024-11-15",
"caseId": "uuid-of-case"
}'
```
The response streams text (the AI-generated analysis). The `X-Analysis-Id` response header contains the analysis ID for later retrieval.
**Analysis Modes:**
| Mode | Purpose | Requires |
|------|---------|----------|
| `gutachten` | Expert legal opinion | Norms |
| `entscheidung` | Decision proposal based on precedent | Decisions |
| `vergleich` | Comparative analysis | Norms or decisions |
| `risiko` | Risk assessment with probability ratings | Any sources |
### Create a Structured Analysis (JSON Output)
```bash
curl -X POST http://localhost:3000/api/analyses/structured \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"mode": "risiko",
"query": "Risikobewertung für die Kündigung eines Chormitglieds nach § 626 BGB",
"normIds": ["uuid1"],
"decisionIds": ["uuid2"]
}'
```
Returns typed JSON with structured fields depending on the mode.
### List & Retrieve Analyses
```bash
# List (metadata only, DSGVO-compliant)
curl "http://localhost:3000/api/analyses?limit=20&offset=0" -b cookies.txt
# Get single analysis with full result and sources
curl http://localhost:3000/api/analyses/{analysisId} \
-H "x-tenant-id: your-tenant-id" \
-b cookies.txt
```
---
## 8. Proceedings (Verfahren)
### Create a Proceeding
```bash
curl -X POST http://localhost:3000/api/proceedings \
-H "Content-Type: application/json" \
-d '{
"tenantId": "your-tenant-id",
"type": "bschgo_bezirk",
"caseId": "uuid-of-case",
"applicant": "Max Mustermann",
"respondent": "Staatstheater Berlin",
"subject": "Nichtverlängerung Spielzeit 2024/25"
}'
```
**Proceeding types:** `bschgo_bezirk`, `bschgo_bund`, `arbgg_erste_instanz`, `arbgg_berufung`, `arbgg_revision`
Workflow steps and initial deadlines are automatically created from templates.
### Check Deadlines
```bash
# All deadlines
curl http://localhost:3000/api/proceedings/{proceedingId}/deadlines
# Only overdue deadlines
curl "http://localhost:3000/api/proceedings/{proceedingId}/deadlines?overdue=true"
# Upcoming in next 14 days
curl "http://localhost:3000/api/proceedings/{proceedingId}/deadlines?upcoming=14"
```
### Advance Proceeding
```bash
curl -X POST http://localhost:3000/api/proceedings/{proceedingId}/advance \
-b cookies.txt
```
---
## 9. NV Bühne Utilities
These are public endpoints (no auth required) for quick calculations.
### Calculate Compensation (Gage)
```bash
# GET (simple query)
curl "http://localhost:3000/api/nv-buehne/compensation?gagenklasse=III&yearsOfService=5&spielzeit=2024/25&fachgruppe=Solo"
# POST (with tenant-specific rules)
curl -X POST http://localhost:3000/api/nv-buehne/compensation \
-H "Content-Type: application/json" \
-d '{
"gagenklasse": "III",
"yearsOfService": 5,
"spielzeit": "2024/25",
"fachgruppe": "Solo",
"tenantId": "uuid",
"fachgruppeId": "uuid"
}'
```
### Check Non-Renewal Deadline (Nichtverlängerungsfrist)
```bash
curl "http://localhost:3000/api/nv-buehne/deadline-check?yearsOfService=15&isOver55=false&spielzeit=2024/25&fachgruppe=Solo&referenceDate=2024-10-31"
```
### Get Current Spielzeit (Season)
```bash
curl "http://localhost:3000/api/nv-buehne/spielzeit"
# or for a specific date:
curl "http://localhost:3000/api/nv-buehne/spielzeit?date=2024-09-01"
```
---
## 10. Settings
### Manage AI Provider API Keys
```bash
# List keys (returns hints only, never full keys)
curl http://localhost:3000/api/settings/api-keys -b cookies.txt
# Add an API key
curl -X POST http://localhost:3000/api/settings/api-keys \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"provider": "anthropic",
"apiKey": "sk-ant-...",
"label": "Production Key"
}'
```
Keys are encrypted at rest with AES-256-GCM.
---
## Pagination
All list endpoints support pagination:
```
?limit=20&offset=0
```
**Response format:**
```json
{
"data": [...],
"pagination": {
"total": 100,
"limit": 20,
"offset": 0,
"hasMore": true
}
}
```
## Error Handling
All errors return JSON:
```json
{
"error": "Human-readable error message"
}
```
| Status | Meaning |
|--------|---------|
| 400 | Validation error (missing/invalid fields) |
| 401 | Not authenticated (missing or expired session) |
| 403 | Permission denied (role lacks required permission) |
| 404 | Resource not found |
| 409 | Conflict (duplicate entry) |
| 413 | File too large (max 10 MB) |
## User Roles & Permissions
| Role | Permissions |
|------|------------|
| `admin` | Full access, manage settings & users |
| `attorney` | Create/edit cases, analyses, norms, decisions |
| `paralegal` | Read access + limited write |
| `viewer` | Read-only |
## Typical Workflow
1. **Register** a tenant and admin user
2. **Configure** AI provider API key in Settings
3. **Create norms** (import NV Bühne provisions)
4. **Upload decisions** (create + link applicable norms)
5. **Create a case** for a client
6. **Upload documents** (court rulings, contracts as PDF/DOCX)
7. **Upload & analyze a contract** to identify clause deviations
8. **Run AI analyses** referencing norms, decisions, and documents
9. **Create proceedings** to track deadlines and workflow steps

View File

@@ -0,0 +1,2 @@
-- Add 'openrouter' to the api_key_provider enum
ALTER TYPE "api_key_provider" ADD VALUE IF NOT EXISTS 'openrouter';

View File

@@ -0,0 +1,60 @@
-- 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 — handled by migration 0006
-- 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

@@ -0,0 +1,58 @@
-- Fix: properly seed system skills with full system prompts
-- The original migration (0005) had a broken CROSS JOIN that failed to insert skills.
-- This migration uses individual INSERTs per tenant to avoid the issue.
-- Base instructions shared by all skills
-- (embedded directly in each prompt below)
DO $$
DECLARE
tenant_record RECORD;
base_prompt TEXT := 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.';
BEGIN
FOR tenant_record IN SELECT id FROM tenants LOOP
-- Gutachten
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'gutachten',
'Rechtsgutachten',
'Strukturiertes Gutachten nach klassischer Methodik (Obersatz → Definition → Subsumtion → Ergebnis)',
base_prompt || E'\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.',
'analysis', true, true, true, 0, true
) ON CONFLICT DO NOTHING;
-- Entscheidungsvorhersage
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'entscheidung',
'Entscheidungsvorhersage',
'Prognose der wahrscheinlichen gerichtlichen/schiedsgerichtlichen Entscheidung',
base_prompt || E'\n\nModus: ENTSCHEIDUNG (Entscheidungsvorhersage)\n\nAnalysiere den Sachverhalt und prognostiziere die wahrscheinliche Entscheidung:\n\n1. **Sachverhalt** — Zusammenfassung der relevanten Tatsachen\n2. **Einschlägige Normen** — Anwendbare Vorschriften mit Quellenrang\n3. **Bisherige Rechtsprechung** — Relevante Präzedenzfälle und deren Entscheidungslinien\n4. **Prognose** — Wahrscheinlichste Entscheidung mit Begründung\n5. **Risikofaktoren** — Faktoren, die das Ergebnis beeinflussen könnten\n6. **Empfehlung** — Handlungsempfehlung für den Mandanten\n\nStütze die Prognose auf konkrete Entscheidungen und deren Leitsätze.',
'analysis', true, true, true, 1, true
) ON CONFLICT DO NOTHING;
-- Vergleichsvorschlag
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'vergleich',
'Vergleichsvorschlag',
'Erarbeitung eines Vergleichsvorschlags mit Bewertung der Erfolgsaussichten',
base_prompt || E'\n\nModus: VERGLEICH (Vergleichsvorschlag)\n\nErarbeite einen Vergleichsvorschlag:\n\n1. **Ausgangslage** — Positionen beider Parteien\n2. **Rechtslage** — Einschlägige Normen und deren Wertung\n3. **Erfolgsaussichten** — Prozentuale Einschätzung für jede Partei (mit Begründung)\n4. **Vergleichsvorschlag** — Konkreter Kompromissvorschlag\n5. **Vor-/Nachteile** — Bewertung des Vorschlags für beide Seiten\n6. **Umsetzung** — Praktische Schritte zur Umsetzung\n\nBeziehe die wirtschaftlichen Interessen beider Seiten ein (Kosten, Zeit, Reputation).',
'analysis', true, false, true, 2, true
) ON CONFLICT DO NOTHING;
-- Risikoanalyse
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'risiko',
'Risikoanalyse',
'Umfassende Risikoanalyse mit Eintrittswahrscheinlichkeiten und Minderungsstrategien',
base_prompt || E'\n\nModus: RISIKO (Risikoanalyse)\n\nErstelle eine umfassende Risikoanalyse:\n\n1. **Sachverhalt** — Zusammenfassung der Situation\n2. **Identifizierte Risiken** — Auflistung aller rechtlichen Risiken, jeweils mit:\n - Beschreibung des Risikos\n - Eintrittswahrscheinlichkeit (hoch/mittel/gering)\n - Schadensausmaß (hoch/mittel/gering)\n - Einschlägige Norm(en) mit Quellenrang\n3. **Risikomatrix** — Tabellarische Übersicht (Wahrscheinlichkeit × Auswirkung)\n4. **Minderungsstrategien** — Konkrete Maßnahmen je Risiko\n5. **Priorisierung** — Dringlichste Handlungsempfehlungen\n\nBewerte jedes Risiko anhand der aktuellen Rechtslage und Rechtsprechung.',
'analysis', true, true, true, 3, true
) ON CONFLICT DO NOTHING;
END LOOP;
END $$;

View File

@@ -36,6 +36,27 @@
"when": 1775856000000,
"tag": "0004_document_source_scope",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1776364800000,
"tag": "0005_skills_and_analysis_refactor",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1776451200000,
"tag": "0006_seed_system_skills_fix",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1776537600000,
"tag": "0004_add_openrouter_provider",
"breakpoints": true
}
]
}

View File

@@ -14,6 +14,7 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["pdfjs-dist", "drizzle-orm", "pg"],
env: {
NEXT_PUBLIC_BUILD_HASH: commitHash,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || "0.9.0.1",
},
};

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

@@ -91,6 +91,7 @@ export default function AISettingsForm() {
>
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai">OpenAI (GPT)</option>
<option value="openrouter">OpenRouter</option>
<option value="ollama">Ollama (Lokal)</option>
</select>
</div>
@@ -103,7 +104,7 @@ export default function AISettingsForm() {
type="text"
value={settings.model}
onChange={(e) => setSettings({ ...settings, model: e.target.value })}
placeholder={settings.provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o'}
placeholder={settings.provider === 'anthropic' ? 'claude-sonnet-4-20250514' : settings.provider === 'openrouter' ? 'anthropic/claude-sonnet-4' : 'gpt-4o'}
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
/>
</div>
@@ -111,7 +112,7 @@ export default function AISettingsForm() {
const activeKey = apiKeys.find(
(k) => k.provider === settings.provider && k.isActive,
);
const providerLabel = settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI';
const providerLabel = settings.provider === 'anthropic' ? 'Anthropic' : settings.provider === 'openrouter' ? 'OpenRouter' : 'OpenAI';
async function handleSaveApiKey() {
if (!apiKeyInput || apiKeyInput.length < 8) {

View File

@@ -14,6 +14,7 @@ interface ApiKeyEntry {
const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
openrouter: 'OpenRouter',
ollama: 'Ollama',
};
@@ -172,6 +173,7 @@ export default function ApiKeySettings() {
>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
</select>
</div>
<div>

View File

@@ -23,7 +23,7 @@ export default async function DashboardLayout({
{children}
</main>
<footer className="px-8 py-3 text-xs text-gray-400 border-t border-gray-200">
Build {process.env.NEXT_PUBLIC_BUILD_HASH || 'dev'}
v{process.env.NEXT_PUBLIC_APP_VERSION || '0.9.0.1'} ({process.env.NEXT_PUBLIC_BUILD_HASH || 'dev'})
</footer>
</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

@@ -3,11 +3,9 @@
import { type NextRequest } from 'next/server';
import { runStructuredAnalysis } from '@/lib/ai/structured-analysis';
import { AnalyseMode } from '@/types';
import { resolveAnalysisMode } from '@/lib/ai/modes';
import { requirePermission } from '@/lib/auth/rbac';
const VALID_MODES = new Set(Object.values(AnalyseMode));
export async function POST(request: NextRequest) {
const auth = await requirePermission('analyses:create');
if ('response' in auth) return auth.response;
@@ -25,9 +23,17 @@ export async function POST(request: NextRequest) {
additionalContext,
} = body;
if (!mode || !VALID_MODES.has(mode)) {
if (!mode || typeof mode !== 'string') {
return Response.json(
{ error: `Invalid mode. Must be one of: ${[...VALID_MODES].join(', ')}` },
{ error: 'mode is required' },
{ status: 400 },
);
}
const modeConfig = await resolveAnalysisMode(mode, ctx.tenantId);
if (!modeConfig) {
return Response.json(
{ error: `Unbekannter Analysemodus: ${mode}` },
{ status: 400 },
);
}

View File

@@ -7,7 +7,7 @@ import { eq } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
import type { AIProvider } from '@/lib/ai/providers';
const VALID_PROVIDERS = new Set<AIProvider>(['anthropic', 'openai', 'ollama']);
const VALID_PROVIDERS = new Set<AIProvider>(['anthropic', 'openai', 'openrouter', 'ollama']);
export async function GET() {
const auth = await requirePermission('settings:manage');

View File

@@ -50,7 +50,14 @@ export async function PATCH(
auditDetails.isActive = isActive;
}
if (apiKey && typeof apiKey === 'string' && apiKey.length >= 8) {
updates.encryptedKey = encrypt(apiKey);
try {
updates.encryptedKey = encrypt(apiKey);
} catch {
return Response.json(
{ error: 'Serverkonfigurationsfehler: Verschlüsselung nicht verfügbar. Bitte ENCRYPTION_KEY prüfen.' },
{ status: 500 },
);
}
updates.keyHint = keyHint(apiKey);
auditDetails.keyRotated = true;
}

View File

@@ -3,13 +3,13 @@
import { db } from '@/lib/db';
import { tenantApiKeys } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { eq, and, isNull } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
import { encrypt, keyHint } from '@/lib/crypto';
import { logAuditEvent } from '@/lib/auth/audit';
import type { AIProvider } from '@/lib/ai/providers';
const VALID_PROVIDERS = new Set<AIProvider>(['anthropic', 'openai', 'ollama']);
const VALID_PROVIDERS = new Set<AIProvider>(['anthropic', 'openai', 'openrouter', 'ollama']);
export async function GET() {
const auth = await requirePermission('settings:manage');
@@ -53,15 +53,15 @@ export async function POST(request: Request) {
return Response.json({ error: 'API-Schlüssel ist erforderlich (mindestens 8 Zeichen).' }, { status: 400 });
}
// Check for duplicate provider+label
// Check for duplicate provider+label (null labels must use IS NULL, not equality)
const existing = await db
.select({ id: tenantApiKeys.id })
.from(tenantApiKeys)
.where(
and(
eq(tenantApiKeys.tenantId, ctx.tenantId),
eq(tenantApiKeys.provider, provider as 'anthropic' | 'openai' | 'ollama'),
label ? eq(tenantApiKeys.label, label) : undefined,
eq(tenantApiKeys.provider, provider as AIProvider),
label ? eq(tenantApiKeys.label, label) : isNull(tenantApiKeys.label),
),
)
.limit(1);
@@ -73,27 +73,41 @@ export async function POST(request: Request) {
);
}
const encryptedKey = encrypt(apiKey);
let encryptedKey: string;
try {
encryptedKey = encrypt(apiKey);
} catch {
return Response.json(
{ error: 'Serverkonfigurationsfehler: Verschlüsselung nicht verfügbar. Bitte ENCRYPTION_KEY prüfen.' },
{ status: 500 },
);
}
const hint = keyHint(apiKey);
const [created] = await db
.insert(tenantApiKeys)
.values({
tenantId: ctx.tenantId,
provider: provider as 'anthropic' | 'openai' | 'ollama',
encryptedKey,
keyHint: hint,
label: label || null,
createdByUserId: ctx.userId,
})
.returning({
id: tenantApiKeys.id,
provider: tenantApiKeys.provider,
keyHint: tenantApiKeys.keyHint,
label: tenantApiKeys.label,
isActive: tenantApiKeys.isActive,
createdAt: tenantApiKeys.createdAt,
});
let created: { id: string; provider: string; keyHint: string; label: string | null; isActive: boolean; createdAt: Date };
try {
[created] = await db
.insert(tenantApiKeys)
.values({
tenantId: ctx.tenantId,
provider: provider as AIProvider,
encryptedKey,
keyHint: hint,
label: label || null,
createdByUserId: ctx.userId,
})
.returning({
id: tenantApiKeys.id,
provider: tenantApiKeys.provider,
keyHint: tenantApiKeys.keyHint,
label: tenantApiKeys.label,
isActive: tenantApiKeys.isActive,
createdAt: tenantApiKeys.createdAt,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return Response.json({ error: `Datenbankfehler beim Speichern: ${msg}` }, { status: 500 });
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
await logAuditEvent(ctx, 'create', 'tenant_api_key', created.id, { provider, label: label || null }, ip);

View File

@@ -2,17 +2,19 @@
// PATCH /api/settings/skills/[id] — Update a skill
// DELETE /api/settings/skills/[id] — Soft-delete (set isActive = false)
import { db } from '@/lib/db';
import { withTenantDb } 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);
const [skill] = await withTenantDb(tenantId, async (tdb) =>
tdb
.select()
.from(skills)
.where(eq(skills.id, skillId))
.limit(1),
);
return skill ?? null;
}
@@ -70,11 +72,13 @@ export async function PATCH(
{ 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);
const existing = await withTenantDb(ctx.tenantId, async (tdb) =>
tdb
.select({ id: skills.id })
.from(skills)
.where(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.' },
@@ -101,11 +105,13 @@ export async function PATCH(
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();
const [updated] = await withTenantDb(ctx.tenantId, async (tdb) =>
tdb
.update(skills)
.set(updates)
.where(eq(skills.id, id))
.returning(),
);
return Response.json(updated);
}
@@ -132,11 +138,13 @@ export async function DELETE(
}
// Soft-delete: set isActive = false
const [updated] = await db
.update(skills)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(skills.id, id))
.returning();
const [updated] = await withTenantDb(ctx.tenantId, async (tdb) =>
tdb
.update(skills)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(skills.id, id))
.returning(),
);
return Response.json(updated);
}

View File

@@ -1,8 +1,8 @@
// PATCH /api/settings/skills/reorder — Update sort_order for drag-and-drop reordering
import { db } from '@/lib/db';
import { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function PATCH(request: Request) {
@@ -19,14 +19,14 @@ export async function PATCH(request: Request) {
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)));
}
await withTenantDb(ctx.tenantId, async (tdb) => {
for (const item of order) {
await tdb
.update(skills)
.set({ sortOrder: item.sortOrder, updatedAt: new Date() })
.where(eq(skills.id, item.id));
}
});
return Response.json({ ok: true });
}

View File

@@ -1,7 +1,7 @@
// 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 { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and, asc } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
@@ -10,11 +10,12 @@ 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));
const rows = await withTenantDb(auth.ctx.tenantId, async (tdb) =>
tdb
.select()
.from(skills)
.orderBy(asc(skills.sortOrder), asc(skills.createdAt)),
);
return Response.json(rows);
}
@@ -58,44 +59,51 @@ export async function POST(request: Request) {
);
}
// 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);
const created = await withTenantDb(ctx.tenantId, async (tdb) => {
// Check slug uniqueness within tenant
const existing = await tdb
.select({ id: skills.id })
.from(skills)
.where(eq(skills.slug, slug))
.limit(1);
if (existing.length > 0) {
if (existing.length > 0) {
return null; // slug conflict
}
// Get max sort order for positioning
const allSkills = await tdb
.select({ sortOrder: skills.sortOrder })
.from(skills);
const maxOrder = allSkills.reduce((max, s) => Math.max(max, s.sortOrder), -1);
const [row] = await tdb
.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 row;
});
if (!created) {
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,33 @@
// GET /api/skills — List active skills for the current tenant (read-only)
// Used by the analyse form to populate the skill selector.
import { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, 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 withTenantDb(auth.ctx.tenantId, async (tdb) =>
tdb
.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(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 { 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,91 @@ 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> {
const skillFields = {
id: skills.id,
slug: skills.slug,
systemPrompt: skills.systemPrompt,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
};
// Try by skillId first
if (input.skillId) {
const [skill] = await withTenantDb(tenantId, async (tdb) =>
tdb
.select(skillFields)
.from(skills)
.where(and(eq(skills.id, input.skillId!), 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 withTenantDb(tenantId, async (tdb) =>
tdb
.select(skillFields)
.from(skills)
.where(and(eq(skills.slug, input.skillSlug!), 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 withTenantDb(tenantId, async (tdb) =>
tdb
.select(skillFields)
.from(skills)
.where(and(eq(skills.slug, input.mode!), 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 +185,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 +193,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 +217,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 +239,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 +249,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 +268,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 +326,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 +349,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 +357,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 +372,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

@@ -2,9 +2,12 @@
// Each mode defines a specific legal analysis workflow
import { AnalyseMode } from '@/types';
import { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
export interface AnalysisModeConfig {
mode: AnalyseMode;
mode: string;
systemPromptKey: string;
requiresNorms: boolean;
requiresDecisions: boolean;
@@ -12,6 +15,10 @@ export interface AnalysisModeConfig {
label: string;
/** Short description */
description: string;
/** Custom system prompt (for skill-based modes) */
customSystemPrompt?: string;
/** Skill ID if resolved from skills table */
skillId?: string;
}
export const ANALYSIS_MODES: Record<AnalyseMode, AnalysisModeConfig> = {
@@ -48,3 +55,43 @@ export const ANALYSIS_MODES: Record<AnalyseMode, AnalysisModeConfig> = {
description: 'Umfassende Risikoanalyse mit Eintrittswahrscheinlichkeiten und Minderungsstrategien',
},
};
const STANDARD_MODES = new Set<string>(Object.values(AnalyseMode));
/**
* Resolve an analysis mode config — checks hardcoded standard modes first,
* then falls back to the skills table for custom tenant-defined modes.
* Returns null if the mode/slug does not exist.
*/
export async function resolveAnalysisMode(
mode: string,
tenantId: string,
): Promise<AnalysisModeConfig | null> {
// Standard hardcoded mode
if (STANDARD_MODES.has(mode)) {
return ANALYSIS_MODES[mode as AnalyseMode];
}
// Look up custom skill by slug
const skill = await withTenantDb(tenantId, async (tdb) => {
const [row] = await tdb
.select()
.from(skills)
.where(and(eq(skills.slug, mode), eq(skills.isActive, true)))
.limit(1);
return row ?? null;
});
if (!skill) return null;
return {
mode: skill.slug,
systemPromptKey: skill.slug,
requiresNorms: skill.requiresNorms ?? false,
requiresDecisions: skill.requiresDecisions ?? false,
label: skill.name,
description: skill.description ?? '',
customSystemPrompt: skill.systemPrompt,
skillId: skill.id,
};
}

View File

@@ -1,5 +1,5 @@
// AI Provider abstraction via Vercel AI SDK v6
// Supports: Anthropic, OpenAI, Ollama — selected via AI_PROVIDER env var or tenant settings
// Supports: Anthropic, OpenAI, OpenRouter, Ollama — selected via AI_PROVIDER env var or tenant settings
// Per-tenant API keys are loaded from tenant_api_keys (AES-256-GCM encrypted)
import { createAnthropic, anthropic } from '@ai-sdk/anthropic';
@@ -10,17 +10,19 @@ import { tenants, tenantApiKeys } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';
import { decrypt } from '@/lib/crypto';
export type AIProvider = 'anthropic' | 'openai' | 'ollama';
export type AIProvider = 'anthropic' | 'openai' | 'openrouter' | 'ollama';
const DEFAULT_MODELS: Record<AIProvider, string> = {
anthropic: 'claude-sonnet-4-20250514',
openai: 'gpt-4o',
openrouter: 'anthropic/claude-sonnet-4',
ollama: 'llama3',
};
export function getProvider(): AIProvider {
const p = process.env.AI_PROVIDER;
if (p === 'openai') return 'openai';
if (p === 'openrouter') return 'openrouter';
if (p === 'ollama') return 'ollama';
return 'anthropic';
}
@@ -45,6 +47,13 @@ function buildModel(
}
return openai(modelId);
}
case 'openrouter': {
const openrouter = createOpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: options?.apiKey ?? process.env.OPENROUTER_API_KEY ?? '',
});
return openrouter(modelId);
}
case 'ollama': {
const baseURL = options?.ollamaUrl ?? process.env.OLLAMA_URL ?? 'http://localhost:11434';
const ollama = createOpenAI({
@@ -97,7 +106,7 @@ export async function getModelForTenant(tenantId: string): Promise<{ model: Lang
.limit(1);
const s = tenant?.settings as Record<string, string> | undefined;
const provider: AIProvider = (['anthropic', 'openai', 'ollama'].includes(s?.aiProvider ?? '') ? s!.aiProvider : getProvider()) as AIProvider;
const provider: AIProvider = (['anthropic', 'openai', 'openrouter', 'ollama'].includes(s?.aiProvider ?? '') ? s!.aiProvider : getProvider()) as AIProvider;
let modelId: string;
if (provider === 'ollama') {

View File

@@ -4,9 +4,8 @@
import { generateText } from 'ai';
import { getModelForTenant } from './providers';
import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts';
import { ANALYSIS_MODES } from './modes';
import { ANALYSIS_MODES, resolveAnalysisMode } from './modes';
import { STRUCTURED_OUTPUT_INSTRUCTION, type StructuredAnalysisOutput } from './structured-output';
import { AnalyseMode } from '@/types';
import { db } from '@/lib/db';
import { norms, normInstruments, decisions, analyses } from '@/lib/db/schema';
import { eq, and, lte, or, isNull, gte, inArray } from 'drizzle-orm';
@@ -15,7 +14,7 @@ interface StructuredAnalysisInput {
tenantId: string;
userId: string;
caseId?: string;
mode: AnalyseMode;
mode: string;
title: string;
query: string;
normIds?: string[];
@@ -108,8 +107,10 @@ export async function runStructuredAnalysis(
result: StructuredAnalysisOutput;
sources: { normIds: string[]; decisionIds: string[] };
}> {
const modeConfig = ANALYSIS_MODES[input.mode];
const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
const modeConfig = await resolveAnalysisMode(input.mode, input.tenantId);
if (!modeConfig) {
throw new Error(`Unbekannter Analysemodus: ${input.mode}`);
}
const [normContext, decisionContext] = await Promise.all([
modeConfig.requiresNorms
@@ -132,8 +133,10 @@ export async function runStructuredAnalysis(
const userMessage = messageParts.join('\n\n---\n\n');
// Add structured output instruction to system prompt
const systemPrompt = SYSTEM_PROMPTS[systemPromptKey] + STRUCTURED_OUTPUT_INSTRUCTION;
// Use custom system prompt for skill-based modes, standard prompts for built-in modes
const basePrompt = modeConfig.customSystemPrompt
?? SYSTEM_PROMPTS[modeConfig.systemPromptKey as AnalysisModeKey];
const systemPrompt = basePrompt + STRUCTURED_OUTPUT_INSTRUCTION;
const { model, provider, modelId } = await getModelForTenant(input.tenantId);
@@ -144,7 +147,8 @@ export async function runStructuredAnalysis(
tenantId: input.tenantId,
userId: input.userId,
caseId: input.caseId ?? null,
mode: input.mode,
mode: input.mode as "vergleich" | "gutachten" | "entscheidung" | "risiko",
skillId: modeConfig.skillId ?? null,
status: 'in_progress',
title: input.title,
query: input.query,
@@ -172,7 +176,7 @@ export async function runStructuredAnalysis(
const jsonMatch = result.text.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error('No JSON object found');
structured = JSON.parse(jsonMatch[0]);
structured.mode = input.mode;
(structured as any).mode = input.mode;
} catch {
// Fallback: store raw text, mark as non-structured
await db

View File

@@ -31,6 +31,12 @@ const PERMISSIONS: Record<string, readonly Role[]> = {
'decisions:write': ['admin', 'attorney'],
'decisions:read': ['admin', 'attorney', 'paralegal', 'viewer'],
// Skills
'skills:read': ['admin', 'attorney', 'paralegal', 'viewer'],
'skills:create': ['admin'],
'skills:edit': ['admin'],
'skills:delete': ['admin'],
// Settings
'settings:manage': ['admin'],

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)
// ============================================================
@@ -977,6 +1019,7 @@ export const documents = pgTable(
export const apiKeyProviderEnum = pgEnum("api_key_provider", [
"anthropic",
"openai",
"openrouter",
"ollama",
]);
@@ -1154,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] }),

4
version.json Normal file
View File

@@ -0,0 +1,4 @@
{
"version": "0.9.0",
"build": 11
}