37 Commits

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

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

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

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

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

Seed is now handled entirely by migration 0006.

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

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 21:15:24 +00:00
Gitea Actions
b665f530e0 Bump build to 0.9.0.4 [skip ci] 2026-04-13 20:44:23 +00:00
a5e58aa142 Merge pull request 'feat: Dynamic skill selection for analysis (AIIA-98)' (#4) from feat/aiia-98-dynamic-skill-selection into master
All checks were successful
Deploy to VPS / deploy (push) Successful in 46s
2026-04-13 20:44:20 +00:00
Gitea Actions
abc6a259f0 Bump build to 0.9.0.3 [skip ci] 2026-04-13 20:43:18 +00:00
445491654b Merge pull request 'feat: Skills management settings UI and API routes (AIIA-97)' (#3) from feat/aiia-97-skills-settings-ui into master
Some checks failed
Deploy to VPS / deploy (push) Failing after 35s
2026-04-13 20:43:16 +00:00
Frontend Engineer
aec4a39d10 feat: refactor analysis to use DB-driven skills (AIIA-96)
Replace hardcoded ANALYSIS_MODES lookups with database-driven skill loading:
- Add skills table to Drizzle schema with tenant-scoped, configurable skills
- Add analyses.skill_id FK and structured_result JSONB column
- Refactor runAnalysis()/runAnalysisSync() to resolve skills from DB
- Support skillId, skillSlug, or legacy mode enum (with fallback)
- Add structured data output via generateObject() + jsonSchema() for
  skills with output_type = structured_data
- Update /api/analyses POST to accept skillId/skillSlug alongside mode
- Migration 0005: creates skills table, seeds system skills, backfills

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:59:52 +00:00
Frontend Engineer
e521b8e338 feat: replace hardcoded Analysemodus with dynamic skill selection (AIIA-98)
- Add GET /api/skills read-only endpoint for fetching active tenant skills
- Update analyse-form.tsx to fetch skills dynamically, show description
  as helper text, and render structured data results as table
- Extract SkillCards client component for the skill info cards
- Send skillId alongside mode slug for forward compatibility

Depends on: AIIA-97 (skills schema/API) and backend analysis integration.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:52:43 +00:00
Frontend Engineer
d15476f5e9 feat: add Skills management settings UI and API routes (AIIA-97)
- Skill types (src/types/skill.ts) with form data, slugify helper
- Skills settings component with list view (drag-and-drop reorder),
  editor form (name, slug, prompt, output type, JSON schema, context
  requirements, active toggle), system skill protection
- API routes: GET/POST /api/settings/skills, GET/PATCH/DELETE
  /api/settings/skills/[id], PATCH /api/settings/skills/reorder
- Integrated into /einstellungen page (admin only)
- API routes depend on `skills` table from AIIA-94 schema migration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 19:46:38 +00:00
Gitea Actions
817a0da714 Bump build to 0.9.0.2 [skip ci] 2026-04-12 20:54:11 +00:00
CTO (LegalAI)
86f4ef9012 feat: add auto-incrementing version number (0.9.0.X) in footer
All checks were successful
Deploy to VPS / deploy (push) Successful in 38s
- Added version.json to track base version and build number
- Updated Gitea Actions workflow to bump build number on each deploy
- Footer now shows version number alongside commit hash
- Version passed as build arg through Docker build pipeline

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 20:50:20 +00:00
CTO
e60b27cbd4 feat: persistent document viewing, archive page, and multi-format export (AIIA-83)
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m7s
- Add document download, view, and text export endpoints via ?action= query param
- Add /dokumente archive page with category filtering, pagination, and text viewer modal
- Add /analyse/[id] detail page for viewing analysis results with text export
- Make analysis list items clickable (link to detail view)
- Add download/export buttons to existing document upload component
- Add Dokumente nav item to sidebar

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 19:57:17 +00:00
CTO
d10a2453d2 fix: persist uploads across container rebuilds with Docker volume (AIIA-74)
All checks were successful
Deploy to VPS / deploy (push) Successful in 52s
Uploaded documents were lost on every redeploy because /app/uploads
had no persistent volume. Add uploads_data volume to docker-compose.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:50:59 +00:00
CTO
8dc71448d7 fix: add DOMMatrix/Path2D/ImageData polyfills for pdfjs-dist in Node.js (AIIA-74)
All checks were successful
Deploy to VPS / deploy (push) Successful in 48s
pdfjs-dist v5 requires DOMMatrix even in legacy build. Add minimal
polyfills so PDF text extraction works in the Node.js Docker container
without @napi-rs/canvas.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:41:20 +00:00
CTO
af219c38d8 fix: register 0004_document_source_scope migration in drizzle journal
All checks were successful
Deploy to VPS / deploy (push) Successful in 31s
The migration file was added by feat/aiia-66-source-selection but was not
registered in _journal.json, so it never runs on deploy. This caused
'source_scope' column-missing errors on document insert.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:28:11 +00:00
CTO (Paperclip)
b4ad27ad02 Merge feat/aiia-70-document-delete into master
All checks were successful
Deploy to VPS / deploy (push) Successful in 32s
Resolves conflicts with feat/aiia-66-source-selection merge:
- route.ts: keep both GET and DELETE endpoints
- dokument-upload.tsx: keep IngestionProgress + delete button

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:22:50 +00:00
e5d9d3cef3 Merge pull request 'feat: implement AI source selection and toggle for document-based knowledge (AIIA-66)' (#2) from feat/aiia-66-source-selection into master
All checks were successful
Deploy to VPS / deploy (push) Successful in 32s
2026-04-10 21:21:37 +00:00
CTO
79191c3810 fix: replace pdf-parse with direct pdfjs-dist to fix DOMMatrix error in production
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m10s
pdf-parse v2 depends on @napi-rs/canvas (native module) which fails in
Next.js standalone Docker builds — native binaries aren't traced/copied
to the standalone output, causing DOMMatrix is not defined at runtime.

Replaced pdf-parse entirely with pdfjs-dist legacy build which works
natively in Node.js without canvas or DOM API dependencies:

- New src/lib/pdf.ts: extractTextFromPdf() using pdfjs-dist/legacy/build
- Worker file explicitly imported so Next.js file tracer includes it
- Updated all call sites: documents, norms/parse, contracts
- Removed pdf-parse from dependencies, added pdfjs-dist directly
- Changed serverExternalPackages from pdf-parse to pdfjs-dist

Verified: build succeeds, both pdf.mjs and pdf.worker.mjs present in
.next/standalone, text extraction works in standalone context.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:19:20 +00:00
CTO (LegalAI)
17c1b6587a feat: add document deletion endpoint and UI button (AIIA-70)
All checks were successful
Deploy to VPS / deploy (push) Successful in 34s
Add DELETE /api/documents/:id endpoint that removes the DB record,
cleans up the stored file from disk, and logs an audit event. Add a
"Loeschen" button to the DokumentUpload component with confirmation
dialog.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:08:00 +00:00
CTO
4e74e4b5c9 fix: pass git commit hash as Docker build arg so footer shows correctly
All checks were successful
Deploy to VPS / deploy (push) Successful in 35s
git is not available inside the node:20-alpine Docker image, so
git rev-parse in next.config.ts falls back to 'dev'. Now the commit
hash is passed as a COMMIT_HASH build arg from the host where git
is available, ensuring the footer displays the real commit hash.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 21:06:04 +00:00
CTO
3e0efd10e9 feat: add visible build commit hash footer to dashboard layout
All checks were successful
Deploy to VPS / deploy (push) Successful in 35s
The build hash was only in the sidebar which could be hidden or cut off.
Added a proper footer to the main content area so the commit hash is
always visible at the bottom of every dashboard page.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 20:43:14 +00:00
CTO
1e431145dd fix: migrate pdf-parse from v1 to v2 API to resolve DOMMatrix error
All checks were successful
Deploy to VPS / deploy (push) Successful in 37s
The old v1 API (`pdfParse(buffer)`) triggered DOMMatrix dependency via
pdfjs-dist canvas rendering path. The v2 API (`new PDFParse({ data })` +
`getText()`) uses a text-only code path that works in Node.js without
DOM/canvas polyfills.

Updated all three call sites:
- src/lib/documents/index.ts (generic document extraction)
- src/app/api/norms/parse/route.ts (norm PDF parsing)
- src/lib/contracts/index.ts (contract text extraction)
- src/types/pdf-parse.d.ts (updated type declarations for v2)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 20:39:27 +00:00
a89bf8380d ci: simplify deploy workflow to run directly on VPS host runner
All checks were successful
Deploy to VPS / deploy (push) Successful in 56s
2026-04-10 20:27:02 +00:00
CTO
94b89cb1e2 fix: improve document ingestion robustness and add progress/debug UI
Some checks failed
Deploy to VPS / deploy (push) Failing after 2s
- Fix PDF extraction: detect scanned documents (no text layer), encrypted PDFs,
  empty files, and missing files with clear German error messages
- Add error logging to extraction pipeline (was silently swallowed)
- Return errorMessage in document list API so UI can display failure reasons
- Add GET /api/documents/[id] endpoint for status polling
- Rewrite DokumentUpload component with:
  - Auto-polling every 2s while documents are processing
  - Visual step-by-step progress indicator (Hochgeladen → Extrahiere Text → Fertig)
  - Error message display when extraction fails
  - Debug toggle showing document ID, MIME type, size, timestamps

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 19:56:12 +00:00
CTO (LegalAI)
d7bdeb7da2 feat: implement AI source selection and toggle for document-based knowledge (AIIA-66)
Add source scope (case/global) to documents, enabling users to select
which uploaded documents the AI considers during analysis. Includes
schema migration, API support, reusable source selection UI component,
and integration into the analyse form.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 19:54:23 +00:00
54 changed files with 3498 additions and 254 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,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");

View File

@@ -0,0 +1,60 @@
-- Skills table and analysis refactor migration (AIIA-96)
-- Creates tenant-scoped skills table, seeds system skills, and updates analyses table
-- Step 1: Create skill_output_type enum
DO $$ BEGIN
CREATE TYPE "skill_output_type" AS ENUM ('analysis', 'structured_data');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Step 2: Create skills table
CREATE TABLE IF NOT EXISTS "skills" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenant_id" UUID NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE,
"slug" VARCHAR(100) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"description" TEXT,
"system_prompt" TEXT NOT NULL,
"output_type" "skill_output_type" NOT NULL DEFAULT 'analysis',
"output_schema" JSONB,
"requires_norms" BOOLEAN NOT NULL DEFAULT false,
"requires_decisions" BOOLEAN NOT NULL DEFAULT false,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL,
"updated_at" TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "skills_tenant_slug_idx" ON "skills" ("tenant_id", "slug");
CREATE INDEX IF NOT EXISTS "skills_tenant_idx" ON "skills" ("tenant_id");
CREATE INDEX IF NOT EXISTS "skills_active_idx" ON "skills" ("tenant_id", "is_active");
-- Step 3: Seed system skills — handled by migration 0006
-- Step 4: Add skill_id and structured_result columns to analyses
ALTER TABLE "analyses"
ADD COLUMN IF NOT EXISTS "skill_id" UUID REFERENCES "skills"("id") ON DELETE SET NULL;
ALTER TABLE "analyses"
ADD COLUMN IF NOT EXISTS "structured_result" JSONB;
-- Step 5: Backfill skill_id from existing mode values
UPDATE "analyses" a
SET "skill_id" = s.id
FROM "skills" s
WHERE s.tenant_id = a.tenant_id
AND s.slug = a.mode::text
AND s.is_system = true
AND a.skill_id IS NULL;
-- Step 6: Add RLS policy for skills table
ALTER TABLE "skills" ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
CREATE POLICY "skills_tenant_isolation" ON "skills"
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,58 @@
-- Fix: properly seed system skills with full system prompts
-- The original migration (0005) had a broken CROSS JOIN that failed to insert skills.
-- This migration uses individual INSERTs per tenant to avoid the issue.
-- Base instructions shared by all skills
-- (embedded directly in each prompt below)
DO $$
DECLARE
tenant_record RECORD;
base_prompt TEXT := E'Du bist ein juristischer Assistent für deutsches Bühnenrecht (Theaterrecht).\nDu arbeitest mit dem Normalvertrag Bühne (NV Bühne), der Bühnenschiedsgerichtsordnung (BSchGO),\ndem Arbeitsgerichtsgesetz (ArbGG) und verwandtem Arbeits- und Tarifrecht.\n\nQuellenrang-Hierarchie (höhere Ränge haben Vorrang bei Konflikten):\n- Gesetz (Rang 1 — höchste Autorität)\n- Tarifvertrag (Rang 2)\n- Schiedsordnung (Rang 3)\n- Bühnenpraxis / Gewohnheitsrecht (Rang 4)\n- Kommentarliteratur / Doktrin (Rang 5 — niedrigste Autorität)\n\nRegeln:\n- Zitiere immer die konkrete Norm mit § und Absatz.\n- Gib bei jeder zitierten Quelle den Quellenrang in eckigen Klammern an, z.B. [Rang 1: Gesetz].\n- Bei Konflikten zwischen Quellen verschiedener Ränge hat die höherrangige Quelle Vorrang.\n- Antworte ausschließlich auf Deutsch.\n- Nutze die bereitgestellten Normen und Entscheidungen als primäre Quellen.';
BEGIN
FOR tenant_record IN SELECT id FROM tenants LOOP
-- Gutachten
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'gutachten',
'Rechtsgutachten',
'Strukturiertes Gutachten nach klassischer Methodik (Obersatz → Definition → Subsumtion → Ergebnis)',
base_prompt || E'\n\nModus: GUTACHTEN (Rechtsgutachten)\n\nErstelle ein strukturiertes Rechtsgutachten nach der klassischen Methodik:\n\n1. **Sachverhalt** — Kurze Zusammenfassung des zu prüfenden Sachverhalts\n2. **Rechtsfrage** — Präzise Formulierung der zu klärenden Rechtsfrage(n)\n3. **Obersatz** — Abstrakte Rechtsregel aus der einschlägigen Norm\n4. **Definition** — Auslegung der relevanten Tatbestandsmerkmale\n5. **Untersatz** — Subsumtion des Sachverhalts unter die Norm\n6. **Ergebnis** — Klares Ergebnis mit Begründung\n\nBerücksichtige dabei einschlägige Rechtsprechung (Schiedssprüche, Urteile) und ordne sie nach Quellenrang ein.',
'analysis', true, true, true, 0, true
) ON CONFLICT DO NOTHING;
-- Entscheidungsvorhersage
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'entscheidung',
'Entscheidungsvorhersage',
'Prognose der wahrscheinlichen gerichtlichen/schiedsgerichtlichen Entscheidung',
base_prompt || E'\n\nModus: ENTSCHEIDUNG (Entscheidungsvorhersage)\n\nAnalysiere den Sachverhalt und prognostiziere die wahrscheinliche Entscheidung:\n\n1. **Sachverhalt** — Zusammenfassung der relevanten Tatsachen\n2. **Einschlägige Normen** — Anwendbare Vorschriften mit Quellenrang\n3. **Bisherige Rechtsprechung** — Relevante Präzedenzfälle und deren Entscheidungslinien\n4. **Prognose** — Wahrscheinlichste Entscheidung mit Begründung\n5. **Risikofaktoren** — Faktoren, die das Ergebnis beeinflussen könnten\n6. **Empfehlung** — Handlungsempfehlung für den Mandanten\n\nStütze die Prognose auf konkrete Entscheidungen und deren Leitsätze.',
'analysis', true, true, true, 1, true
) ON CONFLICT DO NOTHING;
-- Vergleichsvorschlag
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'vergleich',
'Vergleichsvorschlag',
'Erarbeitung eines Vergleichsvorschlags mit Bewertung der Erfolgsaussichten',
base_prompt || E'\n\nModus: VERGLEICH (Vergleichsvorschlag)\n\nErarbeite einen Vergleichsvorschlag:\n\n1. **Ausgangslage** — Positionen beider Parteien\n2. **Rechtslage** — Einschlägige Normen und deren Wertung\n3. **Erfolgsaussichten** — Prozentuale Einschätzung für jede Partei (mit Begründung)\n4. **Vergleichsvorschlag** — Konkreter Kompromissvorschlag\n5. **Vor-/Nachteile** — Bewertung des Vorschlags für beide Seiten\n6. **Umsetzung** — Praktische Schritte zur Umsetzung\n\nBeziehe die wirtschaftlichen Interessen beider Seiten ein (Kosten, Zeit, Reputation).',
'analysis', true, false, true, 2, true
) ON CONFLICT DO NOTHING;
-- Risikoanalyse
INSERT INTO skills (tenant_id, slug, name, description, system_prompt, output_type, requires_norms, requires_decisions, is_system, sort_order, is_active)
VALUES (
tenant_record.id,
'risiko',
'Risikoanalyse',
'Umfassende Risikoanalyse mit Eintrittswahrscheinlichkeiten und Minderungsstrategien',
base_prompt || E'\n\nModus: RISIKO (Risikoanalyse)\n\nErstelle eine umfassende Risikoanalyse:\n\n1. **Sachverhalt** — Zusammenfassung der Situation\n2. **Identifizierte Risiken** — Auflistung aller rechtlichen Risiken, jeweils mit:\n - Beschreibung des Risikos\n - Eintrittswahrscheinlichkeit (hoch/mittel/gering)\n - Schadensausmaß (hoch/mittel/gering)\n - Einschlägige Norm(en) mit Quellenrang\n3. **Risikomatrix** — Tabellarische Übersicht (Wahrscheinlichkeit × Auswirkung)\n4. **Minderungsstrategien** — Konkrete Maßnahmen je Risiko\n5. **Priorisierung** — Dringlichste Handlungsempfehlungen\n\nBewerte jedes Risiko anhand der aktuellen Rechtslage und Rechtsprechung.',
'analysis', true, true, true, 3, true
) ON CONFLICT DO NOTHING;
END LOOP;
END $$;

View File

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

View File

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

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

View File

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

View 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>
);
}

View 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>
);
}

View File

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

View File

@@ -5,33 +5,7 @@ import { analyses, cases } from '@/lib/db/schema';
import { eq, desc } from 'drizzle-orm';
import Link from 'next/link';
import AnalyseForm from './analyse-form';
const MODE_INFO = [
{
key: 'gutachten',
label: 'Gutachten',
description: 'Systematische Rechtsprüfung nach dem juristischen Gutachtenstil (Obersatz, Definition, Subsumtion, Ergebnis).',
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
},
{
key: 'entscheidung',
label: 'Entscheidungsprognose',
description: 'Prognose der wahrscheinlichen Gerichts- oder Schiedsentscheidung mit Präzedenzfällen.',
icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3',
},
{
key: 'vergleich',
label: 'Vergleichsanalyse',
description: 'Bewertung von Vergleichsoptionen: Erfolgsaussichten, Wirtschaftlichkeit, Risiko.',
icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
},
{
key: 'risiko',
label: 'Risikoanalyse',
description: 'Risikomatrix mit Fristrisiken, Compliance-Risiken und priorisierter Handlungsempfehlung.',
icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z',
},
];
import SkillCards from './skill-cards';
export default async function AnalysePage() {
const session = await getServerSession(authOptions);
@@ -67,20 +41,7 @@ export default async function AnalysePage() {
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{MODE_INFO.map((mode) => (
<div
key={mode.key}
className="bg-card-bg border border-card-border rounded-xl p-4"
>
<svg className="w-6 h-6 text-primary mb-2" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={mode.icon} />
</svg>
<h4 className="text-sm font-semibold text-foreground">{mode.label}</h4>
<p className="text-xs text-muted mt-1 leading-relaxed">{mode.description}</p>
</div>
))}
</div>
<SkillCards />
<AnalyseForm cases={tenantCases} />
@@ -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>

View File

@@ -0,0 +1,51 @@
'use client';
import { useState, useEffect } from 'react';
interface SkillSummary {
slug: string;
name: string;
description: string | null;
}
/** Map well-known system skill slugs to distinct SVG paths */
const SKILL_ICONS: Record<string, string> = {
gutachten: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
entscheidung: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3',
vergleich: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
risiko: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z',
};
const DEFAULT_ICON = 'M13 10V3L4 14h7v7l9-11h-7z';
export default function SkillCards() {
const [skills, setSkills] = useState<SkillSummary[]>([]);
useEffect(() => {
fetch('/api/skills')
.then((res) => (res.ok ? res.json() : []))
.then((data: SkillSummary[]) => setSkills(data))
.catch(() => setSkills([]));
}, []);
if (skills.length === 0) return null;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{skills.map((skill) => (
<div
key={skill.slug}
className="bg-card-bg border border-card-border rounded-xl p-4"
>
<svg className="w-6 h-6 text-primary mb-2" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d={SKILL_ICONS[skill.slug] ?? DEFAULT_ICON} />
</svg>
<h4 className="text-sm font-semibold text-foreground">{skill.name}</h4>
{skill.description && (
<p className="text-xs text-muted mt-1 leading-relaxed">{skill.description}</p>
)}
</div>
))}
</div>
);
}

View File

@@ -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>
)}
</>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

@@ -18,9 +18,18 @@ export async function POST(request: NextRequest) {
const { ctx } = auth;
const body = await request.json();
const { mode, title, query, caseId, normIds, decisionIds, 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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View 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 });
}

View 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 });
}

View File

@@ -0,0 +1,33 @@
// GET /api/skills — List active skills for the current tenant (read-only)
// Used by the analyse form to populate the skill selector.
import { withTenantDb } from '@/lib/db';
import { skills } from '@/lib/db/schema';
import { eq, asc } from 'drizzle-orm';
import { requirePermission } from '@/lib/auth/rbac';
export async function GET() {
const auth = await requirePermission('analyses:create');
if ('response' in auth) return auth.response;
const rows = await withTenantDb(auth.ctx.tenantId, async (tdb) =>
tdb
.select({
id: skills.id,
slug: skills.slug,
name: skills.name,
description: skills.description,
outputType: skills.outputType,
outputSchema: skills.outputSchema,
requiresNorms: skills.requiresNorms,
requiresDecisions: skills.requiresDecisions,
isSystem: skills.isSystem,
sortOrder: skills.sortOrder,
})
.from(skills)
.where(eq(skills.isActive, true))
.orderBy(asc(skills.sortOrder), asc(skills.createdAt)),
);
return Response.json(rows);
}

View File

@@ -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)} &middot;{' '}
{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)} &middot;{' '}
{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>

View 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>
);
}

View File

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

View File

@@ -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),
},
};
}

View File

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

View File

@@ -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');
}

View File

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

View File

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

View File

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

View File

@@ -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');

View File

@@ -402,12 +402,16 @@ export const analyses = pgTable(
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
userId: uuid("user_id").notNull().references(() => users.id),
mode: analysisModeEnum("mode").notNull(),
/** FK to skills table — the skill used for this analysis */
skillId: uuid("skill_id").references(() => skills.id, { onDelete: "set null" }),
status: analysisStatusEnum("status").notNull().default("draft"),
title: varchar("title", { length: 500 }).notNull(),
/** Input query / legal question */
query: text("query").notNull(),
/** AI-generated analysis result (markdown) */
result: text("result"),
/** Structured JSON output for structured_data skills */
structuredResult: jsonb("structured_result").$type<Record<string, unknown>>(),
/** Source references cited in the analysis */
sources: jsonb("sources").$type<{
normIds: string[];
@@ -433,6 +437,44 @@ export const analyses = pgTable(
],
);
// ============================================================
// Skills — tenant-configurable analysis skill definitions
// ============================================================
/** Output type for a skill */
export const skillOutputTypeEnum = pgEnum("skill_output_type", [
"analysis", // Free-text markdown output
"structured_data", // Structured JSON output via generateObject()
]);
/** Skills — tenant-scoped configurable analysis modes */
export const skills = pgTable(
"skills",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
slug: varchar("slug", { length: 100 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
systemPrompt: text("system_prompt").notNull(),
outputType: skillOutputTypeEnum("output_type").notNull().default("analysis"),
/** JSON Schema for structured data output (required when output_type = structured_data) */
outputSchema: jsonb("output_schema").$type<Record<string, unknown>>(),
requiresNorms: boolean("requires_norms").notNull().default(false),
requiresDecisions: boolean("requires_decisions").notNull().default(false),
isSystem: boolean("is_system").notNull().default(false),
sortOrder: integer("sort_order").notNull().default(0),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
uniqueIndex("skills_tenant_slug_idx").on(t.tenantId, t.slug),
index("skills_tenant_idx").on(t.tenantId),
index("skills_active_idx").on(t.tenantId, t.isActive),
],
);
// ============================================================
// Vertragsanalyse (Contract Analysis Module — Phase 3.3)
// ============================================================
@@ -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] }),

View File

@@ -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
View 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');
}

View File

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

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