Compare commits
37 Commits
feat/aiia-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4885805f1 | ||
|
|
b8f4427f90 | ||
|
|
3366f84137 | ||
|
|
a49e191999 | ||
|
|
2594d78613 | ||
|
|
2feca358cc | ||
|
|
41fcc5be42 | ||
|
|
da2a81f081 | ||
|
|
622d0bee34 | ||
|
|
f4ad3da3ec | ||
|
|
2caf7d1229 | ||
|
|
f73a97e696 | ||
|
|
f0a7d6837b | ||
|
|
b665f530e0 | ||
| a5e58aa142 | |||
|
|
abc6a259f0 | ||
| 445491654b | |||
|
|
aec4a39d10 | ||
|
|
e521b8e338 | ||
|
|
d15476f5e9 | ||
|
|
817a0da714 | ||
|
|
86f4ef9012 | ||
|
|
27132aa383 | ||
|
|
e60b27cbd4 | ||
|
|
d10a2453d2 | ||
|
|
8dc71448d7 | ||
|
|
af219c38d8 | ||
|
|
b4ad27ad02 | ||
| e5d9d3cef3 | |||
|
|
79191c3810 | ||
|
|
17c1b6587a | ||
|
|
4e74e4b5c9 | ||
|
|
3e0efd10e9 | ||
|
|
1e431145dd | ||
| a89bf8380d | |||
|
|
94b89cb1e2 | ||
|
|
d7bdeb7da2 |
@@ -9,16 +9,29 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
port: ${{ secrets.VPS_PORT || 22 }}
|
||||
script: |
|
||||
cd ${{ secrets.VPS_PROJECT_PATH || '/opt/legalai' }}
|
||||
git pull origin master
|
||||
docker compose build app
|
||||
docker compose up -d app
|
||||
echo "Deployed commit: $(git rev-parse --short HEAD)"
|
||||
- name: Pull latest code
|
||||
run: |
|
||||
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 --build-arg COMMIT_HASH=$COMMIT_HASH --build-arg APP_VERSION=${{ env.APP_VERSION }} app
|
||||
docker compose up -d app
|
||||
echo "Deployed version: ${{ env.APP_VERSION }} (commit: $COMMIT_HASH)"
|
||||
|
||||
@@ -7,6 +7,10 @@ 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
|
||||
|
||||
@@ -38,8 +38,9 @@ git reset --hard "origin/$BRANCH"
|
||||
echo "Updated to: $(git log --oneline -1)"
|
||||
|
||||
# Rebuild containers
|
||||
export COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
echo ""
|
||||
echo "Rebuilding containers..."
|
||||
echo "Rebuilding containers (commit: $COMMIT_HASH)..."
|
||||
docker compose -p "$PROJECT_NAME" build --no-cache app
|
||||
|
||||
if [[ "$BUILD_ONLY" == true ]]; then
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
COMMIT_HASH: ${COMMIT_HASH:-dev}
|
||||
APP_VERSION: ${APP_VERSION:-0.9.0.1}
|
||||
ports:
|
||||
- "3002:3000"
|
||||
environment:
|
||||
@@ -13,6 +17,8 @@ services:
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -48,3 +54,4 @@ services:
|
||||
volumes:
|
||||
postgres_data:
|
||||
meilisearch_data:
|
||||
uploads_data:
|
||||
|
||||
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';
|
||||
17
drizzle/0004_document_source_scope.sql
Normal file
17
drizzle/0004_document_source_scope.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Add source scope to documents table (AIIA-66)
|
||||
-- Documents are either case-specific or globally available
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "document_source_scope" AS ENUM ('case', 'global');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE "documents"
|
||||
ADD COLUMN IF NOT EXISTS "source_scope" "document_source_scope" NOT NULL DEFAULT 'case';
|
||||
|
||||
-- Auto-set global scope for norm documents (they are always globally available)
|
||||
UPDATE "documents" SET "source_scope" = 'global' WHERE "category" = 'norm';
|
||||
|
||||
-- Index for efficient source scope queries
|
||||
CREATE INDEX IF NOT EXISTS "documents_source_scope_idx" ON "documents" ("source_scope");
|
||||
60
drizzle/0005_skills_and_analysis_refactor.sql
Normal file
60
drizzle/0005_skills_and_analysis_refactor.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Skills table and analysis refactor migration (AIIA-96)
|
||||
-- Creates tenant-scoped skills table, seeds system skills, and updates analyses table
|
||||
|
||||
-- Step 1: Create skill_output_type enum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "skill_output_type" AS ENUM ('analysis', 'structured_data');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Step 2: Create skills table
|
||||
CREATE TABLE IF NOT EXISTS "skills" (
|
||||
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"tenant_id" UUID NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
|
||||
"slug" VARCHAR(100) NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"description" TEXT,
|
||||
"system_prompt" TEXT NOT NULL,
|
||||
"output_type" "skill_output_type" NOT NULL DEFAULT 'analysis',
|
||||
"output_schema" JSONB,
|
||||
"requires_norms" BOOLEAN NOT NULL DEFAULT false,
|
||||
"requires_decisions" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "skills_tenant_slug_idx" ON "skills" ("tenant_id", "slug");
|
||||
CREATE INDEX IF NOT EXISTS "skills_tenant_idx" ON "skills" ("tenant_id");
|
||||
CREATE INDEX IF NOT EXISTS "skills_active_idx" ON "skills" ("tenant_id", "is_active");
|
||||
|
||||
-- Step 3: Seed system skills — handled by migration 0006
|
||||
|
||||
-- Step 4: Add skill_id and structured_result columns to analyses
|
||||
ALTER TABLE "analyses"
|
||||
ADD COLUMN IF NOT EXISTS "skill_id" UUID REFERENCES "skills"("id") ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE "analyses"
|
||||
ADD COLUMN IF NOT EXISTS "structured_result" JSONB;
|
||||
|
||||
-- Step 5: Backfill skill_id from existing mode values
|
||||
UPDATE "analyses" a
|
||||
SET "skill_id" = s.id
|
||||
FROM "skills" s
|
||||
WHERE s.tenant_id = a.tenant_id
|
||||
AND s.slug = a.mode::text
|
||||
AND s.is_system = true
|
||||
AND a.skill_id IS NULL;
|
||||
|
||||
-- Step 6: Add RLS policy for skills table
|
||||
ALTER TABLE "skills" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE POLICY "skills_tenant_isolation" ON "skills"
|
||||
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
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 $$;
|
||||
@@ -29,6 +29,34 @@
|
||||
"when": 1775775900000,
|
||||
"tag": "0003_tenant_api_keys",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1775856000000,
|
||||
"tag": "0004_document_source_scope",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1776364800000,
|
||||
"tag": "0005_skills_and_analysis_refactor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1776451200000,
|
||||
"tag": "0006_seed_system_skills_fix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1776537600000,
|
||||
"tag": "0004_add_openrouter_provider",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,9 +11,10 @@ const commitHash = (() => {
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["pdf-parse", "drizzle-orm", "pg"],
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -17,7 +17,7 @@
|
||||
"mammoth": "^1.12.0",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"pg": "^8.20.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
@@ -2058,6 +2058,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
@@ -7148,26 +7149,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"mammoth": "^1.12.0",
|
||||
"next": "16.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"pg": "^8.20.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
|
||||
37
src/app/(dashboard)/analyse/[id]/analyse-detail.tsx
Normal file
37
src/app/(dashboard)/analyse/[id]/analyse-detail.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
interface Props {
|
||||
analysisId: string;
|
||||
result: string | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function AnalyseDetail({ analysisId, result, title }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{result && (
|
||||
<a
|
||||
href={`/api/analyses/${analysisId}?action=export-txt`}
|
||||
className="px-4 py-2 text-sm font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Als Text exportieren
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-4">Ergebnis</h3>
|
||||
<div className="prose prose-sm max-w-none text-foreground whitespace-pre-wrap">
|
||||
{result}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
|
||||
<p className="text-muted text-sm">Noch kein Ergebnis verfuegbar.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/app/(dashboard)/analyse/[id]/page.tsx
Normal file
88
src/app/(dashboard)/analyse/[id]/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { withTenantDb } from '@/lib/db';
|
||||
import { analyses } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import AnalyseDetail from './analyse-detail';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
gutachten: 'Gutachten',
|
||||
entscheidung: 'Entscheidungsprognose',
|
||||
vergleich: 'Vergleichsanalyse',
|
||||
risiko: 'Risikoanalyse',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
archived: 'Archiviert',
|
||||
};
|
||||
|
||||
export default async function AnalyseDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const tenantId = session!.user.tenantId;
|
||||
const { id } = await params;
|
||||
|
||||
const analysis = await withTenantDb(tenantId, async (tdb) => {
|
||||
const [a] = await tdb
|
||||
.select()
|
||||
.from(analyses)
|
||||
.where(eq(analyses.id, id))
|
||||
.limit(1);
|
||||
return a ?? null;
|
||||
});
|
||||
|
||||
if (!analysis) notFound();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/analyse"
|
||||
className="text-sm text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
← Zurueck
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground">{analysis.title}</h2>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{MODE_LABELS[analysis.mode] ?? analysis.mode}
|
||||
{' · '}
|
||||
{STATUS_LABELS[analysis.status] ?? analysis.status}
|
||||
{' · '}
|
||||
{new Date(analysis.createdAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.query && (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">Fragestellung</h3>
|
||||
<p className="text-sm text-muted whitespace-pre-wrap">{analysis.query}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnalyseDetail
|
||||
analysisId={analysis.id}
|
||||
result={analysis.result}
|
||||
title={analysis.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import SourceSelection from '@/components/documents/source-selection';
|
||||
|
||||
interface CaseOption {
|
||||
id: string;
|
||||
@@ -8,24 +9,89 @@ interface CaseOption {
|
||||
caseNumber: string;
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
{ key: 'gutachten', label: 'Gutachten' },
|
||||
{ key: 'entscheidung', label: 'Entscheidungsprognose' },
|
||||
{ key: 'vergleich', label: 'Vergleichsanalyse' },
|
||||
{ key: 'risiko', label: 'Risikoanalyse' },
|
||||
] as const;
|
||||
interface SkillOption {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
outputType: 'analysis' | 'structured_data';
|
||||
outputSchema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** Render a structured data result as a key-value table. */
|
||||
function StructuredDataResult({ data }: { data: Record<string, unknown> }) {
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length === 0) return <p className="text-sm text-muted">Keine Daten.</p>;
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-card-border">
|
||||
<th className="text-left py-2 pr-4 font-medium text-foreground">Feld</th>
|
||||
<th className="text-left py-2 font-medium text-foreground">Wert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(([key, value]) => (
|
||||
<tr key={key} className="border-b border-card-border/50">
|
||||
<td className="py-2 pr-4 text-muted font-medium whitespace-nowrap">{key}</td>
|
||||
<td className="py-2 text-foreground">
|
||||
{typeof value === 'object' && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value ?? '—')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
|
||||
const [mode, setMode] = useState<string>('gutachten');
|
||||
const [skills, setSkills] = useState<SkillOption[]>([]);
|
||||
const [skillSlug, setSkillSlug] = useState('');
|
||||
const [caseId, setCaseId] = useState('');
|
||||
const [question, setQuestion] = useState('');
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<string[]>([]);
|
||||
const [result, setResult] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const selectedSkill = useMemo(
|
||||
() => skills.find((s) => s.slug === skillSlug),
|
||||
[skills, skillSlug],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/skills')
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((data: SkillOption[]) => {
|
||||
setSkills(data);
|
||||
if (data.length > 0 && !skillSlug) {
|
||||
setSkillSlug(data[0].slug);
|
||||
}
|
||||
})
|
||||
.catch(() => setSkills([]))
|
||||
.finally(() => setSkillsLoading(false));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** Try to parse a JSON structured-data response from streamed text. */
|
||||
function tryParseStructured(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text.trim());
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON (yet) — show as text
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!question.trim()) return;
|
||||
if (!question.trim() || !skillSlug) return;
|
||||
|
||||
setError('');
|
||||
setResult('');
|
||||
@@ -36,10 +102,12 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
title: `${MODES.find((m) => m.key === mode)?.label ?? mode} — ${question.trim().slice(0, 80)}`,
|
||||
mode: skillSlug,
|
||||
skillId: selectedSkill?.id,
|
||||
title: `${selectedSkill?.name ?? skillSlug} — ${question.trim().slice(0, 80)}`,
|
||||
query: question.trim(),
|
||||
caseId: caseId || undefined,
|
||||
documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -66,21 +134,37 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const isStructuredSkill = selectedSkill?.outputType === 'structured_data';
|
||||
const structuredData = isStructuredSkill && result ? tryParseStructured(result) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="bg-card-bg border border-card-border rounded-xl p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Analysemodus</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
>
|
||||
{MODES.map((m) => (
|
||||
<option key={m.key} value={m.key}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{skillsLoading ? (
|
||||
<div className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white text-muted">
|
||||
Lade Skills…
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white text-muted">
|
||||
Keine Skills verfügbar
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={skillSlug}
|
||||
onChange={(e) => setSkillSlug(e.target.value)}
|
||||
className="w-full rounded-lg border border-card-border px-3 py-2.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
|
||||
>
|
||||
{skills.map((s) => (
|
||||
<option key={s.slug} value={s.slug}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{selectedSkill?.description && (
|
||||
<p className="mt-1 text-xs text-muted leading-relaxed">{selectedSkill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -110,13 +194,19 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SourceSelection
|
||||
caseId={caseId || undefined}
|
||||
selectedIds={selectedDocumentIds}
|
||||
onChange={setSelectedDocumentIds}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-danger bg-danger/10 rounded-lg px-3 py-2">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !question.trim()}
|
||||
disabled={loading || !question.trim() || !skillSlug}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-light transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Analyse läuft...' : 'Analyse starten'}
|
||||
@@ -128,12 +218,21 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Ergebnis</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
||||
{MODES.find((m) => m.key === mode)?.label}
|
||||
{selectedSkill?.name ?? skillSlug}
|
||||
</span>
|
||||
{isStructuredSkill && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-secondary/10 text-secondary font-medium">
|
||||
Strukturierte Daten
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{result}
|
||||
</div>
|
||||
{structuredData ? (
|
||||
<StructuredDataResult data={structuredData} />
|
||||
) : (
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,33 +5,7 @@ import { analyses, cases } from '@/lib/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import Link from 'next/link';
|
||||
import AnalyseForm from './analyse-form';
|
||||
|
||||
const MODE_INFO = [
|
||||
{
|
||||
key: 'gutachten',
|
||||
label: 'Gutachten',
|
||||
description: 'Systematische Rechtsprüfung nach dem juristischen Gutachtenstil (Obersatz, Definition, Subsumtion, Ergebnis).',
|
||||
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||
},
|
||||
{
|
||||
key: 'entscheidung',
|
||||
label: 'Entscheidungsprognose',
|
||||
description: 'Prognose der wahrscheinlichen Gerichts- oder Schiedsentscheidung mit Präzedenzfällen.',
|
||||
icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3',
|
||||
},
|
||||
{
|
||||
key: 'vergleich',
|
||||
label: 'Vergleichsanalyse',
|
||||
description: 'Bewertung von Vergleichsoptionen: Erfolgsaussichten, Wirtschaftlichkeit, Risiko.',
|
||||
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
||||
},
|
||||
{
|
||||
key: 'risiko',
|
||||
label: 'Risikoanalyse',
|
||||
description: 'Risikomatrix mit Fristrisiken, Compliance-Risiken und priorisierter Handlungsempfehlung.',
|
||||
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z',
|
||||
},
|
||||
];
|
||||
import SkillCards from './skill-cards';
|
||||
|
||||
export default async function AnalysePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -67,20 +41,7 @@ export default async function AnalysePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{MODE_INFO.map((mode) => (
|
||||
<div
|
||||
key={mode.key}
|
||||
className="bg-card-bg border border-card-border rounded-xl p-4"
|
||||
>
|
||||
<svg className="w-6 h-6 text-primary mb-2" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={mode.icon} />
|
||||
</svg>
|
||||
<h4 className="text-sm font-semibold text-foreground">{mode.label}</h4>
|
||||
<p className="text-xs text-muted mt-1 leading-relaxed">{mode.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SkillCards />
|
||||
|
||||
<AnalyseForm cases={tenantCases} />
|
||||
|
||||
@@ -89,17 +50,15 @@ export default async function AnalysePage() {
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Bisherige Analysen</h3>
|
||||
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
|
||||
{recentAnalyses.map((a) => (
|
||||
<div key={a.id} className="px-5 py-4 flex items-center justify-between">
|
||||
<Link key={a.id} href={`/analyse/${a.id}`} className="px-5 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{a.title || 'Ohne Titel'}</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
{MODE_INFO.find((m) => m.key === a.mode)?.label ?? a.mode}
|
||||
</p>
|
||||
<p className="text-xs text-muted mt-0.5">{a.mode}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted">
|
||||
{new Date(a.createdAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
51
src/app/(dashboard)/analyse/skill-cards.tsx
Normal file
51
src/app/(dashboard)/analyse/skill-cards.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface SkillSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/** Map well-known system skill slugs to distinct SVG paths */
|
||||
const SKILL_ICONS: Record<string, string> = {
|
||||
gutachten: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||||
entscheidung: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3',
|
||||
vergleich: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
||||
risiko: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON = 'M13 10V3L4 14h7v7l9-11h-7z';
|
||||
|
||||
export default function SkillCards() {
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/skills')
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((data: SkillSummary[]) => setSkills(data))
|
||||
.catch(() => setSkills([]));
|
||||
}, []);
|
||||
|
||||
if (skills.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.slug}
|
||||
className="bg-card-bg border border-card-border rounded-xl p-4"
|
||||
>
|
||||
<svg className="w-6 h-6 text-primary mb-2" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={SKILL_ICONS[skill.slug] ?? DEFAULT_ICON} />
|
||||
</svg>
|
||||
<h4 className="text-sm font-semibold text-foreground">{skill.name}</h4>
|
||||
{skill.description && (
|
||||
<p className="text-xs text-muted mt-1 leading-relaxed">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
src/app/(dashboard)/dokumente/dokumente-archiv.tsx
Normal file
235
src/app/(dashboard)/dokumente/dokumente-archiv.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface DocumentItem {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
category: string;
|
||||
sourceScope: string;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
caseId: string | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
documents: DocumentItem[];
|
||||
currentCategory: string;
|
||||
currentPage: number;
|
||||
categoryLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
uploaded: 'Hochgeladen',
|
||||
extracting: 'Extrahiere...',
|
||||
extracted: 'Extrahiert',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
uploaded: 'bg-blue-500/10 text-blue-700',
|
||||
extracting: 'bg-yellow-500/10 text-yellow-700',
|
||||
extracted: 'bg-green-500/10 text-green-700',
|
||||
failed: 'bg-red-500/10 text-red-700',
|
||||
};
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function DokumenteArchiv({ documents, currentCategory, currentPage, categoryLabels }: Props) {
|
||||
const router = useRouter();
|
||||
const [viewingDoc, setViewingDoc] = useState<{ id: string; filename: string; text: string } | null>(null);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
async function handleView(doc: DocumentItem) {
|
||||
if (doc.status !== 'extracted') return;
|
||||
setLoading(doc.id);
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${doc.id}?action=view`);
|
||||
if (!res.ok) throw new Error('Laden fehlgeschlagen');
|
||||
const data = await res.json();
|
||||
setViewingDoc({
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
text: data.extractedText || 'Kein Text verfuegbar.',
|
||||
});
|
||||
} catch {
|
||||
alert('Dokument konnte nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCategoryChange(cat: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (cat !== 'all') params.set('category', cat);
|
||||
router.push(`/dokumente?${params}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Category filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['all', ...Object.keys(categoryLabels)].map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => handleCategoryChange(cat)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentCategory === cat
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-card-bg border border-card-border text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{cat === 'all' ? 'Alle' : categoryLabels[cat] ?? cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Document list */}
|
||||
{documents.length === 0 ? (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-8 text-center">
|
||||
<p className="text-muted text-sm">Keine Dokumente gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl divide-y divide-card-border">
|
||||
{documents.map((doc) => (
|
||||
<div key={doc.id} className="px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">{doc.filename}</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
{formatFileSize(doc.fileSizeBytes)}
|
||||
{' · '}
|
||||
{categoryLabels[doc.category] ?? doc.category}
|
||||
{' · '}
|
||||
{new Date(doc.createdAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ${
|
||||
STATUS_COLORS[doc.status] ?? 'bg-gray-500/10 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[doc.status] ?? doc.status}
|
||||
</span>
|
||||
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{doc.status === 'extracted' && (
|
||||
<button
|
||||
onClick={() => handleView(doc)}
|
||||
disabled={loading === doc.id}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading === doc.id ? '...' : 'Ansehen'}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={`/api/documents/${doc.id}?action=download`}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-card-bg border border-card-border text-foreground rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Herunterladen
|
||||
</a>
|
||||
{doc.status === 'extracted' && (
|
||||
<a
|
||||
href={`/api/documents/${doc.id}?action=export-txt`}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-card-bg border border-card-border text-foreground rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Als Text
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.status === 'failed' && doc.errorMessage && (
|
||||
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg p-2 text-xs text-red-700">
|
||||
{doc.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex gap-2 justify-center">
|
||||
{currentPage > 1 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams();
|
||||
if (currentCategory !== 'all') params.set('category', currentCategory);
|
||||
params.set('page', String(currentPage - 1));
|
||||
router.push(`/dokumente?${params}`);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-card-bg border border-card-border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
)}
|
||||
{documents.length === 20 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams();
|
||||
if (currentCategory !== 'all') params.set('category', currentCategory);
|
||||
params.set('page', String(currentPage + 1));
|
||||
router.push(`/dokumente?${params}`);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-card-bg border border-card-border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document viewer modal */}
|
||||
{viewingDoc && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">{viewingDoc.filename}</h3>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<a
|
||||
href={`/api/documents/${viewingDoc.id}?action=download`}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Original herunterladen
|
||||
</a>
|
||||
<a
|
||||
href={`/api/documents/${viewingDoc.id}?action=export-txt`}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Als Text exportieren
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setViewingDoc(null)}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<pre className="whitespace-pre-wrap text-sm text-foreground font-sans leading-relaxed">
|
||||
{viewingDoc.text}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
src/app/(dashboard)/dokumente/page.tsx
Normal file
69
src/app/(dashboard)/dokumente/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { documents } from '@/lib/db/schema';
|
||||
import { desc, eq, and, ilike } from 'drizzle-orm';
|
||||
import DokumenteArchiv from './dokumente-archiv';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
entscheidung: 'Entscheidung',
|
||||
norm: 'Norm',
|
||||
falldokument: 'Falldokument',
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
export default async function DokumentePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ category?: string; page?: string }>;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const tenantId = session!.user.tenantId;
|
||||
const { category, page } = await searchParams;
|
||||
const currentPage = parseInt(page ?? '1', 10);
|
||||
const pageSize = 20;
|
||||
const offset = (currentPage - 1) * pageSize;
|
||||
|
||||
const conditions = [eq(documents.tenantId, tenantId)];
|
||||
if (category && category !== 'all') {
|
||||
conditions.push(eq(documents.category, category as any));
|
||||
}
|
||||
|
||||
const docs = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
filename: documents.filename,
|
||||
mimeType: documents.mimeType,
|
||||
fileSizeBytes: documents.fileSizeBytes,
|
||||
category: documents.category,
|
||||
sourceScope: documents.sourceScope,
|
||||
status: documents.status,
|
||||
errorMessage: documents.errorMessage,
|
||||
caseId: documents.caseId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(documents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(documents.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground">Dokumentenarchiv</h2>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
Alle hochgeladenen Dokumente einsehen, herunterladen und exportieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DokumenteArchiv
|
||||
documents={docs}
|
||||
currentCategory={category ?? 'all'}
|
||||
currentPage={currentPage}
|
||||
categoryLabels={CATEGORY_LABELS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tenants, users } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import AISettingsForm from './ai-settings';
|
||||
import ApiKeySettings from './api-key-settings';
|
||||
import SkillsSettings from './skills-settings';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: 'Administrator',
|
||||
@@ -76,6 +77,8 @@ export default async function EinstellungenPage() {
|
||||
|
||||
{isAdmin && <ApiKeySettings />}
|
||||
|
||||
{isAdmin && <SkillsSettings />}
|
||||
|
||||
{isAdmin && tenantUsers.length > 0 && (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-4">Benutzer</h3>
|
||||
|
||||
496
src/app/(dashboard)/einstellungen/skills-settings.tsx
Normal file
496
src/app/(dashboard)/einstellungen/skills-settings.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import type { Skill, SkillFormData } from '@/types/skill';
|
||||
import { emptySkillForm, slugify } from '@/types/skill';
|
||||
|
||||
const OUTPUT_TYPE_LABELS: Record<string, string> = {
|
||||
analysis: 'Analyse',
|
||||
structured_data: 'Strukturierte Daten',
|
||||
};
|
||||
|
||||
export default function SkillsSettings() {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// Editor state
|
||||
const [editing, setEditing] = useState<Skill | null>(null); // null = creating new
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [form, setForm] = useState<SkillFormData>(emptySkillForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
|
||||
// Drag state
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
|
||||
const loadSkills = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/skills');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSkills(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setForm(emptySkillForm());
|
||||
setSlugManual(false);
|
||||
setShowEditor(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function openEdit(skill: Skill) {
|
||||
setEditing(skill);
|
||||
setForm({
|
||||
name: skill.name,
|
||||
slug: skill.slug,
|
||||
description: skill.description ?? '',
|
||||
systemPrompt: skill.systemPrompt,
|
||||
outputType: skill.outputType,
|
||||
outputSchema: skill.outputSchema ? JSON.stringify(skill.outputSchema, null, 2) : '',
|
||||
requiresNorms: skill.requiresNorms,
|
||||
requiresDecisions: skill.requiresDecisions,
|
||||
isActive: skill.isActive,
|
||||
});
|
||||
setSlugManual(true);
|
||||
setShowEditor(true);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setShowEditor(false);
|
||||
setEditing(null);
|
||||
setForm(emptySkillForm());
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function handleNameChange(name: string) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name,
|
||||
...(!slugManual && { slug: slugify(name) }),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSlugChange(slug: string) {
|
||||
setSlugManual(true);
|
||||
setForm((f) => ({ ...f, slug }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim() || !form.slug.trim() || !form.systemPrompt.trim()) {
|
||||
setMessage({ type: 'error', text: 'Name, Slug und System-Prompt sind Pflichtfelder.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate JSON schema if structured data
|
||||
let outputSchema: Record<string, unknown> | null = null;
|
||||
if (form.outputType === 'structured_data') {
|
||||
if (!form.outputSchema.trim()) {
|
||||
setMessage({ type: 'error', text: 'JSON Schema ist bei strukturierten Daten erforderlich.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
outputSchema = JSON.parse(form.outputSchema);
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Ungültiges JSON im Schema-Feld.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
slug: form.slug.trim(),
|
||||
description: form.description.trim() || null,
|
||||
systemPrompt: form.systemPrompt,
|
||||
outputType: form.outputType,
|
||||
outputSchema,
|
||||
requiresNorms: form.requiresNorms,
|
||||
requiresDecisions: form.requiresDecisions,
|
||||
isActive: form.isActive,
|
||||
};
|
||||
|
||||
try {
|
||||
const url = editing
|
||||
? `/api/settings/skills/${editing.id}`
|
||||
: '/api/settings/skills';
|
||||
const method = editing ? 'PATCH' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: editing ? 'Skill aktualisiert.' : 'Skill erstellt.' });
|
||||
closeEditor();
|
||||
await loadSkills();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Speichern.' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(skill: Skill) {
|
||||
if (skill.isSystem) return;
|
||||
if (!confirm(`Skill "${skill.name}" wirklich löschen?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/settings/skills/${skill.id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Skill deaktiviert.' });
|
||||
await loadSkills();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error ?? 'Fehler beim Löschen.' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Netzwerkfehler.' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(skill: Skill) {
|
||||
try {
|
||||
const res = await fetch(`/api/settings/skills/${skill.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: !skill.isActive }),
|
||||
});
|
||||
if (res.ok) await loadSkills();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetToDefault(skill: Skill) {
|
||||
if (!skill.isSystem) return;
|
||||
// Prefill with current values — the backend handles "reset" by restoring original system prompt
|
||||
// For now, we just re-fetch. A future enhancement could add a dedicated reset endpoint.
|
||||
setMessage({ type: 'error', text: 'Zurücksetzen auf Standard wird noch nicht unterstützt.' });
|
||||
}
|
||||
|
||||
// Drag-and-drop reorder
|
||||
function handleDragStart(idx: number) {
|
||||
setDragIdx(idx);
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent, idx: number) {
|
||||
e.preventDefault();
|
||||
if (dragIdx === null || dragIdx === idx) return;
|
||||
|
||||
const reordered = [...skills];
|
||||
const [moved] = reordered.splice(dragIdx, 1);
|
||||
reordered.splice(idx, 0, moved);
|
||||
setSkills(reordered);
|
||||
setDragIdx(idx);
|
||||
}
|
||||
|
||||
async function handleDragEnd() {
|
||||
if (dragIdx === null) return;
|
||||
setDragIdx(null);
|
||||
|
||||
const order = skills.map((s, i) => ({ id: s.id, sortOrder: i }));
|
||||
try {
|
||||
await fetch('/api/settings/skills/reorder', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
});
|
||||
} catch {
|
||||
await loadSkills(); // revert on error
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<p className="text-sm text-muted">Lade Skills...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Skills (Analysemodi)</h3>
|
||||
{!showEditor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-primary/90"
|
||||
>
|
||||
+ Neuer Skill
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && !showEditor && (
|
||||
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
{showEditor && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 border-t border-card-border pt-4">
|
||||
<p className="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
{editing ? `Skill bearbeiten: ${editing.name}` : 'Neuen Skill erstellen'}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="z.B. Rechtsgutachten"
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Slug *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.slug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder="z.B. rechtsgutachten"
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50"
|
||||
/>
|
||||
<p className="text-xs text-muted mt-1">Eindeutiger Bezeichner (Kleinbuchstaben, Bindestriche)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Kurze Beschreibung des Skills"
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">System-Prompt *</label>
|
||||
<textarea
|
||||
value={form.systemPrompt}
|
||||
onChange={(e) => setForm({ ...form, systemPrompt: e.target.value })}
|
||||
rows={20}
|
||||
placeholder="Der System-Prompt, der an das AI-Modell gesendet wird..."
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Ausgabetyp</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="outputType"
|
||||
value="analysis"
|
||||
checked={form.outputType === 'analysis'}
|
||||
onChange={() => setForm({ ...form, outputType: 'analysis' })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Analyse (Freitext)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="outputType"
|
||||
value="structured_data"
|
||||
checked={form.outputType === 'structured_data'}
|
||||
onChange={() => setForm({ ...form, outputType: 'structured_data' })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Strukturierte Daten
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.outputType === 'structured_data' && (
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">JSON Schema *</label>
|
||||
<textarea
|
||||
value={form.outputSchema}
|
||||
onChange={(e) => setForm({ ...form, outputSchema: e.target.value })}
|
||||
rows={10}
|
||||
placeholder='{"type": "object", "properties": {...}}'
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50 resize-y"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.requiresNorms}
|
||||
onChange={(e) => setForm({ ...form, requiresNorms: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Normen benötigt
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.requiresDecisions}
|
||||
onChange={(e) => setForm({ ...form, requiresDecisions: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Entscheidungen benötigt
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
onChange={(e) => setForm({ ...form, isActive: e.target.checked })}
|
||||
className="accent-primary"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{message && showEditor && (
|
||||
<p className={`text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
className="rounded-lg border border-card-border px-4 py-2 text-sm font-medium text-muted hover:text-foreground"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{editing?.isSystem && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetToDefault(editing)}
|
||||
className="rounded-lg border border-amber-300 px-4 py-2 text-sm font-medium text-amber-600 hover:bg-amber-50"
|
||||
>
|
||||
Standard wiederherstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Skills list */}
|
||||
{!showEditor && skills.length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
Keine Skills vorhanden. Erstellen Sie einen neuen Skill oder warten Sie auf die System-Skills.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showEditor && skills.length > 0 && (
|
||||
<div className="divide-y divide-card-border">
|
||||
{skills.map((skill, idx) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`py-3 flex items-center justify-between gap-3 ${
|
||||
dragIdx === idx ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
className="cursor-grab text-muted hover:text-foreground shrink-0"
|
||||
title="Ziehen zum Sortieren"
|
||||
>
|
||||
⠿
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{skill.name}</span>
|
||||
<span className="text-xs text-muted font-mono">({skill.slug})</span>
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full shrink-0 ${
|
||||
skill.isActive ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
title={skill.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||
/>
|
||||
{skill.isSystem && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-0.5 truncate">
|
||||
{skill.description ?? '—'} · {OUTPUT_TYPE_LABELS[skill.outputType] ?? skill.outputType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleActive(skill)}
|
||||
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
|
||||
>
|
||||
{skill.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(skill)}
|
||||
className="text-xs px-2 py-1 rounded border border-card-border text-muted hover:text-foreground"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
{!skill.isSystem && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(skill)}
|
||||
className="text-xs px-2 py-1 rounded border border-red-300 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export default async function DashboardLayout({
|
||||
<main className="flex-1 p-8 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="px-8 py-3 text-xs text-gray-400 border-t border-gray-200">
|
||||
v{process.env.NEXT_PUBLIC_APP_VERSION || '0.9.0.1'} ({process.env.NEXT_PUBLIC_BUILD_HASH || 'dev'})
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
// GET /api/analyses/:id — Retrieve a single analysis with its sources
|
||||
// Supports ?action=export-txt for plain-text export
|
||||
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { withTenantDb } from '@/lib/db';
|
||||
import { analyses, norms, normInstruments, decisions } from '@/lib/db/schema';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { logAuditEvent } from '@/lib/auth/audit';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
import type { TenantContext } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('analyses:read');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
const { id } = await params;
|
||||
|
||||
const tenantId = request.headers.get('x-tenant-id');
|
||||
if (!tenantId) {
|
||||
return Response.json({ error: 'Missing x-tenant-id header' }, { status: 401 });
|
||||
}
|
||||
|
||||
// RLS enforces tenant isolation — only rows matching app.tenant_id are visible.
|
||||
const result = await withTenantDb(tenantId, async (tdb) => {
|
||||
const result = await withTenantDb(ctx.tenantId, async (tdb) => {
|
||||
const [analysis] = await tdb
|
||||
.select()
|
||||
.from(analyses)
|
||||
@@ -72,14 +72,33 @@ export async function GET(
|
||||
return Response.json({ error: 'Analysis not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const userId = request.headers.get('x-user-id') ?? 'unknown';
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('x-real-ip')
|
||||
?? undefined;
|
||||
await logAuditEvent(
|
||||
{ tenantId, userId } as TenantContext,
|
||||
'read', 'analysis', id, undefined, ip,
|
||||
);
|
||||
|
||||
const action = request.nextUrl.searchParams.get('action');
|
||||
|
||||
// Export analysis as plain text
|
||||
if (action === 'export-txt') {
|
||||
if (!result.analysis.result) {
|
||||
return Response.json(
|
||||
{ error: 'Analyse hat noch kein Ergebnis.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await logAuditEvent(ctx, 'export', 'analysis', id, { format: 'txt' }, ip);
|
||||
|
||||
const safeTitle = (result.analysis.title || 'analyse').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_');
|
||||
return new Response(result.analysis.result, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(safeTitle)}.txt"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await logAuditEvent(ctx, 'read', 'analysis', id, undefined, ip);
|
||||
|
||||
return Response.json({
|
||||
...result.analysis,
|
||||
|
||||
@@ -18,9 +18,18 @@ export async function POST(request: NextRequest) {
|
||||
const { ctx } = auth;
|
||||
|
||||
const body = await request.json();
|
||||
const { mode, title, query, caseId, normIds, decisionIds, stichtag } = body;
|
||||
const { skillId, skillSlug, mode, title, query, caseId, normIds, decisionIds, documentIds, stichtag } = body;
|
||||
|
||||
if (!mode || !VALID_MODES.has(mode)) {
|
||||
// Require at least one of skillId, skillSlug, or mode
|
||||
if (!skillId && !skillSlug && !mode) {
|
||||
return Response.json(
|
||||
{ error: 'Either skillId, skillSlug, or mode is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate legacy mode if provided
|
||||
if (mode && !skillId && !skillSlug && !VALID_MODES.has(mode)) {
|
||||
return Response.json(
|
||||
{ error: `Invalid mode. Must be one of: ${[...VALID_MODES].join(', ')}` },
|
||||
{ status: 400 },
|
||||
@@ -34,26 +43,37 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const { analysisId, stream } = await runAnalysis({
|
||||
const result = await runAnalysis({
|
||||
tenantId: ctx.tenantId,
|
||||
userId: ctx.userId,
|
||||
caseId,
|
||||
skillId,
|
||||
skillSlug,
|
||||
mode,
|
||||
title,
|
||||
query,
|
||||
normIds,
|
||||
decisionIds,
|
||||
documentIds,
|
||||
stichtag,
|
||||
});
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('x-real-ip')
|
||||
?? undefined;
|
||||
await logAuditEvent(ctx, 'create', 'analysis', analysisId, { mode, title }, ip);
|
||||
await logAuditEvent(ctx, 'create', 'analysis', result.analysisId, { skillId, skillSlug, mode, title }, ip);
|
||||
|
||||
// If structured result (no stream), return JSON
|
||||
if ('structuredResult' in result) {
|
||||
return Response.json({
|
||||
analysisId: result.analysisId,
|
||||
structuredResult: result.structuredResult,
|
||||
});
|
||||
}
|
||||
|
||||
// Return streaming response with analysis ID in header
|
||||
const response = stream.toTextStreamResponse();
|
||||
response.headers.set('X-Analysis-Id', analysisId);
|
||||
const response = result.stream.toTextStreamResponse();
|
||||
response.headers.set('X-Analysis-Id', result.analysisId);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -75,6 +95,7 @@ export async function GET(request: NextRequest) {
|
||||
id: analyses.id,
|
||||
title: analyses.title,
|
||||
mode: analyses.mode,
|
||||
skillId: analyses.skillId,
|
||||
status: analyses.status,
|
||||
createdAt: analyses.createdAt,
|
||||
updatedAt: analyses.updatedAt,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,106 @@
|
||||
// GET /api/documents/:id — get document metadata, or download/export the file
|
||||
// DELETE /api/documents/:id — delete a document and its stored file
|
||||
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { deleteDocument } from '@/lib/documents';
|
||||
import { getDocument, deleteDocument } from '@/lib/documents';
|
||||
import { logAuditEvent } from '@/lib/auth/audit';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('cases:read');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const doc = await getDocument(ctx.tenantId, id);
|
||||
|
||||
if (!doc) {
|
||||
return Response.json({ error: 'Dokument nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const action = request.nextUrl.searchParams.get('action');
|
||||
|
||||
// Download the original file
|
||||
if (action === 'download') {
|
||||
const fs = await import('node:fs/promises');
|
||||
try {
|
||||
await fs.access(doc.storagePath);
|
||||
} catch {
|
||||
return Response.json({ error: 'Datei nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
const fileBuffer = await fs.readFile(doc.storagePath);
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('x-real-ip')
|
||||
?? undefined;
|
||||
await logAuditEvent(ctx, 'download', 'document', id, { filename: doc.filename }, ip);
|
||||
|
||||
return new Response(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': doc.mimeType,
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(doc.filename)}"`,
|
||||
'Content-Length': String(fileBuffer.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export as plain text (extracted text)
|
||||
if (action === 'export-txt') {
|
||||
if (!doc.extractedText) {
|
||||
return Response.json(
|
||||
{ error: 'Kein extrahierter Text verfuegbar. Status: ' + doc.status },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('x-real-ip')
|
||||
?? undefined;
|
||||
await logAuditEvent(ctx, 'export', 'document', id, { format: 'txt', filename: doc.filename }, ip);
|
||||
|
||||
const baseName = doc.filename.replace(/\.[^.]+$/, '');
|
||||
return new Response(doc.extractedText, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(baseName)}.txt"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// View extracted text (inline, for the UI viewer)
|
||||
if (action === 'view') {
|
||||
return Response.json({
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
mimeType: doc.mimeType,
|
||||
fileSizeBytes: doc.fileSizeBytes,
|
||||
category: doc.category,
|
||||
status: doc.status,
|
||||
errorMessage: doc.errorMessage,
|
||||
extractedText: doc.extractedText ?? null,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Default: metadata only
|
||||
return Response.json({
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
mimeType: doc.mimeType,
|
||||
fileSizeBytes: doc.fileSizeBytes,
|
||||
category: doc.category,
|
||||
status: doc.status,
|
||||
errorMessage: doc.errorMessage,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { logAuditEvent } from '@/lib/auth/audit';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
const VALID_CATEGORIES = new Set(['entscheidung', 'norm', 'falldokument', 'sonstiges']);
|
||||
const VALID_SOURCE_SCOPES = new Set(['case', 'global']);
|
||||
|
||||
/** Convert empty/whitespace-only strings to undefined (FormData sends "" for blank fields). */
|
||||
function emptyToUndefined(value: string | null): string | undefined {
|
||||
@@ -22,6 +23,7 @@ export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
const category = (formData.get('category') as string) || 'sonstiges';
|
||||
const sourceScope = emptyToUndefined(formData.get('sourceScope') as string | null);
|
||||
const caseId = emptyToUndefined(formData.get('caseId') as string | null);
|
||||
const decisionId = emptyToUndefined(formData.get('decisionId') as string | null);
|
||||
const normInstrumentId = emptyToUndefined(formData.get('normInstrumentId') as string | null);
|
||||
@@ -50,6 +52,9 @@ export async function POST(request: NextRequest) {
|
||||
userId: ctx.userId,
|
||||
file,
|
||||
category: category as 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges',
|
||||
sourceScope: sourceScope && VALID_SOURCE_SCOPES.has(sourceScope)
|
||||
? sourceScope as 'case' | 'global'
|
||||
: undefined,
|
||||
caseId,
|
||||
decisionId,
|
||||
normInstrumentId,
|
||||
@@ -61,8 +66,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
|
||||
// Trigger text extraction asynchronously (fire-and-forget)
|
||||
extractDocumentText(ctx.tenantId, result.documentId).catch(() => {
|
||||
// Extraction errors are stored in the document record
|
||||
extractDocumentText(ctx.tenantId, result.documentId).catch((err) => {
|
||||
console.error(`[documents] Text extraction failed for ${result.documentId}:`, err);
|
||||
});
|
||||
|
||||
return Response.json(result, { status: 201 });
|
||||
@@ -81,6 +86,7 @@ export async function GET(request: NextRequest) {
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 100);
|
||||
const offset = parseInt(searchParams.get('offset') ?? '0', 10);
|
||||
const category = searchParams.get('category') as string | null;
|
||||
const sourceScope = searchParams.get('sourceScope') as string | null;
|
||||
const caseId = emptyToUndefined(searchParams.get('caseId'));
|
||||
const decisionId = emptyToUndefined(searchParams.get('decisionId'));
|
||||
const normInstrumentId = emptyToUndefined(searchParams.get('normInstrumentId'));
|
||||
@@ -91,6 +97,9 @@ export async function GET(request: NextRequest) {
|
||||
category: category && VALID_CATEGORIES.has(category)
|
||||
? category as 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges'
|
||||
: undefined,
|
||||
sourceScope: sourceScope && VALID_SOURCE_SCOPES.has(sourceScope)
|
||||
? sourceScope as 'case' | 'global'
|
||||
: undefined,
|
||||
caseId,
|
||||
decisionId,
|
||||
normInstrumentId,
|
||||
|
||||
@@ -39,10 +39,9 @@ Antworte NUR mit einem JSON-Array. Kein erklaerener Text, kein Markdown, nur das
|
||||
}
|
||||
]`;
|
||||
|
||||
async function extractTextFromPdf(buffer: Buffer): Promise<string> {
|
||||
const pdfParse = (await import('pdf-parse')).default;
|
||||
const data = await pdfParse(buffer);
|
||||
return data.text;
|
||||
async function extractTextFromPdfBuffer(buffer: Buffer): Promise<string> {
|
||||
const { extractTextFromPdf } = await import('@/lib/pdf');
|
||||
return extractTextFromPdf(buffer);
|
||||
}
|
||||
|
||||
const CHUNK_CHAR_LIMIT = 10_000;
|
||||
@@ -98,7 +97,7 @@ export async function POST(request: Request) {
|
||||
|
||||
if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) {
|
||||
try {
|
||||
text = await extractTextFromPdf(buffer);
|
||||
text = await extractTextFromPdfBuffer(buffer);
|
||||
} catch (err) {
|
||||
console.error('PDF parse error:', err);
|
||||
return Response.json(
|
||||
|
||||
@@ -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);
|
||||
|
||||
150
src/app/api/settings/skills/[id]/route.ts
Normal file
150
src/app/api/settings/skills/[id]/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// GET /api/settings/skills/[id] — Get skill detail
|
||||
// PATCH /api/settings/skills/[id] — Update a skill
|
||||
// DELETE /api/settings/skills/[id] — Soft-delete (set isActive = false)
|
||||
|
||||
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 withTenantDb(tenantId, async (tdb) =>
|
||||
tdb
|
||||
.select()
|
||||
.from(skills)
|
||||
.where(eq(skills.id, skillId))
|
||||
.limit(1),
|
||||
);
|
||||
return skill ?? null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { id } = await params;
|
||||
|
||||
const skill = await findSkillForTenant(id, auth.ctx.tenantId);
|
||||
if (!skill) {
|
||||
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(skill);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
const { id } = await params;
|
||||
|
||||
const skill = await findSkillForTenant(id, ctx.tenantId);
|
||||
if (!skill) {
|
||||
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
name, slug, description, systemPrompt, outputType,
|
||||
outputSchema, requiresNorms, requiresDecisions, isActive,
|
||||
} = body as {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string | null;
|
||||
systemPrompt?: string;
|
||||
outputType?: string;
|
||||
outputSchema?: Record<string, unknown> | null;
|
||||
requiresNorms?: boolean;
|
||||
requiresDecisions?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
// Validate slug if changed
|
||||
if (slug !== undefined && slug !== skill.slug) {
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
|
||||
return Response.json(
|
||||
{ error: 'Slug muss Kleinbuchstaben, Ziffern und Bindestriche enthalten.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
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.' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (outputType === 'structured_data' && outputSchema === null) {
|
||||
return Response.json(
|
||||
{ error: 'JSON Schema ist bei strukturierten Daten erforderlich.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (slug !== undefined) updates.slug = slug;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (systemPrompt !== undefined) updates.systemPrompt = systemPrompt;
|
||||
if (outputType !== undefined) updates.outputType = outputType;
|
||||
if (outputSchema !== undefined) updates.outputSchema = outputSchema;
|
||||
if (requiresNorms !== undefined) updates.requiresNorms = requiresNorms;
|
||||
if (requiresDecisions !== undefined) updates.requiresDecisions = requiresDecisions;
|
||||
if (isActive !== undefined) updates.isActive = isActive;
|
||||
|
||||
const [updated] = await withTenantDb(ctx.tenantId, async (tdb) =>
|
||||
tdb
|
||||
.update(skills)
|
||||
.set(updates)
|
||||
.where(eq(skills.id, id))
|
||||
.returning(),
|
||||
);
|
||||
|
||||
return Response.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
const { id } = await params;
|
||||
|
||||
const skill = await findSkillForTenant(id, ctx.tenantId);
|
||||
if (!skill) {
|
||||
return Response.json({ error: 'Skill nicht gefunden.' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (skill.isSystem) {
|
||||
return Response.json(
|
||||
{ error: 'System-Skills können nicht gelöscht werden. Deaktivieren Sie den Skill stattdessen.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Soft-delete: set isActive = false
|
||||
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);
|
||||
}
|
||||
32
src/app/api/settings/skills/reorder/route.ts
Normal file
32
src/app/api/settings/skills/reorder/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// PATCH /api/settings/skills/reorder — Update sort_order for drag-and-drop reordering
|
||||
|
||||
import { withTenantDb } from '@/lib/db';
|
||||
import { skills } from '@/lib/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
|
||||
const body = await request.json();
|
||||
const { order } = body as {
|
||||
order?: Array<{ id: string; sortOrder: number }>;
|
||||
};
|
||||
|
||||
if (!order || !Array.isArray(order)) {
|
||||
return Response.json({ error: 'order Array ist erforderlich.' }, { status: 400 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
109
src/app/api/settings/skills/route.ts
Normal file
109
src/app/api/settings/skills/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// GET /api/settings/skills — List all skills for tenant (sorted by sortOrder)
|
||||
// POST /api/settings/skills — Create a new skill
|
||||
|
||||
import { withTenantDb } from '@/lib/db';
|
||||
import { skills } from '@/lib/db/schema';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
|
||||
const rows = await withTenantDb(auth.ctx.tenantId, async (tdb) =>
|
||||
tdb
|
||||
.select()
|
||||
.from(skills)
|
||||
.orderBy(asc(skills.sortOrder), asc(skills.createdAt)),
|
||||
);
|
||||
|
||||
return Response.json(rows);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requirePermission('settings:manage');
|
||||
if ('response' in auth) return auth.response;
|
||||
const { ctx } = auth;
|
||||
|
||||
const body = await request.json();
|
||||
const { name, slug, description, systemPrompt, outputType, outputSchema, requiresNorms, requiresDecisions, isActive } = body as {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string | null;
|
||||
systemPrompt?: string;
|
||||
outputType?: string;
|
||||
outputSchema?: Record<string, unknown> | null;
|
||||
requiresNorms?: boolean;
|
||||
requiresDecisions?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
if (!name || !slug || !systemPrompt) {
|
||||
return Response.json(
|
||||
{ error: 'Name, Slug und System-Prompt sind erforderlich.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
|
||||
return Response.json(
|
||||
{ error: 'Slug muss Kleinbuchstaben, Ziffern und Bindestriche enthalten.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (outputType === 'structured_data' && !outputSchema) {
|
||||
return Response.json(
|
||||
{ error: 'JSON Schema ist bei strukturierten Daten erforderlich.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(created, { status: 201 });
|
||||
}
|
||||
33
src/app/api/skills/route.ts
Normal file
33
src/app/api/skills/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// GET /api/skills — List active skills for the current tenant (read-only)
|
||||
// Used by the analyse form to populate the skill selector.
|
||||
|
||||
import { withTenantDb } from '@/lib/db';
|
||||
import { skills } from '@/lib/db/schema';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { requirePermission } from '@/lib/auth/rbac';
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requirePermission('analyses:create');
|
||||
if ('response' in auth) return auth.response;
|
||||
|
||||
const rows = await withTenantDb(auth.ctx.tenantId, async (tdb) =>
|
||||
tdb
|
||||
.select({
|
||||
id: skills.id,
|
||||
slug: skills.slug,
|
||||
name: skills.name,
|
||||
description: skills.description,
|
||||
outputType: skills.outputType,
|
||||
outputSchema: skills.outputSchema,
|
||||
requiresNorms: skills.requiresNorms,
|
||||
requiresDecisions: skills.requiresDecisions,
|
||||
isSystem: skills.isSystem,
|
||||
sortOrder: skills.sortOrder,
|
||||
})
|
||||
.from(skills)
|
||||
.where(eq(skills.isActive, true))
|
||||
.orderBy(asc(skills.sortOrder), asc(skills.createdAt)),
|
||||
);
|
||||
|
||||
return Response.json(rows);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
interface DokumentUploadProps {
|
||||
category: 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges';
|
||||
/** Source scope — case-specific or globally available */
|
||||
sourceScope?: 'case' | 'global';
|
||||
/** Optional linked entity ID */
|
||||
caseId?: string;
|
||||
decisionId?: string;
|
||||
@@ -18,12 +20,13 @@ interface DocumentItem {
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
uploaded: 'Hochgeladen',
|
||||
extracting: 'Text wird extrahiert...',
|
||||
extracting: 'Extrahiere Text...',
|
||||
extracted: 'Extrahiert',
|
||||
failed: 'Fehlgeschlagen',
|
||||
};
|
||||
@@ -35,14 +38,86 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
failed: 'bg-red-500/10 text-red-700',
|
||||
};
|
||||
|
||||
const STEP_LABELS = [
|
||||
{ key: 'uploaded', label: 'Hochgeladen' },
|
||||
{ key: 'extracting', label: 'Extrahiere Text' },
|
||||
{ key: 'extracted', label: 'Fertig' },
|
||||
];
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getStepIndex(status: string): number {
|
||||
if (status === 'uploaded') return 0;
|
||||
if (status === 'extracting') return 1;
|
||||
if (status === 'extracted') return 2;
|
||||
return -1; // failed
|
||||
}
|
||||
|
||||
function IngestionProgress({ doc, debug }: { doc: DocumentItem; debug: boolean }) {
|
||||
const stepIdx = getStepIndex(doc.status);
|
||||
const isFailed = doc.status === 'failed';
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center gap-1">
|
||||
{STEP_LABELS.map((step, i) => {
|
||||
const isActive = i === stepIdx;
|
||||
const isComplete = i < stepIdx;
|
||||
const isCurrent = isActive && !isFailed;
|
||||
|
||||
let dotClass = 'w-2.5 h-2.5 rounded-full shrink-0 transition-colors';
|
||||
if (isComplete) dotClass += ' bg-green-500';
|
||||
else if (isCurrent) dotClass += ' bg-yellow-500 animate-pulse';
|
||||
else if (isFailed && i === 1) dotClass += ' bg-red-500';
|
||||
else dotClass += ' bg-gray-300';
|
||||
|
||||
let lineClass = 'flex-1 h-0.5 transition-colors';
|
||||
if (isComplete) lineClass += ' bg-green-500';
|
||||
else lineClass += ' bg-gray-200';
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-1 flex-1">
|
||||
<div className={dotClass} />
|
||||
<span className={`text-[10px] ${isActive || isComplete ? 'text-foreground font-medium' : 'text-muted'}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
{i < STEP_LABELS.length - 1 && <div className={lineClass} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{isFailed && doc.errorMessage && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 text-xs text-red-700">
|
||||
<span className="font-medium">Fehler: </span>
|
||||
{doc.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug info */}
|
||||
{debug && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-2.5 text-[11px] font-mono text-gray-600 space-y-0.5">
|
||||
<div>ID: {doc.id}</div>
|
||||
<div>Status: {doc.status}</div>
|
||||
<div>MIME: {doc.mimeType}</div>
|
||||
<div>Groesse: {formatFileSize(doc.fileSizeBytes)}</div>
|
||||
<div>Hochgeladen: {new Date(doc.createdAt).toLocaleString('de-DE')}</div>
|
||||
{doc.errorMessage && <div className="text-red-600">Fehler: {doc.errorMessage}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DokumentUpload({
|
||||
category,
|
||||
sourceScope,
|
||||
caseId,
|
||||
decisionId,
|
||||
normInstrumentId,
|
||||
@@ -54,7 +129,9 @@ export default function DokumentUpload({
|
||||
const [success, setSuccess] = useState('');
|
||||
const [documents, setDocuments] = useState<DocumentItem[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [debug, setDebug] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
const params = new URLSearchParams({ category });
|
||||
@@ -73,6 +150,27 @@ export default function DokumentUpload({
|
||||
}
|
||||
}, [category, caseId, decisionId, normInstrumentId]);
|
||||
|
||||
// Determine if any documents need polling (in-progress states)
|
||||
const hasPending = documents.some(
|
||||
(d) => d.status === 'uploaded' || d.status === 'extracting',
|
||||
);
|
||||
|
||||
// Poll for status updates when documents are being processed
|
||||
useEffect(() => {
|
||||
if (hasPending) {
|
||||
pollRef.current = setInterval(fetchDocuments, 2000);
|
||||
} else if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [hasPending, fetchDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [fetchDocuments]);
|
||||
@@ -86,6 +184,7 @@ export default function DokumentUpload({
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
if (sourceScope) formData.append('sourceScope', sourceScope);
|
||||
if (caseId) formData.append('caseId', caseId);
|
||||
if (decisionId) formData.append('decisionId', decisionId);
|
||||
if (normInstrumentId) formData.append('normInstrumentId', normInstrumentId);
|
||||
@@ -100,7 +199,7 @@ export default function DokumentUpload({
|
||||
throw new Error(data.error || 'Upload fehlgeschlagen');
|
||||
}
|
||||
|
||||
setSuccess(`"${file.name}" erfolgreich hochgeladen.`);
|
||||
setSuccess(`"${file.name}" erfolgreich hochgeladen. Textextraktion laeuft...`);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
fetchDocuments();
|
||||
} catch (err) {
|
||||
@@ -194,40 +293,76 @@ export default function DokumentUpload({
|
||||
|
||||
{documents.length > 0 && (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
Dokumente ({documents.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Dokumente ({documents.length})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDebug((prev) => !prev)}
|
||||
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors ${
|
||||
debug
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-card-border text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Debug {debug ? 'an' : 'aus'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-card-border"
|
||||
className="p-3 rounded-lg border border-card-border"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{doc.filename}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{formatFileSize(doc.fileSizeBytes)} ·{' '}
|
||||
{new Date(doc.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{doc.filename}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{formatFileSize(doc.fileSizeBytes)} ·{' '}
|
||||
{new Date(doc.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ml-3 ${
|
||||
STATUS_COLORS[doc.status] ?? 'bg-gray-500/10 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[doc.status] ?? doc.status}
|
||||
</span>
|
||||
<a
|
||||
href={`/api/documents/${doc.id}?action=download`}
|
||||
className="ml-2 text-xs text-primary hover:text-primary/80 transition-colors shrink-0"
|
||||
title="Herunterladen"
|
||||
>
|
||||
↓
|
||||
</a>
|
||||
{doc.status === 'extracted' && (
|
||||
<a
|
||||
href={`/api/documents/${doc.id}?action=export-txt`}
|
||||
className="ml-1 text-xs text-primary hover:text-primary/80 transition-colors shrink-0"
|
||||
title="Als Text exportieren"
|
||||
>
|
||||
TXT
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(doc.id, doc.filename)}
|
||||
disabled={deleting === doc.id}
|
||||
className="ml-2 text-xs text-danger hover:text-danger/80 transition-colors disabled:opacity-50 shrink-0"
|
||||
title="Dokument loeschen"
|
||||
>
|
||||
{deleting === doc.id ? '...' : 'Loeschen'}
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium shrink-0 ml-3 ${
|
||||
STATUS_COLORS[doc.status] ?? 'bg-gray-500/10 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[doc.status] ?? doc.status}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(doc.id, doc.filename)}
|
||||
disabled={deleting === doc.id}
|
||||
className="ml-2 text-xs text-danger hover:text-danger/80 transition-colors disabled:opacity-50 shrink-0"
|
||||
title="Dokument loeschen"
|
||||
>
|
||||
{deleting === doc.id ? '...' : 'Loeschen'}
|
||||
</button>
|
||||
|
||||
{/* Show progress for non-extracted documents or if debug is on */}
|
||||
{(doc.status !== 'extracted' || debug) && (
|
||||
<IngestionProgress doc={doc} debug={debug} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
206
src/components/documents/source-selection.tsx
Normal file
206
src/components/documents/source-selection.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface DocumentSource {
|
||||
id: string;
|
||||
filename: string;
|
||||
category: string;
|
||||
sourceScope: string;
|
||||
status: string;
|
||||
caseId: string | null;
|
||||
}
|
||||
|
||||
interface SourceSelectionProps {
|
||||
caseId?: string;
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
entscheidung: 'Entscheidung',
|
||||
norm: 'Norm',
|
||||
falldokument: 'Falldokument',
|
||||
sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<string, string> = {
|
||||
global: 'Global',
|
||||
case: 'Fallbezogen',
|
||||
};
|
||||
|
||||
export default function SourceSelection({ caseId, selectedIds, onChange }: SourceSelectionProps) {
|
||||
const [sources, setSources] = useState<DocumentSource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch global documents + case-specific documents (if a case is selected)
|
||||
const params = new URLSearchParams();
|
||||
const results: DocumentSource[] = [];
|
||||
|
||||
// Always fetch global documents
|
||||
const globalRes = await fetch('/api/documents?sourceScope=global');
|
||||
if (globalRes.ok) {
|
||||
const data = await globalRes.json();
|
||||
results.push(...data.filter((d: DocumentSource) => d.status === 'extracted'));
|
||||
}
|
||||
|
||||
// Fetch case documents if case is selected
|
||||
if (caseId) {
|
||||
const caseRes = await fetch(`/api/documents?caseId=${caseId}&sourceScope=case`);
|
||||
if (caseRes.ok) {
|
||||
const data = await caseRes.json();
|
||||
results.push(...data.filter((d: DocumentSource) => d.status === 'extracted'));
|
||||
}
|
||||
}
|
||||
|
||||
setSources(results);
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [caseId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSources();
|
||||
}, [fetchSources]);
|
||||
|
||||
function toggleSource(id: string) {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((s) => s !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (selectedIds.length === sources.length) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange(sources.map((s) => s.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Group by scope
|
||||
const globalSources = sources.filter((s) => s.sourceScope === 'global');
|
||||
const caseSources = sources.filter((s) => s.sourceScope === 'case');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-xs text-muted py-2">Quellen werden geladen...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-muted py-2">
|
||||
Keine Dokumente verfügbar. Laden Sie Dokumente hoch, um sie als KI-Quellen zu verwenden.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-card-border rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 text-sm text-foreground hover:bg-gray-50 transition-colors rounded-lg"
|
||||
>
|
||||
<span className="font-medium">
|
||||
KI-Quellen ({selectedIds.length}/{sources.length} ausgewählt)
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-muted transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-card-border px-3 py-2 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted">
|
||||
Wählen Sie die Dokumente, die die KI berücksichtigen soll.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{selectedIds.length === sources.length ? 'Alle abwählen' : 'Alle auswählen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{globalSources.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1">
|
||||
Globale Quellen (Normen / Gesetze)
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{globalSources.map((src) => (
|
||||
<SourceItem
|
||||
key={src.id}
|
||||
source={src}
|
||||
selected={selectedIds.includes(src.id)}
|
||||
onToggle={() => toggleSource(src.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{caseSources.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1">
|
||||
Fallbezogene Dokumente
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{caseSources.map((src) => (
|
||||
<SourceItem
|
||||
key={src.id}
|
||||
source={src}
|
||||
selected={selectedIds.includes(src.id)}
|
||||
onToggle={() => toggleSource(src.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceItem({
|
||||
source,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
source: DocumentSource;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={onToggle}
|
||||
className="rounded border-gray-300 text-primary focus:ring-primary/30"
|
||||
/>
|
||||
<span className="text-sm text-foreground truncate flex-1">{source.filename}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-muted shrink-0">
|
||||
{CATEGORY_LABELS[source.category] ?? source.category}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
|
||||
{ href: '/entscheidungen', label: 'Entscheidungen', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/analyse', label: 'Analyse', icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' },
|
||||
{ href: '/vertraege', label: 'Verträge', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/dokumente', label: 'Dokumente', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
|
||||
{ href: '/verfahren', label: 'Verfahren', icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3' },
|
||||
{ href: '/einstellungen', label: 'Einstellungen', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
] as const;
|
||||
|
||||
@@ -1,29 +1,123 @@
|
||||
// Core analysis service — orchestrates norm/decision lookup, prompt assembly, and AI generation
|
||||
// Refactored to use DB-driven skills instead of hardcoded ANALYSIS_MODES (AIIA-96)
|
||||
|
||||
import { streamText, generateText } from 'ai';
|
||||
import { streamText, generateText, generateObject, jsonSchema } from 'ai';
|
||||
import { getModelForTenant } from './providers';
|
||||
import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts';
|
||||
import { buildContextBlock } from './prompts';
|
||||
import { SYSTEM_PROMPTS, type AnalysisModeKey } from './prompts';
|
||||
import { ANALYSIS_MODES } from './modes';
|
||||
import { AnalyseMode } from '@/types';
|
||||
import { db } from '@/lib/db';
|
||||
import { norms, normInstruments, decisions, analyses } from '@/lib/db/schema';
|
||||
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';
|
||||
|
||||
interface AnalysisInput {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
caseId?: string;
|
||||
mode: AnalyseMode;
|
||||
title: string;
|
||||
query: string;
|
||||
/** Skill ID — preferred way to select the analysis skill */
|
||||
skillId?: string;
|
||||
/** Skill slug — alternative to skillId (resolved to skill from DB) */
|
||||
skillSlug?: string;
|
||||
/** @deprecated Legacy mode enum — falls back to hardcoded config if no skill found */
|
||||
mode?: AnalyseMode;
|
||||
/** Optional: specific norm IDs to include as context */
|
||||
normIds?: string[];
|
||||
/** Optional: specific decision IDs to include as context */
|
||||
decisionIds?: string[];
|
||||
/** Optional: specific document IDs to include as context */
|
||||
documentIds?: string[];
|
||||
/** Optional: reference date for norm versioning (Stichtag) */
|
||||
stichtag?: string;
|
||||
}
|
||||
|
||||
interface ResolvedSkill {
|
||||
id: string;
|
||||
slug: string;
|
||||
systemPrompt: string;
|
||||
outputType: 'analysis' | 'structured_data';
|
||||
outputSchema: Record<string, unknown> | null;
|
||||
requiresNorms: boolean;
|
||||
requiresDecisions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the skill to use for this analysis.
|
||||
* Priority: skillId > skillSlug > mode (legacy fallback)
|
||||
*/
|
||||
async function resolveSkill(
|
||||
tenantId: string,
|
||||
input: Pick<AnalysisInput, 'skillId' | 'skillSlug' | 'mode'>,
|
||||
): Promise<ResolvedSkill> {
|
||||
const skillFields = {
|
||||
id: skills.id,
|
||||
slug: skills.slug,
|
||||
systemPrompt: skills.systemPrompt,
|
||||
outputType: skills.outputType,
|
||||
outputSchema: skills.outputSchema,
|
||||
requiresNorms: skills.requiresNorms,
|
||||
requiresDecisions: skills.requiresDecisions,
|
||||
};
|
||||
|
||||
// Try by skillId first
|
||||
if (input.skillId) {
|
||||
const [skill] = await withTenantDb(tenantId, async (tdb) =>
|
||||
tdb
|
||||
.select(skillFields)
|
||||
.from(skills)
|
||||
.where(and(eq(skills.id, input.skillId!), eq(skills.isActive, true)))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (skill) return skill;
|
||||
throw new Error(`Skill not found: ${input.skillId}`);
|
||||
}
|
||||
|
||||
// Try by skillSlug
|
||||
if (input.skillSlug) {
|
||||
const [skill] = await withTenantDb(tenantId, async (tdb) =>
|
||||
tdb
|
||||
.select(skillFields)
|
||||
.from(skills)
|
||||
.where(and(eq(skills.slug, input.skillSlug!), eq(skills.isActive, true)))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (skill) return skill;
|
||||
throw new Error(`Skill not found: ${input.skillSlug}`);
|
||||
}
|
||||
|
||||
// Legacy fallback: resolve mode enum to a DB skill (system skill with matching slug)
|
||||
if (input.mode) {
|
||||
const [skill] = await withTenantDb(tenantId, async (tdb) =>
|
||||
tdb
|
||||
.select(skillFields)
|
||||
.from(skills)
|
||||
.where(and(eq(skills.slug, input.mode!), eq(skills.isActive, true)))
|
||||
.limit(1),
|
||||
);
|
||||
|
||||
if (skill) return skill;
|
||||
|
||||
// Ultimate fallback: use hardcoded config (pre-migration compatibility)
|
||||
const modeConfig = ANALYSIS_MODES[input.mode];
|
||||
const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
|
||||
return {
|
||||
id: '',
|
||||
slug: input.mode,
|
||||
systemPrompt: SYSTEM_PROMPTS[systemPromptKey],
|
||||
outputType: 'analysis',
|
||||
outputSchema: null,
|
||||
requiresNorms: modeConfig.requiresNorms,
|
||||
requiresDecisions: modeConfig.requiresDecisions,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Either skillId, skillSlug, or mode must be provided');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch norms relevant to the analysis, respecting temporal versioning.
|
||||
* If normIds are given, fetch those. Otherwise fetch all active norms for the tenant.
|
||||
@@ -89,27 +183,65 @@ async function fetchDecisionContext(
|
||||
.limit(decisionIds?.length ? 50 : 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch document content for the analysis context.
|
||||
*/
|
||||
async function fetchDocumentContext(
|
||||
tenantId: string,
|
||||
documentIds?: string[],
|
||||
caseId?: string,
|
||||
) {
|
||||
if (!documentIds?.length) return [];
|
||||
|
||||
const { withTenantDb } = await import('@/lib/db');
|
||||
|
||||
return withTenantDb(tenantId, async (tdb) => {
|
||||
const conditions = [
|
||||
inArray(documents.id, documentIds),
|
||||
eq(documents.status, 'extracted'),
|
||||
];
|
||||
|
||||
return tdb
|
||||
.select({
|
||||
id: documents.id,
|
||||
filename: documents.filename,
|
||||
category: documents.category,
|
||||
sourceScope: documents.sourceScope,
|
||||
extractedText: documents.extractedText,
|
||||
})
|
||||
.from(documents)
|
||||
.where(and(...conditions))
|
||||
.limit(20);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an analysis record in the database and return a streaming response.
|
||||
* Supports both free-text (analysis) and structured data output types.
|
||||
*/
|
||||
export async function runAnalysis(input: AnalysisInput) {
|
||||
const modeConfig = ANALYSIS_MODES[input.mode];
|
||||
const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
|
||||
const skill = await resolveSkill(input.tenantId, input);
|
||||
|
||||
// Fetch context in parallel
|
||||
const [normContext, decisionContext] = await Promise.all([
|
||||
modeConfig.requiresNorms
|
||||
// Fetch context in parallel based on skill requirements
|
||||
const [normContext, decisionContext, documentContext] = await Promise.all([
|
||||
skill.requiresNorms
|
||||
? fetchNormContext(input.tenantId, input.normIds, input.stichtag)
|
||||
: Promise.resolve([]),
|
||||
modeConfig.requiresDecisions
|
||||
skill.requiresDecisions
|
||||
? fetchDecisionContext(input.tenantId, input.decisionIds)
|
||||
: Promise.resolve([]),
|
||||
input.documentIds?.length
|
||||
? fetchDocumentContext(input.tenantId, input.documentIds, input.caseId)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const contextBlock = buildContextBlock(normContext, decisionContext);
|
||||
const contextBlock = buildContextBlock(normContext, decisionContext, documentContext);
|
||||
|
||||
const { model, provider, modelId } = await getModelForTenant(input.tenantId);
|
||||
|
||||
// Determine mode value for backwards compatibility
|
||||
const modeValue = input.mode ?? skill.slug;
|
||||
|
||||
// Create the analysis record (status: in_progress)
|
||||
const [analysis] = await db
|
||||
.insert(analyses)
|
||||
@@ -117,7 +249,8 @@ export async function runAnalysis(input: AnalysisInput) {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId ?? null,
|
||||
mode: input.mode,
|
||||
mode: modeValue as AnalyseMode,
|
||||
skillId: skill.id || null,
|
||||
status: 'in_progress',
|
||||
title: input.title,
|
||||
query: input.query,
|
||||
@@ -126,7 +259,7 @@ export async function runAnalysis(input: AnalysisInput) {
|
||||
sources: {
|
||||
normIds: normContext.map((n) => n.id),
|
||||
decisionIds: decisionContext.map((d) => d.id),
|
||||
otherSources: [],
|
||||
otherSources: documentContext.map((d) => d.id),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
@@ -135,15 +268,45 @@ export async function runAnalysis(input: AnalysisInput) {
|
||||
? `${contextBlock}\n\n---\n\n## Rechtsfrage\n\n${input.query}`
|
||||
: input.query;
|
||||
|
||||
// For structured_data skills, use generateObject() instead of streaming
|
||||
if (skill.outputType === 'structured_data' && skill.outputSchema) {
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: skill.systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
schema: jsonSchema(skill.outputSchema),
|
||||
maxOutputTokens: 4096,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(analyses)
|
||||
.set({
|
||||
status: 'completed',
|
||||
result: JSON.stringify(result.object, null, 2),
|
||||
structuredResult: result.object as Record<string, unknown>,
|
||||
tokenUsage: {
|
||||
inputTokens: result.usage.inputTokens ?? 0,
|
||||
outputTokens: result.usage.outputTokens ?? 0,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(analyses.id, analysis.id));
|
||||
|
||||
return {
|
||||
analysisId: analysis.id,
|
||||
structuredResult: result.object,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: streaming free-text analysis
|
||||
return {
|
||||
analysisId: analysis.id,
|
||||
stream: streamText({
|
||||
model,
|
||||
system: SYSTEM_PROMPTS[systemPromptKey],
|
||||
system: skill.systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
maxOutputTokens: 4096,
|
||||
onFinish: async ({ text, usage }) => {
|
||||
// Update the analysis record with the result
|
||||
await db
|
||||
.update(analyses)
|
||||
.set({
|
||||
@@ -163,26 +326,30 @@ export async function runAnalysis(input: AnalysisInput) {
|
||||
|
||||
/**
|
||||
* Non-streaming analysis — for batch/background use.
|
||||
* Supports both analysis and structured_data output types.
|
||||
*/
|
||||
export async function runAnalysisSync(input: AnalysisInput) {
|
||||
const modeConfig = ANALYSIS_MODES[input.mode];
|
||||
const systemPromptKey = modeConfig.systemPromptKey as AnalysisModeKey;
|
||||
const skill = await resolveSkill(input.tenantId, input);
|
||||
|
||||
const [normContext, decisionContext] = await Promise.all([
|
||||
modeConfig.requiresNorms
|
||||
const [normContext, decisionContext, documentContext] = await Promise.all([
|
||||
skill.requiresNorms
|
||||
? fetchNormContext(input.tenantId, input.normIds, input.stichtag)
|
||||
: Promise.resolve([]),
|
||||
modeConfig.requiresDecisions
|
||||
skill.requiresDecisions
|
||||
? fetchDecisionContext(input.tenantId, input.decisionIds)
|
||||
: Promise.resolve([]),
|
||||
input.documentIds?.length
|
||||
? fetchDocumentContext(input.tenantId, input.documentIds, input.caseId)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const contextBlock = buildContextBlock(normContext, decisionContext);
|
||||
const contextBlock = buildContextBlock(normContext, decisionContext, documentContext);
|
||||
const userMessage = contextBlock
|
||||
? `${contextBlock}\n\n---\n\n## Rechtsfrage\n\n${input.query}`
|
||||
: input.query;
|
||||
|
||||
const { model, provider, modelId } = await getModelForTenant(input.tenantId);
|
||||
const modeValue = input.mode ?? skill.slug;
|
||||
|
||||
const [analysis] = await db
|
||||
.insert(analyses)
|
||||
@@ -190,7 +357,8 @@ export async function runAnalysisSync(input: AnalysisInput) {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
caseId: input.caseId ?? null,
|
||||
mode: input.mode,
|
||||
mode: modeValue as AnalyseMode,
|
||||
skillId: skill.id || null,
|
||||
status: 'in_progress',
|
||||
title: input.title,
|
||||
query: input.query,
|
||||
@@ -199,14 +367,51 @@ export async function runAnalysisSync(input: AnalysisInput) {
|
||||
sources: {
|
||||
normIds: normContext.map((n) => n.id),
|
||||
decisionIds: decisionContext.map((d) => d.id),
|
||||
otherSources: [],
|
||||
otherSources: documentContext.map((d) => d.id),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Structured data output
|
||||
if (skill.outputType === 'structured_data' && skill.outputSchema) {
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: skill.systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
schema: jsonSchema(skill.outputSchema),
|
||||
maxOutputTokens: 4096,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(analyses)
|
||||
.set({
|
||||
status: 'completed',
|
||||
result: JSON.stringify(result.object, null, 2),
|
||||
structuredResult: result.object as Record<string, unknown>,
|
||||
tokenUsage: {
|
||||
inputTokens: result.usage.inputTokens ?? 0,
|
||||
outputTokens: result.usage.outputTokens ?? 0,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(analyses.id, analysis.id));
|
||||
|
||||
return {
|
||||
analysisId: analysis.id,
|
||||
result: JSON.stringify(result.object, null, 2),
|
||||
structuredResult: result.object,
|
||||
sources: {
|
||||
normIds: normContext.map((n) => n.id),
|
||||
decisionIds: decisionContext.map((d) => d.id),
|
||||
documentIds: documentContext.map((d) => d.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Free-text output
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: SYSTEM_PROMPTS[systemPromptKey],
|
||||
system: skill.systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
maxOutputTokens: 4096,
|
||||
});
|
||||
@@ -230,6 +435,7 @@ export async function runAnalysisSync(input: AnalysisInput) {
|
||||
sources: {
|
||||
normIds: normContext.map((n) => n.id),
|
||||
decisionIds: decisionContext.map((d) => d.id),
|
||||
documentIds: documentContext.map((d) => d.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +118,12 @@ export function buildContextBlock(
|
||||
headnote: string | null;
|
||||
reasoning: string | null;
|
||||
}>,
|
||||
documents?: Array<{
|
||||
filename: string;
|
||||
category: string;
|
||||
sourceScope: string;
|
||||
extractedText: string | null;
|
||||
}>,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -149,5 +155,17 @@ export function buildContextBlock(
|
||||
}
|
||||
}
|
||||
|
||||
const docsWithText = documents?.filter((d) => d.extractedText) ?? [];
|
||||
if (docsWithText.length > 0) {
|
||||
parts.push('## Hochgeladene Dokumente\n');
|
||||
for (const d of docsWithText) {
|
||||
const scopeLabel = d.sourceScope === 'global' ? 'Global' : 'Fallbezogen';
|
||||
parts.push(`### ${d.filename} [${scopeLabel}]`);
|
||||
const text = d.extractedText!;
|
||||
parts.push(text.slice(0, 2000) + (text.length > 2000 ? '…' : ''));
|
||||
parts.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
|
||||
@@ -137,10 +137,8 @@ export async function extractDocumentText(tenantId: string, documentId: string):
|
||||
let text: string;
|
||||
|
||||
if (doc.mimeType === 'application/pdf') {
|
||||
// Dynamic import for pdf-parse (optional dependency)
|
||||
const pdfParse = (await import('pdf-parse')).default;
|
||||
const pdfData = await pdfParse(fileBuffer);
|
||||
text = pdfData.text;
|
||||
const { extractTextFromPdf } = await import('@/lib/pdf');
|
||||
text = await extractTextFromPdf(fileBuffer);
|
||||
} else {
|
||||
// DOCX — use mammoth for extraction
|
||||
const mammoth = await import('mammoth');
|
||||
|
||||
@@ -402,12 +402,16 @@ export const analyses = pgTable(
|
||||
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
|
||||
userId: uuid("user_id").notNull().references(() => users.id),
|
||||
mode: analysisModeEnum("mode").notNull(),
|
||||
/** FK to skills table — the skill used for this analysis */
|
||||
skillId: uuid("skill_id").references(() => skills.id, { onDelete: "set null" }),
|
||||
status: analysisStatusEnum("status").notNull().default("draft"),
|
||||
title: varchar("title", { length: 500 }).notNull(),
|
||||
/** Input query / legal question */
|
||||
query: text("query").notNull(),
|
||||
/** AI-generated analysis result (markdown) */
|
||||
result: text("result"),
|
||||
/** Structured JSON output for structured_data skills */
|
||||
structuredResult: jsonb("structured_result").$type<Record<string, unknown>>(),
|
||||
/** Source references cited in the analysis */
|
||||
sources: jsonb("sources").$type<{
|
||||
normIds: string[];
|
||||
@@ -433,6 +437,44 @@ export const analyses = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Skills — tenant-configurable analysis skill definitions
|
||||
// ============================================================
|
||||
|
||||
/** Output type for a skill */
|
||||
export const skillOutputTypeEnum = pgEnum("skill_output_type", [
|
||||
"analysis", // Free-text markdown output
|
||||
"structured_data", // Structured JSON output via generateObject()
|
||||
]);
|
||||
|
||||
/** Skills — tenant-scoped configurable analysis modes */
|
||||
export const skills = pgTable(
|
||||
"skills",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
|
||||
slug: varchar("slug", { length: 100 }).notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
systemPrompt: text("system_prompt").notNull(),
|
||||
outputType: skillOutputTypeEnum("output_type").notNull().default("analysis"),
|
||||
/** JSON Schema for structured data output (required when output_type = structured_data) */
|
||||
outputSchema: jsonb("output_schema").$type<Record<string, unknown>>(),
|
||||
requiresNorms: boolean("requires_norms").notNull().default(false),
|
||||
requiresDecisions: boolean("requires_decisions").notNull().default(false),
|
||||
isSystem: boolean("is_system").notNull().default(false),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
isActive: boolean("is_active").notNull().default(true),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("skills_tenant_slug_idx").on(t.tenantId, t.slug),
|
||||
index("skills_tenant_idx").on(t.tenantId),
|
||||
index("skills_active_idx").on(t.tenantId, t.isActive),
|
||||
],
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Vertragsanalyse (Contract Analysis Module — Phase 3.3)
|
||||
// ============================================================
|
||||
@@ -896,6 +938,12 @@ export const nonRenewalDeadlines = pgTable(
|
||||
// Dokumente (Generic Document Upload — Phase 3.4)
|
||||
// ============================================================
|
||||
|
||||
/** Document source scope — case-specific vs globally available */
|
||||
export const documentSourceScopeEnum = pgEnum("document_source_scope", [
|
||||
"case", // Case-specific document (only available within its case)
|
||||
"global", // Globally available (laws, regulations — available everywhere)
|
||||
]);
|
||||
|
||||
/** Document category — what kind of document this is */
|
||||
export const documentCategoryEnum = pgEnum("document_category", [
|
||||
"entscheidung", // Court decision / arbitration award document
|
||||
@@ -924,6 +972,8 @@ export const documents = pgTable(
|
||||
userId: uuid("user_id").notNull().references(() => users.id),
|
||||
/** Document category */
|
||||
category: documentCategoryEnum("category").notNull().default("sonstiges"),
|
||||
/** Source scope: case-specific or globally available */
|
||||
sourceScope: documentSourceScopeEnum("source_scope").notNull().default("case"),
|
||||
/** Optional link to a case */
|
||||
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
|
||||
/** Optional link to a decision */
|
||||
@@ -969,6 +1019,7 @@ export const documents = pgTable(
|
||||
export const apiKeyProviderEnum = pgEnum("api_key_provider", [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"ollama",
|
||||
]);
|
||||
|
||||
@@ -1146,6 +1197,10 @@ export const nonRenewalDeadlinesRelations = relations(nonRenewalDeadlines, ({ on
|
||||
contract: one(contracts, { fields: [nonRenewalDeadlines.contractId], references: [contracts.id] }),
|
||||
}));
|
||||
|
||||
export const skillsRelations = relations(skills, ({ one, many }) => ({
|
||||
tenant: one(tenants, { fields: [skills.tenantId], references: [tenants.id] }),
|
||||
}));
|
||||
|
||||
export const documentsRelations = relations(documents, ({ one }) => ({
|
||||
tenant: one(tenants, { fields: [documents.tenantId], references: [tenants.id] }),
|
||||
case: one(cases, { fields: [documents.caseId], references: [cases.id] }),
|
||||
|
||||
@@ -14,12 +14,14 @@ const ALLOWED_MIME_TYPES = new Set([
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
type DocumentCategory = 'entscheidung' | 'norm' | 'falldokument' | 'sonstiges';
|
||||
type DocumentSourceScope = 'case' | 'global';
|
||||
|
||||
interface UploadOptions {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
file: File;
|
||||
category: DocumentCategory;
|
||||
sourceScope?: DocumentSourceScope;
|
||||
caseId?: string;
|
||||
decisionId?: string;
|
||||
normInstrumentId?: string;
|
||||
@@ -36,7 +38,9 @@ interface UploadResult {
|
||||
* Text extraction happens asynchronously via extractDocumentText().
|
||||
*/
|
||||
export async function uploadDocument(opts: UploadOptions): Promise<UploadResult> {
|
||||
const { tenantId, userId, file, category, caseId, decisionId, normInstrumentId } = opts;
|
||||
const { tenantId, userId, file, category, sourceScope, caseId, decisionId, normInstrumentId } = opts;
|
||||
// Norm documents are always global; case documents default to case-scoped
|
||||
const resolvedScope = sourceScope ?? (category === 'norm' ? 'global' : 'case');
|
||||
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
throw new Error(
|
||||
@@ -70,6 +74,7 @@ export async function uploadDocument(opts: UploadOptions): Promise<UploadResult>
|
||||
tenantId,
|
||||
userId,
|
||||
category,
|
||||
sourceScope: resolvedScope,
|
||||
caseId: caseId ?? null,
|
||||
decisionId: decisionId ?? null,
|
||||
normInstrumentId: normInstrumentId ?? null,
|
||||
@@ -116,18 +121,48 @@ export async function extractDocumentText(tenantId: string, documentId: string):
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises');
|
||||
|
||||
// Verify file exists before attempting extraction
|
||||
try {
|
||||
await fs.access(doc.storagePath);
|
||||
} catch {
|
||||
throw new Error(`Datei nicht gefunden: ${doc.storagePath}`);
|
||||
}
|
||||
|
||||
const fileBuffer = await fs.readFile(doc.storagePath);
|
||||
|
||||
if (fileBuffer.length === 0) {
|
||||
throw new Error('Datei ist leer (0 Bytes).');
|
||||
}
|
||||
|
||||
let text: string;
|
||||
|
||||
if (doc.mimeType === 'application/pdf') {
|
||||
const pdfParse = (await import('pdf-parse')).default;
|
||||
const pdfData = await pdfParse(fileBuffer);
|
||||
text = pdfData.text;
|
||||
const { extractTextFromPdf } = await import('@/lib/pdf');
|
||||
try {
|
||||
text = await extractTextFromPdf(fileBuffer);
|
||||
} catch (pdfErr) {
|
||||
const pdfMessage = pdfErr instanceof Error ? pdfErr.message : String(pdfErr);
|
||||
if (pdfMessage.includes('encrypted') || pdfMessage.includes('password')) {
|
||||
throw new Error('PDF ist passwortgeschuetzt oder verschluesselt. Bitte ungeschuetzte Version hochladen.');
|
||||
}
|
||||
throw new Error(`PDF konnte nicht gelesen werden: ${pdfMessage}`);
|
||||
}
|
||||
|
||||
// Detect scanned PDFs with no text layer
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error(
|
||||
'PDF enthaelt keinen extrahierbaren Text. Moeglicherweise handelt es sich um ein gescanntes Dokument ohne Textebene (OCR erforderlich).',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const mammoth = await import('mammoth');
|
||||
const result = await mammoth.extractRawText({ buffer: fileBuffer });
|
||||
text = result.value;
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('DOCX enthaelt keinen extrahierbaren Text.');
|
||||
}
|
||||
}
|
||||
|
||||
await withTenantDb(tenantId, async (tdb) => {
|
||||
@@ -144,6 +179,7 @@ export async function extractDocumentText(tenantId: string, documentId: string):
|
||||
return text;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Textextraktion fehlgeschlagen';
|
||||
console.error(`[extractDocumentText] Document ${documentId} failed:`, message);
|
||||
await withTenantDb(tenantId, async (tdb) => {
|
||||
await tdb
|
||||
.update(documents)
|
||||
@@ -165,6 +201,7 @@ export async function listDocuments(
|
||||
tenantId: string,
|
||||
filters?: {
|
||||
category?: DocumentCategory;
|
||||
sourceScope?: DocumentSourceScope;
|
||||
caseId?: string;
|
||||
decisionId?: string;
|
||||
normInstrumentId?: string;
|
||||
@@ -178,6 +215,9 @@ export async function listDocuments(
|
||||
if (filters?.category) {
|
||||
conditions.push(eq(documents.category, filters.category));
|
||||
}
|
||||
if (filters?.sourceScope) {
|
||||
conditions.push(eq(documents.sourceScope, filters.sourceScope));
|
||||
}
|
||||
if (filters?.caseId) {
|
||||
conditions.push(eq(documents.caseId, filters.caseId));
|
||||
}
|
||||
@@ -195,7 +235,9 @@ export async function listDocuments(
|
||||
mimeType: documents.mimeType,
|
||||
fileSizeBytes: documents.fileSizeBytes,
|
||||
category: documents.category,
|
||||
sourceScope: documents.sourceScope,
|
||||
status: documents.status,
|
||||
errorMessage: documents.errorMessage,
|
||||
caseId: documents.caseId,
|
||||
decisionId: documents.decisionId,
|
||||
normInstrumentId: documents.normInstrumentId,
|
||||
|
||||
82
src/lib/pdf.ts
Normal file
82
src/lib/pdf.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// PDF text extraction using pdfjs-dist legacy build
|
||||
// Polyfill DOMMatrix/Path2D/ImageData for Node.js where they are unavailable
|
||||
|
||||
if (typeof globalThis.DOMMatrix === 'undefined') {
|
||||
// Minimal DOMMatrix polyfill — sufficient for pdfjs text extraction (no rendering)
|
||||
globalThis.DOMMatrix = class DOMMatrix {
|
||||
a: number; b: number; c: number; d: number; e: number; f: number;
|
||||
m11: number; m12: number; m13: number; m14: number;
|
||||
m21: number; m22: number; m23: number; m24: number;
|
||||
m31: number; m32: number; m33: number; m34: number;
|
||||
m41: number; m42: number; m43: number; m44: number;
|
||||
is2D: boolean; isIdentity: boolean;
|
||||
constructor(init?: number[] | string) {
|
||||
const v = Array.isArray(init) ? init : [1, 0, 0, 1, 0, 0];
|
||||
[this.a, this.b, this.c, this.d, this.e, this.f] =
|
||||
v.length === 16
|
||||
? [v[0], v[1], v[4], v[5], v[12], v[13]]
|
||||
: [v[0] ?? 1, v[1] ?? 0, v[2] ?? 0, v[3] ?? 1, v[4] ?? 0, v[5] ?? 0];
|
||||
this.m11 = this.a; this.m12 = this.b; this.m13 = 0; this.m14 = 0;
|
||||
this.m21 = this.c; this.m22 = this.d; this.m23 = 0; this.m24 = 0;
|
||||
this.m31 = 0; this.m32 = 0; this.m33 = 1; this.m34 = 0;
|
||||
this.m41 = this.e; this.m42 = this.f; this.m43 = 0; this.m44 = 1;
|
||||
this.is2D = true; this.isIdentity = this.a === 1 && this.b === 0 && this.c === 0 && this.d === 1 && this.e === 0 && this.f === 0;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static fromMatrix(other: any) { return new DOMMatrix([other.a, other.b, other.c, other.d, other.e, other.f]); }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static fromFloat64Array(arr: any) { return new DOMMatrix(Array.from(arr)); }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static fromFloat32Array(arr: any) { return new DOMMatrix(Array.from(arr)); }
|
||||
} as unknown as typeof DOMMatrix;
|
||||
}
|
||||
|
||||
if (typeof globalThis.Path2D === 'undefined') {
|
||||
globalThis.Path2D = class Path2D { constructor() {} } as unknown as typeof Path2D;
|
||||
}
|
||||
|
||||
if (typeof globalThis.ImageData === 'undefined') {
|
||||
globalThis.ImageData = class ImageData {
|
||||
width: number; height: number; data: Uint8ClampedArray;
|
||||
constructor(w: number, h: number) { this.width = w; this.height = h; this.data = new Uint8ClampedArray(w * h * 4); }
|
||||
} as unknown as typeof ImageData;
|
||||
}
|
||||
|
||||
// Force Next.js file tracer to include the worker file in standalone builds
|
||||
import 'pdfjs-dist/legacy/build/pdf.worker.mjs';
|
||||
|
||||
/**
|
||||
* Extract all text from a PDF buffer.
|
||||
* Uses pdfjs-dist legacy build with Node.js DOM polyfills for text extraction.
|
||||
*/
|
||||
export async function extractTextFromPdf(buffer: Buffer): Promise<string> {
|
||||
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||
|
||||
// Resolve the worker path at runtime so pdfjs can find it in standalone builds
|
||||
const { createRequire } = await import('module');
|
||||
const require = createRequire(import.meta.url ?? __filename);
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = require.resolve(
|
||||
'pdfjs-dist/legacy/build/pdf.worker.mjs',
|
||||
);
|
||||
|
||||
const data = new Uint8Array(buffer);
|
||||
const doc = await pdfjsLib.getDocument({
|
||||
data,
|
||||
useSystemFonts: true,
|
||||
isEvalSupported: false,
|
||||
}).promise;
|
||||
|
||||
const pages: string[] = [];
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
const pageText = content.items
|
||||
.filter((item) => 'str' in item)
|
||||
.map((item) => (item as { str: string }).str)
|
||||
.join(' ');
|
||||
pages.push(pageText);
|
||||
}
|
||||
|
||||
doc.destroy();
|
||||
return pages.join('\n');
|
||||
}
|
||||
51
src/types/pdf-parse.d.ts
vendored
51
src/types/pdf-parse.d.ts
vendored
@@ -1,14 +1,45 @@
|
||||
declare module 'pdf-parse' {
|
||||
interface PdfData {
|
||||
numpages: number;
|
||||
numrender: number;
|
||||
info: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
text: string;
|
||||
version: string;
|
||||
// Worker module — imported for side-effect (file tracer) only
|
||||
declare module 'pdfjs-dist/legacy/build/pdf.worker.mjs' {}
|
||||
|
||||
// pdfjs-dist legacy build type shim for dynamic import
|
||||
declare module 'pdfjs-dist/legacy/build/pdf.mjs' {
|
||||
export const GlobalWorkerOptions: {
|
||||
workerSrc: string;
|
||||
};
|
||||
|
||||
export function getDocument(params: {
|
||||
data: Uint8Array;
|
||||
useSystemFonts?: boolean;
|
||||
isEvalSupported?: boolean;
|
||||
disableAutoFetch?: boolean;
|
||||
}): { promise: Promise<PDFDocumentProxy> };
|
||||
|
||||
interface PDFDocumentProxy {
|
||||
numPages: number;
|
||||
getPage(pageNumber: number): Promise<PDFPageProxy>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
function pdfParse(dataBuffer: Buffer, options?: Record<string, unknown>): Promise<PdfData>;
|
||||
interface PDFPageProxy {
|
||||
getTextContent(): Promise<TextContent>;
|
||||
}
|
||||
|
||||
export default pdfParse;
|
||||
interface TextContent {
|
||||
items: Array<TextItem | TextMarkedContent>;
|
||||
}
|
||||
|
||||
interface TextItem {
|
||||
str: string;
|
||||
dir: string;
|
||||
width: number;
|
||||
height: number;
|
||||
transform: number[];
|
||||
fontName: string;
|
||||
hasEOL: boolean;
|
||||
}
|
||||
|
||||
interface TextMarkedContent {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
}
|
||||
|
||||
57
src/types/skill.ts
Normal file
57
src/types/skill.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Skill types for the Skills management feature
|
||||
|
||||
export interface Skill {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
systemPrompt: string;
|
||||
outputType: 'analysis' | 'structured_data';
|
||||
outputSchema: Record<string, unknown> | null;
|
||||
requiresNorms: boolean;
|
||||
requiresDecisions: boolean;
|
||||
isSystem: boolean;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SkillFormData {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
outputType: 'analysis' | 'structured_data';
|
||||
outputSchema: string; // JSON string for the textarea
|
||||
requiresNorms: boolean;
|
||||
requiresDecisions: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function emptySkillForm(): SkillFormData {
|
||||
return {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
systemPrompt: '',
|
||||
outputType: 'analysis',
|
||||
outputSchema: '',
|
||||
requiresNorms: false,
|
||||
requiresDecisions: false,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate a URL-safe slug from a name */
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[äÄ]/g, 'ae')
|
||||
.replace(/[öÖ]/g, 'oe')
|
||||
.replace(/[üÜ]/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
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