20 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
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
27 changed files with 1013 additions and 232 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

@@ -31,39 +31,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS "skills_tenant_slug_idx" ON "skills" ("tenant_
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 3: Seed system skills — handled by migration 0006
-- Step 4: Add skill_id and structured_result columns to analyses
ALTER TABLE "analyses"

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

@@ -43,6 +43,20 @@
"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

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

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

@@ -1,36 +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 { db } from '@/lib/db';
import { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, and, asc } from 'drizzle-orm';
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 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));
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

@@ -7,7 +7,7 @@ import { buildContextBlock } from './prompts';
import { SYSTEM_PROMPTS, type AnalysisModeKey } from './prompts';
import { ANALYSIS_MODES } from './modes';
import { AnalyseMode } from '@/types';
import { db } from '@/lib/db';
import { db, withTenantDb } 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';
@@ -51,27 +51,25 @@ 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 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);
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}`);
@@ -79,25 +77,13 @@ async function resolveSkill(
// 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);
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}`);
@@ -105,25 +91,13 @@ async function resolveSkill(
// 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);
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;

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

@@ -1019,6 +1019,7 @@ export const documents = pgTable(
export const apiKeyProviderEnum = pgEnum("api_key_provider", [
"anthropic",
"openai",
"openrouter",
"ollama",
]);

4
version.json Normal file
View File

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