Compare commits
20 Commits
feat/aiia-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4885805f1 | ||
|
|
b8f4427f90 | ||
|
|
3366f84137 | ||
|
|
a49e191999 | ||
|
|
2594d78613 | ||
|
|
2feca358cc | ||
|
|
41fcc5be42 | ||
|
|
da2a81f081 | ||
|
|
622d0bee34 | ||
|
|
f4ad3da3ec | ||
|
|
2caf7d1229 | ||
|
|
f73a97e696 | ||
|
|
f0a7d6837b | ||
|
|
b665f530e0 | ||
| a5e58aa142 | |||
|
|
abc6a259f0 | ||
| 445491654b | |||
|
|
817a0da714 | ||
|
|
86f4ef9012 | ||
|
|
27132aa383 |
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
633
docs/API_GUIDE.md
Normal 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
|
||||
2
drizzle/0004_add_openrouter_provider.sql
Normal file
2
drizzle/0004_add_openrouter_provider.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add 'openrouter' to the api_key_provider enum
|
||||
ALTER TYPE "api_key_provider" ADD VALUE IF NOT EXISTS 'openrouter';
|
||||
@@ -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"
|
||||
|
||||
58
drizzle/0006_seed_system_skills_fix.sql
Normal file
58
drizzle/0006_seed_system_skills_fix.sql
Normal 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 $$;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
|
||||
@@ -1019,6 +1019,7 @@ export const documents = pgTable(
|
||||
export const apiKeyProviderEnum = pgEnum("api_key_provider", [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"ollama",
|
||||
]);
|
||||
|
||||
|
||||
4
version.json
Normal file
4
version.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "0.9.0",
|
||||
"build": 11
|
||||
}
|
||||
Reference in New Issue
Block a user