feat: AcroForm-Fill via Claude CLI, Multi-Source, Kanagawa, Docker-Deploy

Komplettes Rework der AI-Studio-Vorlage zu einem produktiven Werkzeug fuer
deutsche AcroForm-Formulare (Reha-Antraege, Arzt-Befundberichte):

- Backend: Express spawnt headless Claude CLI ('claude -p --output-format json'
  via stdin-Pipe). Prompt enthaelt die Feldnamen als Ziel-Schema plus die
  Arbeitsregeln (Stichwortstil, feste Zeichen-Kaestchen ohne Leerzeichen,
  Vordrucke respektieren, keine geratenen Werte, nur medizinisch).
- PDF-Handling: pdfjs-dist statt pdf-lib — pdf-lib scheitert an verschluesselten
  Object-Streams in DRV-Formularen. annotationStorage + saveDocument, kein
  Flatten. Worker-Patch zur Laufzeit forciert Auto-Size und schwarze Schrift.
- Multi-Source-Upload: beliebig viele PDFs/Bilder + optional Freitext.
- Design: Kanagawa Design System (Preset aus ../kanagawa-design-system),
  Tailwind lokal gebaut statt CDN, Dark/Light-Toggle, Progress-Indicator.
- Deployment: Multi-Stage-Dockerfile, docker-compose in matrix_default-Netz,
  Claude-Credentials vom Host per Volume. PLAN.md + AGENTS.md (Alex-Schema).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kenearos 2026-04-20 22:48:32 +02:00
parent d6cab4aeb5
commit 3c669fb003
28 changed files with 6756 additions and 934 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
node_modules
dist
.git
.vscode
test-fixtures
*.log
.env*
.DS_Store
Thumbs.db

9
.gitignore vendored
View file

@ -85,4 +85,11 @@ Thumbs.db
# Dist folder
dist/
dist-ssr/
dist-ssr/
# Server temp uploads (falls lokal ausgelagert)
/tmp/
/uploads/
# Test-Fixtures (nicht committen — enthalten ggf. echte Formulare)
/test-fixtures/

200
AGENTS.md Normal file
View file

@ -0,0 +1,200 @@
# AGENTS.md - Rentenversicherer Repository Guidelines
## Overview
Rentenversicherer ist ein lokales Werkzeug zum halb-automatischen
Ausfüllen von AcroForm-PDFs. Browser-UI + lokaler Node-Server. Der
Server ruft die Claude Code CLI (`claude -p`) als Subprozess auf, um
Daten aus einem Quelldokument in die AcroForm-Feldnamen eines Ziel-PDFs
zu mappen. Das ausgefüllte PDF bleibt editierbar (kein Flatten).
## Run / Dev Commands
Requires: Node.js 20+, Claude Code CLI im PATH, gültiges Claude-Code-Login.
```bash
# Install
npm install
# Development — Vite (5173) + Server (3001) parallel
npm run dev
# Production build und starten
npm run build
npm start
# Typecheck
npx tsc --noEmit
```
## Lint / Test / Validation
Kein dediziertes Test-Framework. Validation:
```bash
# Typecheck (Client + Server, shared tsconfig)
npx tsc --noEmit
# Claude CLI verfügbar?
claude --version
# Smoke-Test (manuell):
# 1. npm run dev
# 2. http://localhost:5173
# 3. Beispiel-PDF hochladen (G2210-11_Aerztlicher_Befundbericht_Anforderung_WAG.pdf)
# 4. Quelldokument hochladen
# 5. Download
# 6. PDF in Acrobat öffnen → Feld klicken → editieren möglich?
```
## Repo Layout
```
Rentenversicherer/
├── App.tsx # React-Einstieg, State-Machine (IDLE/PROCESSING/REVIEW)
├── components/
│ ├── FileUpload.tsx # Drag & Drop + base64-Encoding
│ └── ReviewPanel.tsx # List-View für extrahierte Felder + PDF-Preview
├── services/
│ ├── api.ts # fetch('/api/process'), multipart/form-data
│ └── pdfService.ts # pdfjs-dist: Widgets lesen + saveDocument
├── server/
│ ├── index.ts # Express + multer, POST /api/process
│ └── claudeRunner.ts # spawn('claude', ['-p', ...]), JSON-Parsing
├── types.ts # ExtractedField, FormResponse, AppStatus, FileData
├── vite.config.ts
├── tsconfig.json
├── package.json
└── PLAN.md
```
## Code Style - TypeScript / React
**Komponenten:**
- Functional Components mit Hooks; keine Class-Components.
- Props-Interfaces am Anfang der Datei, Name endet auf `Props`.
- Dateinamen: `PascalCase.tsx` für Komponenten, `camelCase.ts` für Services.
**Imports:**
- React-Imports zuerst, dann 3rd-Party, dann relativ.
- Keine `*.tsx`-/`*.ts`-Extensions in Imports.
**Styling:**
- Tailwind ausschließlich. Keine CSS-Module, kein inline-style außer für
dynamisch berechnete Werte.
- Lucide-Icons (`lucide-react`) für alle Icons.
**Types:**
- Shared Types in `types.ts`. Keine Duplikate in Komponenten.
- `any` vermeiden. `unknown` + Type-Guards bevorzugen.
## Code Style - Server (Node/TypeScript)
- Module: ESM (`"type": "module"` in package.json).
- `tsx watch` für dev; kein ts-node, kein nodemon.
- Async/await überall, kein callback-style.
- Fehler aus dem Claude-Subprozess als 502 ans Frontend weiterreichen,
mit `message` im JSON-Body — niemals stdout/stderr roh leaken.
## Naming Conventions
- Komponenten: `PascalCase` (`ReviewPanel`, `FileUpload`).
- Services: `camelCase` (`pdfService`, `claudeRunner`, `api`).
- Types/Interfaces: `PascalCase` (`ExtractedField`, `FormResponse`).
- Enums: `PascalCase` Key, `UPPER_CASE` Values (`AppStatus.IDLE`).
- Routes: `kebab-case` (`/api/process`).
## Security Rules
1. Kein API-Key im Code und nicht in `.env.local`. Auth läuft über das
bestehende Claude-Code-Login.
2. Uploads nur in `os.tmpdir()`, pro Request eigenes Subverzeichnis,
Cleanup im `finally`-Block.
3. `multer` mit `limits.fileSize` (z.B. 20 MB) — keine unbegrenzten
Uploads.
4. `multer` mit Allowlist für MIME-Types (PDF, PNG, JPEG, WEBP).
5. Server lauscht nur auf `127.0.0.1`, nicht auf `0.0.0.0`.
6. Dateinamen aus dem Upload niemals als Pfad verwenden — immer
UUID/Counter.
## Architecture Rules (non-negotiable)
1. **Nie** flatten — bricht das Kernversprechen, dass das PDF
im Reader editierbar bleibt. pdfjs' `saveDocument()` flacht nicht,
das ist genau der richtige Modus.
2. Werte **immer** über `annotationStorage.setValue(widgetId, { value })`
setzen, nicht versuchen einzelne Annotation-Objekte zu mutieren.
3. Original-PDF-Bytes bleiben strukturell unverändert. Nur Feldwerte
werden gesetzt. Keine Seiten-Mutationen, keine neuen Objekte, keine
Annotations.
4. Wenn Ziel-PDF keine AcroForm-Felder hat: Hard-Fail mit klarer
User-Meldung. **Kein** Fallback auf Overlay/Koordinaten-Modus.
5. `claude`-Subprozess **immer** mit `--permission-mode bypassPermissions`,
sonst blockiert er auf Tool-Prompts.
6. Der Server gibt **nur** strukturiertes JSON an das Frontend weiter —
Claude-Stdout nie ungeparst durchreichen.
7. Temp-Verzeichnisse werden nach jedem Request gelöscht, auch im
Fehlerfall.
## Error Handling
- Client zeigt alle Fehler im roten Banner auf der Upload-Seite.
- Server-Errors: JSON `{ error: string, details?: string }`, HTTP-Status
4xx/5xx passend.
- Claude-CLI-Exitcode ≠ 0 oder Timeout → 502 + `message: "Claude CLI
failed"` + Exit-Code.
- Kein Retry auf Server-Seite. Bei Bedarf triggert der User den Request
erneut.
- Unhandled rejections im Server: `process.on('unhandledRejection', ...)`
loggen, nicht crashen.
## Claude CLI Interface
Der Aufruf aus `server/claudeRunner.ts`:
```bash
claude \
-p "<prompt>" \
--output-format json \
--permission-mode bypassPermissions
```
Prompt-Grobskizze (Deutsch, weil Formulare deutsch sind):
```
Du füllst ein deutsches Behördenformular aus.
TARGET-FORM: <tempdir>/form.pdf
SOURCE: <tempdir>/source.pdf
Die Feldnamen im TARGET-Formular sind:
- feld1 (PDFTextField)
- feld2 (PDFCheckBox)
...
Lies beide Dateien mit dem Read-Tool, extrahiere die Werte aus dem
SOURCE und gib NUR ein JSON-Objekt zurück im Format:
{
"summary": "...",
"fields": [
{ "key": "feldname", "label": "...", "value": "...",
"sourceContext": "...",
"validation": { "status": "VALID"|"WARNING"|"INVALID",
"message": "...", "suggestion": "..." } }
]
}
Deutsches Format:
- Datum: DD.MM.YYYY
- Zahlen: Komma als Dezimaltrenner
- Checkbox-Wert: "X" wenn angekreuzt, "" sonst
```
Das JSON landet als String im `result`-Feld des CLI-Output-Wrappers
(`--output-format json`). `claudeRunner.ts` zieht es raus und
`JSON.parse`t es.

290
App.tsx
View file

@ -1,234 +1,282 @@
import React, { useState, useEffect } from 'react';
import { AppStatus, FileData, FormResponse } from './types';
import React, { useEffect, useState } from 'react';
import { AppStatus, type FileData, type FormResponse } from './types';
import { FileUpload } from './components/FileUpload';
import { SourceInput } from './components/SourceInput';
import { ReviewPanel } from './components/ReviewPanel';
import { processDocuments } from './services/geminiService';
import { getPdfFields, PdfFieldInfo } from './services/pdfService';
import { Bot, Sparkles, ArrowRight, FileCheck2, ScanText, Loader2, AlertTriangle, FileText, Check } from 'lucide-react';
import { ThemeToggle } from './components/ThemeToggle';
import { ProcessingIndicator } from './components/ProcessingIndicator';
import { processDocuments } from './services/api';
import { getPdfFields, type PdfFieldInfo } from './services/pdfService';
import {
AlertTriangle,
ArrowRight,
Bot,
FileCheck2,
ScanText,
Sparkles,
} from 'lucide-react';
const App: React.FC = () => {
const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
const [formFile, setFormFile] = useState<FileData | null>(null);
const [sourceFile, setSourceFile] = useState<FileData | null>(null);
const [sourceFiles, setSourceFiles] = useState<FileData[]>([]);
const [sourceText, setSourceText] = useState('');
const [pdfFields, setPdfFields] = useState<PdfFieldInfo[]>([]);
const [pdfFieldsChecked, setPdfFieldsChecked] = useState(false);
const [responseData, setResponseData] = useState<FormResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const analyzePdf = async () => {
if (formFile && formFile.type === 'application/pdf') {
const fields = await getPdfFields(formFile.base64);
setPdfFields(fields);
console.log("Detected PDF fields:", fields);
} else {
const run = async () => {
if (!formFile) {
setPdfFields([]);
setPdfFieldsChecked(false);
return;
}
if (formFile.type !== 'application/pdf') {
setPdfFields([]);
setPdfFieldsChecked(true);
return;
}
const fields = await getPdfFields(formFile.base64);
setPdfFields(fields);
setPdfFieldsChecked(true);
};
analyzePdf();
run();
}, [formFile]);
const noAcroForm = !!formFile && pdfFieldsChecked && pdfFields.length === 0;
const hasAnySource = sourceFiles.length > 0 || sourceText.trim().length > 0;
const handleAnalyze = async () => {
if (!formFile || !sourceFile) return;
if (!formFile || !hasAnySource) return;
if (pdfFields.length === 0) {
setError(
'Das Ziel-PDF enthält keine AcroForm-Felder. Bitte ein Formular mit interaktiven Feldern hochladen.'
);
setStatus(AppStatus.ERROR);
return;
}
setStatus(AppStatus.PROCESSING);
setError(null);
try {
const data = await processDocuments(formFile, sourceFile, pdfFields);
const data = await processDocuments(
formFile,
sourceFiles,
sourceText,
pdfFields
);
setResponseData(data);
setStatus(AppStatus.REVIEW);
} catch (e: any) {
setError(e.message || "Something went wrong during analysis.");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg || 'Während der Analyse ist etwas schiefgelaufen.');
setStatus(AppStatus.ERROR);
}
};
const reset = () => {
// Cleanup blob URLs to prevent memory leaks
if (formFile?.previewUrl && formFile.previewUrl.startsWith('blob:')) {
if (formFile?.previewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(formFile.previewUrl);
}
if (sourceFile?.previewUrl && sourceFile.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(sourceFile.previewUrl);
for (const f of sourceFiles) {
if (f.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(f.previewUrl);
}
setStatus(AppStatus.IDLE);
setFormFile(null);
setSourceFile(null);
setSourceFiles([]);
setSourceText('');
setResponseData(null);
setError(null);
setPdfFields([]);
setPdfFieldsChecked(false);
};
if (status === AppStatus.REVIEW && responseData && formFile && sourceFile) {
if (status === AppStatus.REVIEW && responseData && formFile) {
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center">
<div className="min-h-screen bg-kng-bg">
<header className="bg-kng-bg-elevated border-b border-kng-border sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="bg-indigo-600 p-1.5 rounded-lg">
<Bot className="w-5 h-5 text-white" />
<div className="bg-kng-accent p-1.5 rounded-kng-md">
<Bot className="w-5 h-5 text-kng-bg" />
</div>
<span className="font-bold text-lg text-slate-900">AutoForm AI</span>
<span className="font-bold text-lg text-kng-text">
Rentenversicherer
</span>
</div>
<ThemeToggle />
</div>
</header>
<ReviewPanel
fields={responseData.fields}
<ReviewPanel
fields={responseData.fields}
summary={responseData.summary}
formFile={formFile}
sourceFile={sourceFile}
isFillablePdf={pdfFields.length > 0}
onReset={reset}
/>
</div>
);
}
const analyzeDisabled =
!formFile ||
!hasAnySource ||
!pdfFieldsChecked ||
pdfFields.length === 0 ||
status === AppStatus.PROCESSING;
return (
<div className="min-h-screen bg-slate-50 flex flex-col">
{/* Header */}
<header className="bg-white border-b border-slate-200">
<div className="min-h-screen bg-kng-bg flex flex-col">
<header className="bg-kng-bg-elevated border-b border-kng-border">
<div className="max-w-7xl mx-auto px-4 h-20 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-indigo-600 p-2 rounded-xl shadow-lg shadow-indigo-200">
<Bot className="w-6 h-6 text-white" />
<div className="bg-kng-accent p-2 rounded-kng-lg shadow-kng-md">
<Bot className="w-6 h-6 text-kng-bg" />
</div>
<div>
<h1 className="font-bold text-xl text-slate-900 tracking-tight">AutoForm AI</h1>
<p className="text-xs text-slate-500 font-medium">Intelligent Document Processing</p>
<h1 className="font-bold text-xl text-kng-text tracking-tight">
Rentenversicherer
</h1>
<p className="text-xs text-kng-text-muted font-medium">
AcroForm-PDFs halbautomatisch ausfüllen
</p>
</div>
</div>
<div className="hidden md:flex items-center space-x-6 text-sm font-medium text-slate-600">
<span className="flex items-center"><ScanText className="w-4 h-4 mr-2" />1. Scan</span>
<span className="flex items-center"><Sparkles className="w-4 h-4 mr-2" />2. Extract</span>
<span className="flex items-center"><FileCheck2 className="w-4 h-4 mr-2" />3. Review</span>
<div className="flex items-center gap-6">
<div className="hidden md:flex items-center space-x-6 text-sm font-medium text-kng-text-secondary">
<span className="flex items-center">
<ScanText className="w-4 h-4 mr-2" />
1. Scan
</span>
<span className="flex items-center">
<Sparkles className="w-4 h-4 mr-2" />
2. Extract
</span>
<span className="flex items-center">
<FileCheck2 className="w-4 h-4 mr-2" />
3. Review
</span>
</div>
<ThemeToggle />
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 max-w-5xl mx-auto w-full px-4 py-12 flex flex-col justify-center">
{status === AppStatus.IDLE || status === AppStatus.ERROR ? (
<>
<div className="text-center mb-12">
<h2 className="text-4xl font-extrabold text-slate-900 mb-4">
Fill Forms Automatically with AI
<h2 className="text-3xl font-extrabold text-kng-text mb-4">
PDF-Formular automatisch ausfüllen
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
Upload a blank PDF form and a source document.
We'll extract the data and fill the PDF fields for you.
<p className="text-lg text-kng-text-secondary max-w-2xl mx-auto">
Original-PDF (mit AcroForm-Feldern) und beliebig viele
Quelldokumente hochladen. Claude extrahiert die Daten, du
prüfst und lädst das ausgefüllte weiterhin editierbare
PDF runter.
</p>
</div>
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 overflow-hidden">
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 m-4">
<div className="bg-kng-bg-elevated rounded-kng-xl shadow-kng-lg border border-kng-border overflow-hidden">
{error && (
<div className="bg-kng-bg border-l-4 border-kng-error p-4 m-4 rounded-kng-md">
<div className="flex">
<div className="flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">
{error}
</p>
</div>
<AlertTriangle className="h-5 w-5 text-kng-error flex-shrink-0" />
<p className="text-sm text-kng-error ml-3">{error}</p>
</div>
</div>
)}
{noAcroForm && !error && (
<div className="bg-kng-bg border-l-4 border-kng-warning p-4 m-4 rounded-kng-md">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-kng-warning flex-shrink-0" />
<p className="text-sm text-kng-warning ml-3">
Das Ziel-PDF enthält keine AcroForm-Felder. Nur Formulare
mit interaktiven Feldern werden unterstützt.
</p>
</div>
</div>
)}
<div className="grid md:grid-cols-2 gap-8 p-8">
{/* Step 1: Blank Form */}
<div className="space-y-4">
<div className="flex items-center space-x-3 mb-4">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-600 border border-slate-300">1</div>
<div className="w-8 h-8 rounded-kng-full bg-kng-surface flex items-center justify-center font-bold text-kng-text-secondary border border-kng-border">
1
</div>
<div className="flex-1">
<h3 className="font-bold text-slate-900 text-lg">Target Form</h3>
<h3 className="font-bold text-kng-text text-lg">
Ziel-Formular
</h3>
{pdfFields.length > 0 && (
<span className="text-xs text-emerald-600 font-medium bg-emerald-50 px-2 py-0.5 rounded-full">
{pdfFields.length} fillable fields detected
<span className="text-xs text-kng-success font-medium bg-kng-surface px-2 py-0.5 rounded-kng-full">
{pdfFields.length} AcroForm-Felder erkannt
</span>
)}
</div>
</div>
<FileUpload
label="Fillable PDF Form"
description="The empty PDF you want to fill."
accept="application/pdf,image/*"
label="Ausfüllbares PDF"
description="Original-PDF mit AcroForm-Feldern."
accept="application/pdf"
onFileSelect={setFormFile}
selectedFile={formFile}
/>
</div>
{/* Step 2: Source Data */}
<div className="space-y-4">
<div className="flex items-center space-x-3 mb-4">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-600 border border-slate-300">2</div>
<h3 className="font-bold text-slate-900 text-lg">Source Document</h3>
<div className="flex items-center space-x-3 mb-4">
<div className="w-8 h-8 rounded-kng-full bg-kng-surface flex items-center justify-center font-bold text-kng-text-secondary border border-kng-border">
2
</div>
<div className="flex-1">
<h3 className="font-bold text-kng-text text-lg">
Quelldokumente
</h3>
{sourceFiles.length > 0 && (
<span className="text-xs text-kng-accent font-medium bg-kng-surface px-2 py-0.5 rounded-kng-full">
{sourceFiles.length}{' '}
{sourceFiles.length === 1 ? 'Datei' : 'Dateien'}
</span>
)}
</div>
</div>
<FileUpload
label="Source Data"
description="Scan, Letter, ID, etc."
accept="image/*,application/pdf"
onFileSelect={setSourceFile}
selectedFile={sourceFile}
<SourceInput
files={sourceFiles}
text={sourceText}
onFilesChange={setSourceFiles}
onTextChange={setSourceText}
/>
</div>
</div>
<div className="bg-slate-50 p-6 border-t border-slate-100 flex justify-end">
<div className="bg-kng-bg p-6 border-t border-kng-border flex justify-end">
<button
onClick={handleAnalyze}
disabled={!formFile || !sourceFile}
className={`
flex items-center px-6 py-3 rounded-xl font-bold text-white shadow-lg transition-all
${(!formFile || !sourceFile)
? 'bg-slate-300 cursor-not-allowed shadow-none'
: 'bg-indigo-600 hover:bg-indigo-700 hover:shadow-indigo-500/30 transform hover:-translate-y-0.5'
}
`}
disabled={analyzeDisabled}
className={`flex items-center px-6 py-3 rounded-kng-lg font-bold shadow-kng-md transition-all ${
analyzeDisabled
? 'bg-kng-surface text-kng-text-muted cursor-not-allowed shadow-none'
: 'bg-kng-accent text-kng-bg hover:brightness-110 transform hover:-translate-y-0.5'
}`}
>
<Sparkles className="w-5 h-5 mr-2" />
Analyze & Fill
Analysieren & Ausfüllen
<ArrowRight className="w-5 h-5 ml-2" />
</button>
</div>
</div>
</>
) : (
/* Processing State */
<div className="flex flex-col items-center justify-center py-20">
<div className="relative">
<div className="absolute inset-0 bg-indigo-500 blur-xl opacity-20 rounded-full animate-pulse"></div>
<div className="relative bg-white p-6 rounded-2xl shadow-xl border border-indigo-100">
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin" />
</div>
</div>
<h3 className="mt-8 text-2xl font-bold text-slate-900">Processing Documents...</h3>
<p className="mt-2 text-slate-500 max-w-md text-center">
AI is reading the source document and mapping data to your PDF form fields.
</p>
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-4 w-full max-w-2xl">
<div className="bg-white p-4 rounded-lg border border-slate-200 shadow-sm flex items-center space-x-3 opacity-50">
<ScanText className="w-5 h-5 text-indigo-600" />
<span className="text-sm font-medium">Parsing PDF</span>
</div>
<div className="bg-white p-4 rounded-lg border border-slate-200 shadow-sm flex items-center space-x-3 opacity-50 animate-pulse delay-75">
<FileText className="w-5 h-5 text-indigo-600" />
<span className="text-sm font-medium">Extracting Data</span>
</div>
<div className="bg-white p-4 rounded-lg border border-slate-200 shadow-sm flex items-center space-x-3 opacity-50 animate-pulse delay-150">
<Check className="w-5 h-5 text-indigo-600" />
<span className="text-sm font-medium">Filling Form</span>
</div>
</div>
</div>
<ProcessingIndicator />
)}
</main>
</div>
);
};
export default App;
export default App;

42
Dockerfile Normal file
View file

@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1.7
# Build-Context: Parent-Verzeichnis (enthält Rentenversicherer/ und
# kanagawa-design-system/). Die relativen CSS-Imports bleiben damit gültig.
# -------- BUILD --------
FROM node:20-alpine AS build
WORKDIR /workspace
COPY kanagawa-design-system ./kanagawa-design-system
WORKDIR /workspace/Rentenversicherer
COPY Rentenversicherer/package.json Rentenversicherer/package-lock.json ./
RUN npm ci
COPY Rentenversicherer ./
RUN npm run build
# -------- RUNTIME --------
FROM node:20-alpine AS runtime
WORKDIR /app
# node:20-alpine bringt schon den User `node` (UID 1000, GID 1000) mit —
# den nehmen wir direkt, damit der Host-Mount /home/openclaw/.claude
# (UID 1000) lesbar und beschreibbar ist.
RUN apk add --no-cache bash ca-certificates dumb-init \
&& npm install -g @anthropic-ai/claude-code@2.1.113 tsx@4.19.0 \
&& chown -R node:node /app
USER node:node
COPY --chown=node:node Rentenversicherer/package.json Rentenversicherer/package-lock.json ./
RUN npm ci
COPY --chown=node:node --from=build /workspace/Rentenversicherer/dist ./dist
COPY --chown=node:node Rentenversicherer/server ./server
COPY --chown=node:node Rentenversicherer/types.ts ./types.ts
COPY --chown=node:node Rentenversicherer/tsconfig.json ./tsconfig.json
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3011
EXPOSE 3011
ENTRYPOINT ["dumb-init", "--"]
CMD ["tsx", "server/index.ts"]

135
PLAN.md Normal file
View file

@ -0,0 +1,135 @@
# Rentenversicherer: AcroForm Auto-Fill
Purpose
- Browser-UI nimmt ein Original-PDF (mit AcroForm-Feldern) und ein
Quelldokument (Scan, Brief, Ausweis, o.ä.) entgegen.
- Ein lokales Node-Backend ruft die Claude Code CLI (`claude -p`) als
Subprozess auf, übergibt beide Dateien und die Liste der AcroForm-
Feldnamen. Claude liefert strukturiertes JSON mit `{feldname -> wert}`.
- User reviewt/korrigiert die Werte im Browser, lädt das ausgefüllte
PDF runter.
- Das heruntergeladene PDF bleibt AcroForm (keine Flattening-Operation).
Im PDF-Reader nachträglich manuell editierbar — als hätte ein Mensch
es ausgefüllt.
## Scope
- Frontend: React/Vite, Upload + Review-Panel + Live-Preview + Download.
- Backend: Minimaler Node-Server, eine Route `POST /api/process`.
Spawnt `claude -p --output-format json` mit Temp-Files und einem
Prompt, der die Ziel-Feldnamen enthält.
- PDF-Handling: `pdfjs-dist` client-side — Widgets sammeln, Werte via
`annotationStorage.setValue(id, { value })` setzen, `doc.saveDocument()`
schreibt ein PDF ohne Flatten. (pdf-lib wurde getestet, scheitert aber
an komprimierten Object-Streams in DRV-Formularen.)
- Lokaler Single-User-Betrieb (localhost).
## Out of scope
- Gemini, Anthropic SDK, Claude Agent SDK — nur die CLI als Subprozess.
- Visual-Overlay-Modus mit Koordinaten für gescannte Flat-PDFs.
- Form-Overlay-View (Drag & Drop) im Review-Panel.
- Authentifizierung, Multi-User, Persistenz, Datenbank.
- PDF-Flattening, Signaturen, Formular-Editor, Seiten-Modifikation.
- Cloud-Deployment.
## Architektur
```
Browser (Vite :5173)
│ multipart upload: form.pdf + source.pdf + fieldNames[]
Node Server (:3001, Express)
│ 1. schreibt beide PDFs in Temp-Verzeichnis
│ 2. spawnt:
│ claude -p --output-format json \
│ --permission-mode bypassPermissions \
│ "<Prompt mit Feldliste + Pfaden>"
│ 3. parst stdout → extrahiert JSON aus result-Feld
│ 4. löscht Temp-Verzeichnis
Browser bekommt { fields: [...], summary: "..." }
│ User reviewt/korrigiert
│ pdfjs-dist: annotationStorage.setValue + saveDocument
│ (kein flatten; AcroForm bleibt erhalten)
Download
```
## Repo-Layout
```
Rentenversicherer/
├── App.tsx # Upload → Process → Review Flow
├── components/
│ ├── FileUpload.tsx
│ └── ReviewPanel.tsx # nur List-View, Overlay-View entfernt
├── services/
│ ├── api.ts # Frontend-Client für /api/process
│ └── pdfService.ts # getPdfFields + createFilledPdf via pdfjs-dist
├── server/
│ ├── index.ts # Express: static + /api/process
│ └── claudeRunner.ts # spawn('claude', ['-p', ...]); JSON-Extraktion
├── types.ts
├── vite.config.ts # dev-proxy /api → :3001
├── package.json # dev-script: vite + server parallel
├── tsconfig.json # shared tsconfig für Client + Server
├── .gitignore
├── PLAN.md
├── AGENTS.md
└── README.md
```
## Workflow (User-Sicht)
1. Original-PDF hochladen. Client scannt AcroForm-Feldnamen
mit `getPdfFields()` und zeigt die Anzahl als Hinweis.
2. Quelldokument hochladen (PDF oder Bild).
3. "Analyze & Fill" klicken.
4. Browser sendet beide Dateien + Feldliste an `POST /api/process`.
5. Server schreibt Dateien ins Temp-Verzeichnis, baut Prompt, spawnt
Claude CLI, parst JSON, antwortet.
6. Review-Panel zeigt `{label, value, validation}`-Paare — User korrigiert,
markiert als verified.
7. "Download" → `createFilledPdf` setzt die Werte via pdfjs
`annotationStorage` und schreibt mit `doc.saveDocument()`.
**Kein** Flatten.
8. User öffnet das runtergeladene PDF → Felder sind gefüllt und
bleiben klick- und editierbar in Acrobat/Reader.
## Umsetzungsschritte
1. `package.json`: Server-Deps (`express`, `multer`, `tsx`,
`concurrently`, typings) ergänzen, Scripts aktualisieren.
2. `server/claudeRunner.ts`: `spawn` mit Windows-kompatiblem Aufruf,
stdout buffern, `--output-format json` Result-Wrapper entpacken,
inneres JSON parsen.
3. `server/index.ts`: Express-App, `multer` für Upload, Temp-Dir pro
Request, `finally`-Cleanup.
4. `vite.config.ts`: `server.proxy['/api']` nach `http://localhost:3001`.
5. `services/api.ts` als Frontend-Client; `services/geminiService.ts`
löschen.
6. `services/pdfService.ts`: auf `pdfjs-dist` umgestellt.
`getPdfFields` iteriert über Pages + Widget-Annotations,
`createFilledPdf` nutzt `annotationStorage` + `saveDocument`.
7. `components/ReviewPanel.tsx`: Form-Overlay-View entfernen, nur
List-View behalten, Koordinaten-Logik raus.
8. `App.tsx`: Fehlermeldung, falls Ziel-PDF keine AcroForm-Felder hat
(UI blockiert Upload dann).
9. Smoke-Test mit
`G2210-11_Aerztlicher_Befundbericht_Anforderung_WAG.pdf`:
`npm run dev`, ausfüllen, runterladen, in Acrobat öffnen,
Feld editieren können.
## Notes
- Claude CLI wird über dein bestehendes Login authentifiziert. Kein
API-Key im Code oder in Env-Dateien.
- `claude -p` braucht `--permission-mode bypassPermissions`, damit das
Read-Tool ohne User-Prompt auf die Temp-Files zugreifen darf.
- Temp-Verzeichnisse liegen unter `os.tmpdir()` und werden nach jedem
Request gelöscht. Kein persistenter Upload-Ordner.
- Windows-Pfad-Konvention: Server nutzt `path.join`, arbeitet also
plattformneutral. Der Claude-CLI-Aufruf läuft via `shell: true` unter
PowerShell (vgl. globale CLAUDE.md).

View file

@ -1,20 +1,58 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Rentenversicherer
# Run and deploy your AI Studio app
Halbautomatisches Ausfüllen von deutschen AcroForm-PDFs (Reha-Anträge,
ärztliche Befundberichte, etc.) mit der Claude Code CLI als Subprozess.
This contains everything you need to run your app locally.
- Original-PDF bleibt strukturell unverändert — Felder sind nach dem
Ausfüllen im PDF-Reader weiter editierbar (kein Flatten).
- Claude zieht die Werte aus beliebig vielen Quelldateien (PDF/Bild) und
optionalem freiem Text.
- Browser-UI im Kanagawa-Design-Schema, Review-Panel mit Live-Preview.
View your app in AI Studio: https://ai.studio/apps/drive/1eOJcZ5qjOqVKG1eSXvA6HRcCwqcgcuGO
## Voraussetzungen
## Run Locally
- Node.js 20+
- Claude Code CLI im `PATH`, gültiges Claude-Login
- Windows: Git Bash (`CLAUDE_CODE_GIT_BASH_PATH` wird auf dem
Scoop-Standard-Pfad gesetzt — falls du Git anders installiert hast,
in der Shell vorher setzen oder `server/claudeRunner.ts:GIT_BASH_FALLBACK`
anpassen)
**Prerequisites:** Node.js
## Start
```bash
npm install
npm run dev
```
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
- Browser-UI: <http://localhost:5173>
- Backend-Health: <http://127.0.0.1:3001/api/health>
## Workflow
1. **Ziel-Formular** (PDF mit AcroForm-Feldern) in die linke Spalte ziehen.
Die App zeigt an, wie viele Felder erkannt wurden.
2. **Quelldokumente** rechts hochladen — beliebig viele PDFs/Bilder, plus
optional ein Freitext-Feld.
3. **„Analysieren & Ausfüllen"** — kann 30120 Sekunden dauern, je nach
Umfang. Claude-CLI läuft headless im Backend.
4. **Review-Panel** — Werte prüfen, bei Bedarf korrigieren, mit Haken
bestätigen.
5. **„PDF runterladen"** — das Original-PDF mit gesetzten Feldern.
Im Acrobat/Reader können die Felder weiter bearbeitet werden.
## Arbeitsregeln für die Verarbeitung
Im Prompt an Claude fest eingebaut (siehe `server/claudeRunner.ts`):
- Stichwortstil, kein Gutachten
- Feste Zeichen-Kästchen (VSNR, IBAN, BIC, IK) ohne Leerzeichen
- Vordrucke respektieren (kein doppeltes "DE", kein "€")
- Nur medizinisch; Sozialbereich bleibt leer
- Keine geratenen Werte — bei Unsicherheit leer + WARNING
- PDF nie flatten
## Dokumentation
- [`PLAN.md`](./PLAN.md) — Zweck, Scope, Architektur
- [`AGENTS.md`](./AGENTS.md) — Commands, Code-Style, Architektur-Regeln

View file

@ -1,6 +1,6 @@
import React, { useRef, useState } from 'react';
import { Upload, FileText, CheckCircle, X, Image as ImageIcon } from 'lucide-react';
import { FileData } from '../types';
import { Upload, FileText, CheckCircle, X } from 'lucide-react';
import type { FileData } from '../types';
interface FileUploadProps {
label: string;
@ -15,26 +15,22 @@ export const FileUpload: React.FC<FileUploadProps> = ({
description,
accept,
onFileSelect,
selectedFile
selectedFile,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const processFile = (file: File) => {
// Create a robust Blob URL for previewing (works better than Base64 for PDFs)
const objectUrl = URL.createObjectURL(file);
const reader = new FileReader();
reader.onload = () => {
const base64String = reader.result as string;
// Remove data URL prefix for API usage
const base64Content = base64String.split(',')[1];
onFileSelect({
file,
previewUrl: objectUrl,
base64: base64Content,
type: file.type as any
type: file.type as FileData['type'],
});
};
reader.readAsDataURL(file);
@ -62,18 +58,24 @@ export const FileUpload: React.FC<FileUploadProps> = ({
return (
<div className="w-full">
<label className="block text-sm font-medium text-slate-700 mb-2">{label}</label>
<label className="block text-sm font-medium text-kng-text-secondary mb-2">
{label}
</label>
{!selectedFile ? (
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={`
relative border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200
${isDragging ? 'border-indigo-500 bg-indigo-50' : 'border-slate-300 hover:border-slate-400 hover:bg-slate-50'}
`}
className={`relative border-2 border-dashed rounded-kng-lg p-8 text-center cursor-pointer transition-all duration-200 ${
isDragging
? 'border-kng-accent bg-kng-surface'
: 'border-kng-border hover:border-kng-accent hover:bg-kng-surface'
}`}
>
<input
type="file"
@ -83,37 +85,43 @@ export const FileUpload: React.FC<FileUploadProps> = ({
onChange={handleChange}
/>
<div className="flex flex-col items-center justify-center space-y-3">
<div className="p-3 bg-white rounded-full shadow-sm">
<Upload className="w-6 h-6 text-indigo-600" />
<div className="p-3 bg-kng-bg-elevated rounded-kng-full shadow-kng-sm">
<Upload className="w-6 h-6 text-kng-accent" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900">Click to upload or drag and drop</p>
<p className="text-xs text-slate-500 mt-1">{description}</p>
<p className="text-sm font-semibold text-kng-text">
Klick oder Drag & Drop
</p>
<p className="text-xs text-kng-text-muted mt-1">{description}</p>
</div>
</div>
</div>
) : (
<div className="relative border border-indigo-100 bg-indigo-50/50 rounded-xl p-4 flex items-center space-x-4">
<div className="w-12 h-12 bg-white rounded-lg shadow-sm flex items-center justify-center flex-shrink-0 overflow-hidden">
<div className="relative border border-kng-border bg-kng-surface rounded-kng-lg p-4 flex items-center space-x-4">
<div className="w-12 h-12 bg-kng-bg-elevated rounded-kng-md shadow-kng-sm flex items-center justify-center flex-shrink-0 overflow-hidden">
{selectedFile.type === 'application/pdf' ? (
<FileText className="w-6 h-6 text-indigo-600" />
<FileText className="w-6 h-6 text-kng-accent" />
) : (
<img src={selectedFile.previewUrl!} alt="Preview" className="w-full h-full object-cover" />
<img
src={selectedFile.previewUrl!}
alt="Preview"
className="w-full h-full object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 truncate">
<p className="text-sm font-medium text-kng-text truncate">
{selectedFile.file.name}
</p>
<p className="text-xs text-slate-500">
<p className="text-xs text-kng-text-muted">
{(selectedFile.file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<div className="flex items-center space-x-2">
<CheckCircle className="w-5 h-5 text-emerald-500" />
<button
<CheckCircle className="w-5 h-5 text-kng-success" />
<button
onClick={clearFile}
className="p-1 hover:bg-white rounded-full transition-colors text-slate-400 hover:text-red-500"
className="p-1 hover:bg-kng-surface-hover rounded-kng-full transition-colors text-kng-text-muted hover:text-kng-error"
>
<X className="w-5 h-5" />
</button>

View file

@ -0,0 +1,80 @@
import React, { useEffect, useState } from 'react';
import { Check, FileText, Loader2, ScanText, Sparkles } from 'lucide-react';
const STAGES = [
{ icon: ScanText, label: 'PDF parsen' },
{ icon: FileText, label: 'Quelldateien lesen' },
{ icon: Sparkles, label: 'Daten extrahieren' },
{ icon: Check, label: 'Felder mappen' },
];
export const ProcessingIndicator: React.FC = () => {
const [elapsed, setElapsed] = useState(0);
const [activeStage, setActiveStage] = useState(0);
useEffect(() => {
const started = Date.now();
const id = setInterval(() => {
const s = Math.floor((Date.now() - started) / 1000);
setElapsed(s);
// Rotierende Stage alle 6 Sekunden — nur Deko, kein echter Progress.
setActiveStage(Math.min(Math.floor(s / 6), STAGES.length - 1));
}, 500);
return () => clearInterval(id);
}, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
const elapsedLabel =
minutes > 0
? `${minutes}:${seconds.toString().padStart(2, '0')} min`
: `${seconds}s`;
return (
<div className="flex flex-col items-center justify-center py-20">
<div className="relative">
<div className="absolute inset-0 bg-kng-accent blur-xl opacity-20 rounded-kng-full animate-pulse" />
<div className="relative bg-kng-bg-elevated p-6 rounded-kng-xl shadow-kng-lg border border-kng-border">
<Loader2 className="w-12 h-12 text-kng-accent animate-spin" />
</div>
</div>
<h3 className="mt-8 text-2xl font-bold text-kng-text">
Claude verarbeitet die Dokumente
</h3>
<p className="mt-2 text-kng-text-secondary max-w-md text-center">
Die Claude-CLI liest alle Quellen und mappt die Daten auf die
AcroForm-Feldnamen.
</p>
<p className="mt-1 text-sm text-kng-text-muted font-mono">
Läuft seit {elapsedLabel}
</p>
<div className="mt-8 grid grid-cols-1 md:grid-cols-4 gap-3 w-full max-w-3xl">
{STAGES.map((stage, idx) => {
const Icon = stage.icon;
const isActive = idx === activeStage;
const isDone = idx < activeStage;
return (
<div
key={stage.label}
className={`p-3 rounded-kng-md border flex items-center space-x-2 shadow-kng-sm transition-all duration-300 ${
isActive
? 'border-kng-accent bg-kng-surface text-kng-text'
: isDone
? 'border-kng-success/50 bg-kng-bg-elevated text-kng-success'
: 'border-kng-border bg-kng-bg-elevated text-kng-text-muted opacity-60'
}`}
>
<Icon
className={`w-4 h-4 flex-shrink-0 ${
isActive ? 'text-kng-accent animate-pulse' : ''
}`}
/>
<span className="text-xs font-medium truncate">{stage.label}</span>
</div>
);
})}
</div>
</div>
);
};

View file

@ -1,552 +1,354 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { ExtractedField, FileData } from '../types';
import { Check, Edit2, Download, RefreshCw, FileText, AlertTriangle, XCircle, ArrowRight, PenTool, CheckCircle2, Circle, LayoutTemplate, List, Move, ExternalLink } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import type { ExtractedField, FileData } from '../types';
import {
AlertTriangle,
ArrowRight,
Check,
Download,
ExternalLink,
FileText,
RefreshCw,
XCircle,
} from 'lucide-react';
import { createFilledPdf } from '../services/pdfService';
import { jsPDF } from "jspdf";
interface ReviewPanelProps {
fields: ExtractedField[];
formFile: FileData;
sourceFile: FileData;
summary: string;
isFillablePdf: boolean;
onReset: () => void;
}
export const ReviewPanel: React.FC<ReviewPanelProps> = ({
fields: initialFields,
formFile,
sourceFile,
type FilterMode = 'ALL' | 'ATTENTION';
export const ReviewPanel: React.FC<ReviewPanelProps> = ({
fields: initialFields,
formFile,
summary,
isFillablePdf,
onReset
onReset,
}) => {
const [fields, setFields] = useState(initialFields);
const [fields, setFields] = useState<ExtractedField[]>(initialFields);
const [activeField, setActiveField] = useState<number | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [filterMode, setFilterMode] = useState<'ALL' | 'ATTENTION'>('ALL');
const [viewMode, setViewMode] = useState<'LIST' | 'FORM'>('LIST');
const [filterMode, setFilterMode] = useState<FilterMode>('ALL');
// Dragging state
const [draggingField, setDraggingField] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Derived state for progress
const verifiedCount = fields.filter(f => f.isVerified).length;
const verifiedCount = fields.filter((f) => f.isVerified).length;
const totalCount = fields.length;
const progressPercent = Math.round((verifiedCount / totalCount) * 100);
const fieldsRequiresAttention = useMemo(() =>
fields.filter(f => f.validation?.status !== 'VALID'),
[fields]);
const progressPercent =
totalCount === 0 ? 0 : Math.round((verifiedCount / totalCount) * 100);
const fieldsRequiresAttention = useMemo(
() => fields.filter((f) => f.validation?.status !== 'VALID'),
[fields]
);
// Generate preview for PDF download
useEffect(() => {
let active = true;
let timeoutId: any;
const generatePreview = async () => {
// If it's a PDF, we try to show the filled version
if (formFile.type === 'application/pdf') {
try {
const filledPdfBytes = await createFilledPdf(formFile.base64, fields, isFillablePdf);
if (!active) return;
const blob = new Blob([filledPdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
setPreviewUrl(prev => {
if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
return url;
});
} catch (e) {
console.error("Failed to generate PDF preview", e);
// Fallback to original if generation fails
setPreviewUrl(formFile.previewUrl);
}
} else {
// For images, just show the original
setPreviewUrl(formFile.previewUrl);
const handle = setTimeout(async () => {
try {
const bytes = await createFilledPdf(formFile.base64, fields);
if (!active) return;
const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
setPreviewUrl((prev) => {
if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
return url;
});
} catch (e) {
console.error('[ReviewPanel] preview failed', e);
}
};
// Debounce to avoid excessive PDF generation while typing
timeoutId = setTimeout(generatePreview, 1000);
}, 600);
return () => {
active = false;
clearTimeout(timeoutId);
clearTimeout(handle);
};
}, [fields, isFillablePdf, formFile]);
}, [fields, formFile]);
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
setPreviewUrl(prev => {
if (prev && prev.startsWith('blob:') && prev !== formFile.previewUrl) {
URL.revokeObjectURL(prev);
}
setPreviewUrl((prev) => {
if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
return null;
});
};
}, []);
const handleUpdate = (index: number, newValue: string) => {
const newFields = [...fields];
newFields[index] = {
...newFields[index],
value: newValue,
// Auto-verify when manually edited
isVerified: true,
validation: { ...newFields[index].validation!, status: 'VALID', message: 'Manually verified' }
};
setFields(newFields);
};
const handleCoordinateUpdate = (index: number, x: number, y: number) => {
const newFields = [...fields];
if (newFields[index].coordinates) {
newFields[index] = {
...newFields[index],
coordinates: { ...newFields[index].coordinates!, x, y },
isVerified: true // Moving it implies verification
setFields((prev) => {
const next = [...prev];
next[index] = {
...next[index],
value: newValue,
isVerified: true,
validation: {
...(next[index].validation ?? { status: 'VALID' }),
status: 'VALID',
message: 'Manuell bestätigt',
},
};
setFields(newFields);
}
return next;
});
};
const toggleVerify = (index: number) => {
const newFields = [...fields];
newFields[index] = {
...newFields[index],
isVerified: !newFields[index].isVerified
};
setFields(newFields);
setFields((prev) => {
const next = [...prev];
next[index] = { ...next[index], isVerified: !next[index].isVerified };
return next;
});
};
const applySuggestion = (index: number) => {
const field = fields[index];
if (field.validation?.suggestion) {
handleUpdate(index, field.validation.suggestion);
}
const suggestion = fields[index].validation?.suggestion;
if (suggestion) handleUpdate(index, suggestion);
};
const handleDownload = async () => {
if (formFile.type === 'application/pdf' && previewUrl) {
const a = document.createElement('a');
a.href = previewUrl;
a.download = `filled_${formFile.file.name}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} else {
const doc = new jsPDF();
doc.text("Extracted Data", 20, 20);
let y = 40;
fields.forEach(f => {
doc.text(`${f.label}: ${f.value}`, 20, y);
y += 10;
});
doc.save("data_report.pdf");
}
const bytes = await createFilledPdf(formFile.base64, fields);
const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `filled_${formFile.file.name}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
};
// Drag handlers
const handleDragStart = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
setDraggingField(index);
setActiveField(index);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (draggingField !== null && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const relativeX = e.clientX - rect.left;
const relativeY = e.clientY - rect.top;
// Convert pixels to 0-1000 scale
const scaleX = (relativeX / rect.width) * 1000;
const scaleY = (relativeY / rect.height) * 1000;
// Clamp values
const clampedX = Math.max(0, Math.min(1000, scaleX));
const clampedY = Math.max(0, Math.min(1000, scaleY));
handleCoordinateUpdate(draggingField, clampedX, clampedY);
}
};
const handleMouseUp = () => {
setDraggingField(null);
};
// Sort: Unverified/Issues first, then verified
const displayedFields = fields.map((f, i) => ({ ...f, originalIndex: i }))
const displayedFields = fields
.map((f, i) => ({ ...f, originalIndex: i }))
.sort((a, b) => {
// Priority 1: Attention needed
const aNeedsAttn = a.validation?.status !== 'VALID';
const bNeedsAttn = b.validation?.status !== 'VALID';
if (aNeedsAttn && !bNeedsAttn) return -1;
if (!aNeedsAttn && bNeedsAttn) return 1;
// Priority 2: Unverified
const aAttn = a.validation?.status !== 'VALID';
const bAttn = b.validation?.status !== 'VALID';
if (aAttn && !bAttn) return -1;
if (!aAttn && bAttn) return 1;
if (!a.isVerified && b.isVerified) return -1;
if (a.isVerified && !b.isVerified) return 1;
return a.originalIndex - b.originalIndex;
})
.filter(f => filterMode === 'ALL' || (f.validation?.status !== 'VALID' && !f.isVerified));
.filter(
(f) =>
filterMode === 'ALL' ||
(f.validation?.status !== 'VALID' && !f.isVerified)
);
return (
<div className="max-w-7xl mx-auto px-4 py-8 h-[calc(100vh-80px)] flex flex-col">
{/* Header Section */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4 flex-shrink-0">
<div>
<h2 className="text-2xl font-bold text-slate-900">Review & Verify</h2>
<p className="text-slate-500 text-sm mt-1">{summary}</p>
<h2 className="text-2xl font-bold text-kng-text">Review & Verify</h2>
<p className="text-kng-text-muted text-sm mt-1">{summary}</p>
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
{/* View Toggle */}
<div className="flex bg-slate-100 p-1 rounded-lg border border-slate-200">
<button
onClick={() => setViewMode('LIST')}
className={`p-1.5 rounded-md flex items-center space-x-2 text-sm font-medium transition-all ${viewMode === 'LIST' ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-700'}`}
title="List View"
>
<List className="w-4 h-4" />
<span className="hidden sm:inline">List</span>
</button>
<button
onClick={() => setViewMode('FORM')}
className={`p-1.5 rounded-md flex items-center space-x-2 text-sm font-medium transition-all ${viewMode === 'FORM' ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-700'}`}
title="Form Overlay View"
>
<LayoutTemplate className="w-4 h-4" />
<span className="hidden sm:inline">Form View</span>
</button>
</div>
<div className="h-6 w-px bg-slate-300 mx-2 hidden md:block"></div>
{/* Verification Progress */}
<div className="hidden md:flex flex-col items-end mr-2">
<span className="text-xs font-semibold text-slate-600 mb-1">
{verifiedCount} / {totalCount} Verified
<span className="text-xs font-semibold text-kng-text-secondary mb-1">
{verifiedCount} / {totalCount} verifiziert
</span>
<div className="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 transition-all duration-500 ease-out"
<div className="w-32 h-2 bg-kng-surface rounded-kng-full overflow-hidden">
<div
className="h-full bg-kng-success transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
<button
<button
onClick={onReset}
className="flex items-center px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
className="flex items-center px-4 py-2 text-sm font-medium text-kng-text bg-kng-surface border border-kng-border rounded-kng-md hover:bg-kng-surface-hover transition-colors"
>
<RefreshCw className="w-4 h-4 mr-2" />
Start Over
Neu starten
</button>
<button
<button
onClick={handleDownload}
className={`flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors shadow-sm bg-indigo-600 hover:bg-indigo-700`}
className="flex items-center px-4 py-2 text-sm font-bold text-kng-bg rounded-kng-md transition-all shadow-kng-md bg-kng-accent hover:brightness-110"
>
<Download className="w-4 h-4 mr-2" />
Download PDF
PDF runterladen
</button>
</div>
</div>
{viewMode === 'LIST' ? (
/* ================= LIST VIEW ================= */
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
{/* Left Column: Preview */}
<div className="bg-slate-900 rounded-xl overflow-hidden shadow-lg flex flex-col">
<div className="p-3 bg-slate-800 border-b border-slate-700 flex justify-between items-center">
<div className="flex items-center space-x-2">
<span className="text-xs font-medium text-slate-300 uppercase tracking-wider">PDF Preview (Live)</span>
</div>
<span className="text-xs text-slate-400">{formFile.file.name}</span>
</div>
<div className="flex-1 bg-slate-900 relative">
{previewUrl ? (
formFile.type === 'application/pdf' ? (
<>
<iframe
src={previewUrl}
title="Form PDF Preview"
className="w-full h-full border-none"
/>
{/* Fallback open button */}
<div className="absolute bottom-4 right-4 opacity-50 hover:opacity-100 transition-opacity">
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className="flex items-center bg-black/70 text-white text-xs px-3 py-1.5 rounded-full hover:bg-black"
>
<ExternalLink className="w-3 h-3 mr-1" />
Open in new tab
</a>
</div>
</>
) : (
<div className="w-full h-full overflow-auto flex items-center justify-center p-4">
<img
src={previewUrl}
alt="Form Document"
className="max-w-full shadow-lg border border-slate-700"
/>
</div>
)
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-500">
<FileText className="w-16 h-16 mb-4 opacity-50" />
<p>Generating preview...</p>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
{/* Preview */}
<div className="bg-kng-bg-elevated rounded-kng-xl overflow-hidden shadow-kng-lg flex flex-col border border-kng-border">
<div className="p-3 bg-kng-surface border-b border-kng-border flex justify-between items-center">
<span className="text-xs font-medium text-kng-text-secondary uppercase tracking-wider">
PDF Preview (live)
</span>
<span className="text-xs text-kng-text-muted">
{formFile.file.name}
</span>
</div>
{/* Right Column: Verification List */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden">
<div className="p-4 border-b border-slate-100 bg-slate-50/50">
<div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-slate-800">Field Verification</h3>
<div className="flex space-x-2 text-xs">
<button
onClick={() => setFilterMode('ALL')}
className={`px-3 py-1 rounded-full border transition-colors ${filterMode === 'ALL' ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'}`}
<div className="flex-1 bg-kng-bg relative">
{previewUrl ? (
<>
<iframe
src={previewUrl}
title="Form PDF Preview"
className="w-full h-full border-none bg-white"
/>
<div className="absolute bottom-4 right-4 opacity-60 hover:opacity-100 transition-opacity">
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className="flex items-center bg-kng-bg/80 text-kng-text text-xs px-3 py-1.5 rounded-kng-full hover:bg-kng-bg border border-kng-border"
>
All ({totalCount})
</button>
{fieldsRequiresAttention.length > 0 && (
<button
onClick={() => setFilterMode('ATTENTION')}
className={`px-3 py-1 rounded-full border transition-colors flex items-center ${filterMode === 'ATTENTION' ? 'bg-amber-100 text-amber-800 border-amber-200' : 'bg-white text-amber-600 border-slate-200 hover:bg-amber-50'}`}
>
<AlertTriangle className="w-3 h-3 mr-1" />
Needs Review ({fieldsRequiresAttention.length})
</button>
)}
<ExternalLink className="w-3 h-3 mr-1" />
In neuem Tab öffnen
</a>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-kng-text-muted">
<FileText className="w-16 h-16 mb-4 opacity-50" />
<p>Preview wird erstellt </p>
</div>
)}
</div>
</div>
{/* Field List */}
<div className="bg-kng-bg-elevated rounded-kng-xl shadow-kng-md border border-kng-border flex flex-col overflow-hidden">
<div className="p-4 border-b border-kng-border bg-kng-surface">
<div className="flex justify-between items-center">
<h3 className="font-semibold text-kng-text">Feld-Verifikation</h3>
<div className="flex space-x-2 text-xs">
<button
onClick={() => setFilterMode('ALL')}
className={`px-3 py-1 rounded-kng-full border transition-colors ${
filterMode === 'ALL'
? 'bg-kng-accent text-kng-bg border-kng-accent'
: 'bg-kng-bg-elevated text-kng-text-secondary border-kng-border hover:bg-kng-surface-hover'
}`}
>
Alle ({totalCount})
</button>
{fieldsRequiresAttention.length > 0 && (
<button
onClick={() => setFilterMode('ATTENTION')}
className={`px-3 py-1 rounded-kng-full border transition-colors flex items-center ${
filterMode === 'ATTENTION'
? 'bg-kng-warning text-kng-bg border-kng-warning'
: 'bg-kng-bg-elevated text-kng-warning border-kng-border hover:bg-kng-surface-hover'
}`}
>
<AlertTriangle className="w-3 h-3 mr-1" />
Prüfen ({fieldsRequiresAttention.length})
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50/30">
{displayedFields.map((field) => {
const idx = field.originalIndex;
const status = field.validation?.status || 'VALID';
const isVerified = field.isVerified;
let statusBorder = isVerified ? "border-emerald-200" : "border-slate-200";
let statusBg = isVerified ? "bg-emerald-50/30" : "bg-white";
</div>
if (!isVerified) {
if (status === 'INVALID') {
statusBorder = "border-red-200";
statusBg = "bg-red-50/50";
} else if (status === 'WARNING') {
statusBorder = "border-amber-200";
statusBg = "bg-amber-50/50";
}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-kng-bg">
{displayedFields.map((field) => {
const idx = field.originalIndex;
const status = field.validation?.status ?? 'VALID';
const isVerified = !!field.isVerified;
let statusBorder = isVerified
? 'border-kng-success'
: 'border-kng-border';
let statusBg = 'bg-kng-bg-elevated';
if (!isVerified) {
if (status === 'INVALID') {
statusBorder = 'border-kng-error';
} else if (status === 'WARNING') {
statusBorder = 'border-kng-warning';
}
}
return (
<div
key={idx}
className={`relative group rounded-lg border transition-all duration-200 p-3 shadow-sm ${activeField === idx ? 'ring-1 ring-indigo-500 border-indigo-500 shadow-md z-10' : statusBorder} ${statusBg}`}
onFocus={() => setActiveField(idx)}
onBlur={() => setActiveField(null)}
>
<div className="flex items-start gap-3">
<button
onClick={() => toggleVerify(idx)}
className={`mt-1 flex-shrink-0 w-5 h-5 rounded border flex items-center justify-center transition-colors ${isVerified ? 'bg-emerald-500 border-emerald-500 text-white' : 'bg-white border-slate-300 text-transparent hover:border-emerald-400'}`}
title={isVerified ? "Mark as unverified" : "Mark as verified"}
>
<Check className="w-3.5 h-3.5" strokeWidth={3} />
</button>
return (
<div
key={idx}
className={`relative group rounded-kng-md border transition-all duration-200 p-3 shadow-kng-sm ${
activeField === idx
? 'ring-1 ring-kng-accent border-kng-accent shadow-kng-md z-10'
: statusBorder
} ${statusBg}`}
onFocus={() => setActiveField(idx)}
onBlur={() => setActiveField(null)}
>
<div className="flex items-start gap-3">
<button
onClick={() => toggleVerify(idx)}
className={`mt-1 flex-shrink-0 w-5 h-5 rounded-kng-sm border flex items-center justify-center transition-colors ${
isVerified
? 'bg-kng-success border-kng-success text-kng-bg'
: 'bg-kng-bg border-kng-border text-transparent hover:border-kng-success'
}`}
title={
isVerified
? 'Als unbestätigt markieren'
: 'Als bestätigt markieren'
}
>
<Check className="w-3.5 h-3.5" strokeWidth={3} />
</button>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<label className={`text-xs font-semibold uppercase tracking-wider truncate ${isVerified ? 'text-emerald-700' : 'text-slate-600'}`}>
{field.label || field.key || "Unknown Field"}
</label>
{!isVerified && status !== 'VALID' && (
<span className={`flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded ${status === 'INVALID' ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'}`}>
{status === 'INVALID' ? <XCircle className="w-3 h-3 mr-1"/> : <AlertTriangle className="w-3 h-3 mr-1"/>}
{status}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<label
className={`text-xs font-semibold uppercase tracking-wider truncate ${
isVerified
? 'text-kng-success'
: 'text-kng-text-secondary'
}`}
>
{field.label || field.key || 'Unbekanntes Feld'}
</label>
{!isVerified && status !== 'VALID' && (
<span
className={`flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded-kng-sm ${
status === 'INVALID'
? 'bg-kng-error text-kng-bg'
: 'bg-kng-warning text-kng-bg'
}`}
>
{status === 'INVALID' ? (
<XCircle className="w-3 h-3 mr-1" />
) : (
<AlertTriangle className="w-3 h-3 mr-1" />
)}
{status}
</span>
)}
</div>
<div className="relative">
<input
type="text"
value={field.value}
onChange={(e) => handleUpdate(idx, e.target.value)}
className={`block w-full rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors border ${isVerified ? 'border-emerald-200 text-emerald-900 bg-emerald-50/50' : 'border-slate-300 text-slate-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500'}`}
placeholder="Empty"
/>
</div>
{(!isVerified && field.validation?.suggestion && status !== 'VALID') && (
<button
<input
type="text"
value={field.value}
onChange={(e) => handleUpdate(idx, e.target.value)}
className="block w-full rounded-kng-sm px-2.5 py-1.5 text-sm font-medium transition-colors border border-kng-border bg-kng-surface text-kng-text placeholder-kng-text-muted focus:border-kng-accent focus:outline-none focus:ring-1 focus:ring-kng-accent"
placeholder="leer"
/>
{!isVerified &&
field.validation?.suggestion &&
status !== 'VALID' && (
<button
onClick={() => applySuggestion(idx)}
className="mt-2 flex items-center text-xs font-bold text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded transition-colors"
className="mt-2 flex items-center text-xs font-bold text-kng-accent hover:brightness-110 bg-kng-surface hover:bg-kng-surface-hover px-2 py-1 rounded-kng-sm transition-colors border border-kng-border"
>
<ArrowRight className="w-3 h-3 mr-1" />
Accept: "{field.validation.suggestion}"
Übernehmen: "{field.validation.suggestion}"
</button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
) : (
/* ================= FORM VIEW (Overlay with Drag & Drop) ================= */
<div className="bg-slate-200 rounded-xl overflow-auto shadow-inner flex-1 border border-slate-300 relative p-8 flex justify-center">
<div
ref={containerRef}
className="relative inline-block shadow-2xl transition-cursor bg-white"
style={{
cursor: draggingField !== null ? 'grabbing' : 'default'
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Background Image/PDF */}
{formFile.type === 'application/pdf' ? (
<div className="flex flex-col items-center justify-center p-20 bg-slate-50 border border-slate-200 text-center" style={{ width: '794px', height: '1123px' }}>
<FileText className="w-20 h-20 text-slate-300 mb-4" />
<h3 className="text-xl font-bold text-slate-800">Visual Editing Unavailable for PDF</h3>
<p className="text-slate-500 max-w-sm mt-2">
The visual drag-and-drop editor works best with Image uploads.
For this PDF, please use the List View to verify data or Download to see the final result.
</p>
<button
onClick={() => setViewMode('LIST')}
className="mt-6 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Switch to List View
</button>
</div>
) : (
<img
src={formFile.previewUrl!}
className="block max-w-full h-auto pointer-events-none opacity-90 select-none"
style={{ maxHeight: '1200px' }}
alt="Form Background"
/>
)}
{/* Fallback info if not visual mode compatible */}
{!fields.some(f => f.coordinates) && formFile.type !== 'application/pdf' && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 z-20 backdrop-blur-sm">
<div className="text-center p-6 max-w-md">
<AlertTriangle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
<h3 className="text-lg font-bold text-slate-900">Visual Mode Not Available</h3>
<p className="text-slate-600 mt-2">
Coordinates were not extracted for this document. Please use the List View to edit fields.
</p>
<button
onClick={() => setViewMode('LIST')}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Switch to List View
</button>
</div>
</div>
)}
{/* Overlay Inputs (Only show if not PDF, or if we force it but it's blank bg) */}
{formFile.type !== 'application/pdf' && fields.map((field, idx) => {
if (!field.coordinates) return null;
// Coordinate conversion (0-1000 scale to percentage)
const left = (field.coordinates.x / 1000) * 100;
const top = (field.coordinates.y / 1000) * 100;
const status = field.validation?.status || 'VALID';
let borderColor = 'border-indigo-400 bg-white/60';
if (field.isVerified) borderColor = 'border-emerald-500 bg-emerald-50/70';
else if (status === 'INVALID') borderColor = 'border-red-500 bg-red-50/70';
else if (status === 'WARNING') borderColor = 'border-amber-500 bg-amber-50/70';
const isDragging = draggingField === idx;
const isCheckbox = field.value === 'X';
return (
<div
key={idx}
className={`absolute group hover:z-50 ${isDragging ? 'z-50 cursor-grabbing' : 'z-10'}`}
style={{
left: `${left}%`,
top: `${top}%`,
width: isCheckbox ? '20px' : '200px',
transform: 'translateY(-50%)', // Center vertically
}}
onMouseDown={(e) => handleDragStart(e, idx)}
>
<div className="relative">
{/* Drag Handle (Visible on Hover) */}
<div className={`absolute -top-3 -left-3 cursor-grab p-1 bg-slate-800 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity ${isDragging ? 'opacity-100' : ''}`}>
<Move className="w-3 h-3" />
</div>
{isCheckbox ? (
<div className={`w-6 h-6 border-2 flex items-center justify-center font-bold text-black ${borderColor}`}>
X
</div>
) : (
<input
type="text"
value={field.value}
onChange={(e) => handleUpdate(idx, e.target.value)}
onFocus={() => setActiveField(idx)}
className={`
w-full px-1 py-0.5 text-xs font-medium border-2 rounded transition-all shadow-sm
focus:ring-2 focus:ring-offset-1 focus:z-10 focus:bg-white
${borderColor}
`}
style={{
fontFamily: 'Courier, monospace',
color: 'black',
background: 'rgba(255, 255, 255, 0.7)'
}}
/>
)}
{/* Validation Icon Overlay */}
{!field.isVerified && status !== 'VALID' && (
<div className="absolute -top-2 -right-2 bg-white rounded-full shadow-md z-20 pointer-events-none">
{status === 'INVALID'
? <XCircle className="w-4 h-4 text-red-500" />
: <AlertTriangle className="w-4 h-4 text-amber-500" />
}
</div>
)}
{/* Tooltip on Hover */}
<div className="absolute opacity-0 group-hover:opacity-100 bottom-full left-0 mb-1 bg-slate-800 text-white text-[10px] px-2 py-1 rounded whitespace-nowrap pointer-events-none z-30 transition-opacity">
{field.label}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
};

151
components/SourceInput.tsx Normal file
View file

@ -0,0 +1,151 @@
import React, { useRef, useState } from 'react';
import { Upload, FileText, X, Image as ImageIcon } from 'lucide-react';
import type { FileData } from '../types';
interface SourceInputProps {
files: FileData[];
text: string;
onFilesChange: (files: FileData[]) => void;
onTextChange: (text: string) => void;
}
const ACCEPT = 'application/pdf,image/png,image/jpeg,image/webp';
function readFile(file: File): Promise<FileData> {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1];
resolve({
file,
previewUrl: objectUrl,
base64,
type: file.type as FileData['type'],
});
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export const SourceInput: React.FC<SourceInputProps> = ({
files,
text,
onFilesChange,
onTextChange,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const handleIncoming = async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
const incoming: FileData[] = [];
for (const f of Array.from(fileList)) {
if (!ACCEPT.split(',').includes(f.type)) continue;
incoming.push(await readFile(f));
}
onFilesChange([...files, ...incoming]);
};
const removeAt = (index: number) => {
const removed = files[index];
if (removed.previewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(removed.previewUrl);
}
onFilesChange(files.filter((_, i) => i !== index));
};
return (
<div className="space-y-3">
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
void handleIncoming(e.dataTransfer.files);
}}
className={`relative border-2 border-dashed rounded-kng-lg p-6 text-center cursor-pointer transition-all duration-200 ${
isDragging
? 'border-kng-accent bg-kng-surface'
: 'border-kng-border hover:border-kng-accent hover:bg-kng-surface'
}`}
>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
accept={ACCEPT}
onChange={(e) => void handleIncoming(e.target.files)}
/>
<div className="flex flex-col items-center justify-center space-y-2">
<div className="p-2 bg-kng-bg-elevated rounded-kng-full shadow-kng-sm">
<Upload className="w-5 h-5 text-kng-accent" />
</div>
<div>
<p className="text-sm font-semibold text-kng-text">
Dateien hinzufügen (Klick oder Drop)
</p>
<p className="text-xs text-kng-text-muted mt-1">
Scans, Briefe, Ausweise PDF oder Bild. Mehrere möglich.
</p>
</div>
</div>
</div>
{files.length > 0 && (
<ul className="space-y-2">
{files.map((f, idx) => (
<li
key={idx}
className="flex items-center gap-3 border border-kng-border bg-kng-surface rounded-kng-md px-3 py-2"
>
<div className="w-8 h-8 bg-kng-bg-elevated rounded-kng-sm flex items-center justify-center flex-shrink-0 overflow-hidden">
{f.type === 'application/pdf' ? (
<FileText className="w-4 h-4 text-kng-text-secondary" />
) : (
<ImageIcon className="w-4 h-4 text-kng-text-secondary" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-kng-text truncate">
{f.file.name}
</p>
<p className="text-xs text-kng-text-muted">
{(f.file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<button
onClick={() => removeAt(idx)}
className="p-1 hover:bg-kng-surface-hover rounded-kng-full transition-colors text-kng-text-muted hover:text-kng-error"
aria-label="Entfernen"
>
<X className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-kng-text-secondary mb-1">
Zusätzlicher Text (optional)
</label>
<textarea
value={text}
onChange={(e) => onTextChange(e.target.value)}
rows={4}
placeholder="Notizen, E-Mail-Text, Werte die nicht in den Dokumenten stehen …"
className="block w-full rounded-kng-md border border-kng-border bg-kng-surface px-3 py-2 text-sm text-kng-text placeholder-kng-text-muted focus:border-kng-accent focus:outline-none focus:ring-1 focus:ring-kng-accent resize-y"
/>
</div>
</div>
);
};

View file

@ -0,0 +1,53 @@
import React, { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';
const STORAGE_KEY = 'kng_theme';
type Theme = 'dark' | 'light';
function getSystemTheme(): Theme {
if (typeof window === 'undefined' || !window.matchMedia) return 'dark';
return window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
}
function readStored(): Theme | null {
try {
const v = localStorage.getItem(STORAGE_KEY);
return v === 'dark' || v === 'light' ? v : null;
} catch {
return null;
}
}
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme);
}
export const ThemeToggle: React.FC = () => {
const [theme, setTheme] = useState<Theme>(() => readStored() ?? getSystemTheme());
useEffect(() => {
applyTheme(theme);
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
// localStorage nicht verfügbar — egal
}
}, [theme]);
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-kng-md border border-kng-border bg-kng-surface hover:bg-kng-surface-hover text-kng-text-secondary hover:text-kng-text transition-colors"
aria-label={theme === 'dark' ? 'Zu Light-Mode wechseln' : 'Zu Dark-Mode wechseln'}
title={theme === 'dark' ? 'Light-Mode' : 'Dark-Mode'}
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</button>
);
};

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
services:
rentenv:
build:
context: ..
dockerfile: Rentenversicherer/Dockerfile
image: rentenv:latest
container_name: rentenv
restart: unless-stopped
user: "1000:1000"
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3011
# Claude-CLI liest HOME um .claude zu finden. node-User hat /home/node.
HOME: /home/node
volumes:
# Claude-Credentials vom Host (User openclaw, UID 1000).
# Read-write weil Claude Session-State rein schreibt.
- /home/openclaw/.claude:/home/node/.claude
- /home/openclaw/.config/claude:/home/node/.config/claude
networks:
- matrix
expose:
- "3011"
networks:
matrix:
external: true
name: matrix_default

View file

@ -1,34 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AutoForm AI</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #f8fafc;
}
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"react/": "https://esm.sh/react@^19.2.4/",
"react": "https://esm.sh/react@^19.2.4",
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
"@google/genai": "https://esm.sh/@google/genai@^1.38.0",
"jspdf": "https://esm.sh/jspdf@2.5.1",
"pdf-lib": "https://esm.sh/pdf-lib@^1.17.1",
"vite": "https://esm.sh/vite@^7.3.1",
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2"
}
}
</script>
</head>
<title>Rentenversicherer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
</html>

View file

@ -1,10 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
throw new Error('Could not find root element to mount to');
}
const root = ReactDOM.createRoot(rootElement);
@ -12,4 +13,4 @@ root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
);

4858
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,29 +1,35 @@
{
"name": "autoform-ai",
"name": "rentenversicherer",
"private": true,
"version": "1.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "concurrently -n vite,server -c cyan,magenta \"vite\" \"tsx watch server/index.ts\"",
"build": "vite build",
"preview": "vite preview"
"start": "tsx server/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.38.0",
"jspdf": "^2.5.1",
"express": "^4.19.2",
"lucide-react": "^0.563.0",
"pdf-lib": "^1.17.1",
"multer": "^2.1.1",
"pdfjs-dist": "^5.6.205",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.12.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"concurrently": "^8.2.2",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"tsx": "^4.19.0",
"typescript": "^5.2.2",
"vite": "^5.1.4"
}
}
}

View file

@ -1,6 +1,6 @@
export default {
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
};

269
server/claudeRunner.ts Normal file
View file

@ -0,0 +1,269 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
export interface ClaudeFieldSpec {
name: string;
type: string;
}
export interface ClaudeExtractedField {
key: string;
label: string;
value: string;
sourceContext?: string;
validation: {
status: 'VALID' | 'WARNING' | 'INVALID';
message?: string;
suggestion?: string;
};
}
export interface ClaudeFormResponse {
summary: string;
fields: ClaudeExtractedField[];
}
interface ClaudeCliEnvelope {
type: string;
subtype: string;
is_error: boolean;
result?: string;
}
export interface RunClaudeArgs {
formFilename: string;
sourceFilenames: string[];
hasSourceText: boolean;
fields: ClaudeFieldSpec[];
}
const GIT_BASH_FALLBACK =
'C:\\Users\\benad\\scoop\\apps\\git\\2.53.0\\usr\\bin\\bash.exe';
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
function describeType(t: string): string {
switch (t) {
case 'Tx':
return 'Text';
case 'Btn':
return 'Checkbox/Button';
case 'Ch':
return 'Auswahlliste';
case 'Sig':
return 'Unterschrift';
default:
return t;
}
}
function buildPrompt(tempDir: string, args: RunClaudeArgs): string {
const formPath = path.join(tempDir, args.formFilename);
const sourcePaths = args.sourceFilenames.map((n) => path.join(tempDir, n));
const textPath = args.hasSourceText
? path.join(tempDir, 'source_text.txt')
: null;
const fieldList = args.fields
.map((f) => ` - "${f.name}" (${describeType(f.type)})`)
.join('\n');
const sourceBlock: string[] = [];
if (sourcePaths.length > 0) {
sourceBlock.push('SOURCE-DATEIEN (alle mit dem Read-Tool lesen):');
for (const p of sourcePaths) sourceBlock.push(` - ${p}`);
}
if (textPath) {
sourceBlock.push(
`ZUSÄTZLICHER TEXT: ${textPath} (Notizen/Kontext vom User)`
);
}
return [
'Du füllst ein deutsches Behörden-/Arztformular aus.',
'',
`TARGET-FORM: ${formPath}`,
'',
...sourceBlock,
'',
'AcroForm-Felder im TARGET:',
fieldList,
'',
'Lies das TARGET-Formular und alle Quelldateien/Text mit dem Read-Tool.',
'Extrahiere die Werte aus den Quellen und mappe sie auf die Feldnamen',
'des TARGET. Quellen können sich überschneiden — bei Widersprüchen',
'nimm den plausibelsten Wert und setze validation.status = "WARNING".',
'',
'ARBEITSREGELN (non-negotiable):',
'',
'1. STICHWORTSTIL, KEIN GUTACHTEN — kurze Einträge, keine ausformulierten',
' Sätze. "Akute Lumboischialgie" statt "Der Patient leidet seit …".',
'',
'2. FESTE ZEICHEN-KÄSTCHEN OHNE LEERZEICHEN — bei VSNR, IBAN, BIC,',
' Institutionskennzeichen (IK), Postleitzahl etc. werden die Zeichen in',
' einzelne Kästchen geschrieben. Zusammenhängend ohne Leerzeichen/Punkte',
' /Bindestriche ausgeben, auch wenn die Quelle sie enthält.',
' Beispiel: Quelle "12 340567 A 005" → value "12340567A005".',
'',
'3. VORDRUCKE RESPEKTIEREN — wenn das Formular ein Präfix schon druckt',
' (z.B. "DE" vor der IBAN, "€" vor dem Betrag), NICHT nochmal',
' mitschreiben. Nur den variablen Teil ins Feld.',
'',
'4. RICHTIGES FELD — gleiche Labels können mehrfach vorkommen',
' (Antragsteller vs. Zahlungsempfänger, erste vs. Folgeseite).',
' Feldname und Umfeld analysieren, Wert in den passenden Abschnitt.',
'',
'5. NUR MEDIZINISCH — Sozialbereich (Familienstand, Einkommen,',
' Wohnsituation jenseits der Adresse) bleibt leer, außer explizit',
' in den Quellen enthalten.',
'',
'6. KEINE GERATENEN WERTE — bei Unsicherheit: value="" und',
' validation.status="WARNING" mit Begründung. Nicht halluzinieren.',
' Lieber leer lassen als falsch ausfüllen.',
'',
'FORMAT-REGELN:',
'- Datum: DD.MM.YYYY',
'- Zahlen: Komma als Dezimaltrenner, Punkt als Tausendertrenner',
'- Checkbox/Button (Btn): value="X" wenn angekreuzt, value="" sonst',
'',
'Wenn ein Feld aus den Quellen nicht sicher ableitbar ist:',
' value="" und validation.status="WARNING" mit Begründung.',
'Wenn ein Feld klar kein Match hat: value="" und status="VALID".',
'',
'ANTWORTE NUR mit einem JSON-Objekt in diesem Format,',
'ohne Markdown-Codefence, ohne Kommentar davor oder danach:',
'',
'{',
' "summary": "kurze Beschreibung was verarbeitet wurde",',
' "fields": [',
' {',
' "key": "<exakter Feldname aus Liste>",',
' "label": "<menschenlesbar>",',
' "value": "<Wert oder leer>",',
' "sourceContext": "<Textsnippet aus der jeweiligen Quelle>",',
' "validation": { "status": "VALID|WARNING|INVALID", "message": "...", "suggestion": "..." }',
' }',
' ]',
'}',
].join('\n');
}
function stripCodeFence(raw: string): string {
const trimmed = raw.trim();
const fenceMatch = trimmed.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
if (fenceMatch) return fenceMatch[1].trim();
return trimmed;
}
export async function runClaude(
tempDir: string,
args: RunClaudeArgs,
timeoutMs: number = DEFAULT_TIMEOUT_MS
): Promise<ClaudeFormResponse> {
const prompt = buildPrompt(tempDir, args);
const env = {
...process.env,
CLAUDE_CODE_GIT_BASH_PATH:
process.env.CLAUDE_CODE_GIT_BASH_PATH ?? GIT_BASH_FALLBACK,
};
const cliArgs = [
'-p',
'--output-format',
'json',
'--permission-mode',
'bypassPermissions',
];
return new Promise<ClaudeFormResponse>((resolve, reject) => {
const child = spawn('claude', cliArgs, {
env,
cwd: tempDir,
shell: true,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
// Prompt via stdin — umgeht Windows-Shell-Escaping von Newlines/Quotes.
child.stdin.write(prompt);
child.stdin.end();
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
reject(new Error(`Claude CLI timeout nach ${timeoutMs}ms`));
}, timeoutMs);
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', (err) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(new Error(`Claude CLI spawn-Fehler: ${err.message}`));
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0) {
reject(
new Error(
`Claude CLI exit ${code}. stderr: ${stderr.slice(0, 500)}`
)
);
return;
}
let envelope: ClaudeCliEnvelope;
try {
envelope = JSON.parse(stdout);
} catch {
reject(
new Error(`Claude CLI-Output ist kein JSON: ${stdout.slice(0, 300)}`)
);
return;
}
if (envelope.is_error || !envelope.result) {
reject(
new Error(
`Claude CLI meldete Fehler: ${JSON.stringify(envelope).slice(0, 500)}`
)
);
return;
}
let parsed: ClaudeFormResponse;
try {
parsed = JSON.parse(stripCodeFence(envelope.result));
} catch {
reject(
new Error(
`Inneres Result ist kein JSON: ${envelope.result.slice(0, 300)}`
)
);
return;
}
if (!Array.isArray(parsed.fields)) {
reject(new Error('Claude-Antwort hat kein fields-Array.'));
return;
}
resolve(parsed);
});
});
}

158
server/index.ts Normal file
View file

@ -0,0 +1,158 @@
import express, { type Request, type Response, type NextFunction } from 'express';
import multer from 'multer';
import { randomUUID } from 'node:crypto';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { runClaude, type ClaudeFieldSpec } from './claudeRunner.ts';
const PORT = Number(process.env.PORT ?? 3001);
const HOST = process.env.HOST ?? '127.0.0.1';
const IS_PROD = process.env.NODE_ENV === 'production';
const DIST_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'dist'
);
const MAX_FILE_BYTES = 20 * 1024 * 1024;
const MAX_SOURCES = 10;
const ALLOWED_MIME = new Set([
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
]);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_FILE_BYTES, files: MAX_SOURCES + 1 },
fileFilter: (_req, file, cb) => {
if (!ALLOWED_MIME.has(file.mimetype)) {
cb(new Error(`Unsupported mime: ${file.mimetype}`));
return;
}
cb(null, true);
},
});
const app = express();
app.post(
'/api/process',
upload.fields([
{ name: 'form', maxCount: 1 },
{ name: 'sources', maxCount: MAX_SOURCES },
]),
async (req: Request, res: Response, next: NextFunction) => {
const files = req.files as Record<string, Express.Multer.File[]> | undefined;
const formFile = files?.form?.[0];
const sourceFiles = files?.sources ?? [];
const sourceText =
typeof req.body?.sourceText === 'string'
? req.body.sourceText.trim()
: '';
if (!formFile) {
res.status(400).json({ error: 'form wird benötigt.' });
return;
}
if (sourceFiles.length === 0 && sourceText.length === 0) {
res
.status(400)
.json({ error: 'Mindestens eine Quelldatei oder sourceText nötig.' });
return;
}
let fieldSpecs: ClaudeFieldSpec[];
try {
const raw = req.body?.fields;
if (typeof raw !== 'string') throw new Error('fields fehlt');
fieldSpecs = JSON.parse(raw);
if (!Array.isArray(fieldSpecs) || fieldSpecs.length === 0) {
throw new Error('fields muss ein nicht-leeres Array sein');
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
res.status(400).json({ error: `fields-Parameter ungültig: ${msg}` });
return;
}
const requestId = randomUUID();
const tempDir = path.join(os.tmpdir(), `rentenv-${requestId}`);
const formName = filenameForMime('form', formFile.mimetype);
try {
await mkdir(tempDir, { recursive: true });
await writeFile(path.join(tempDir, formName), formFile.buffer);
const sourceNames: string[] = [];
for (let i = 0; i < sourceFiles.length; i++) {
const f = sourceFiles[i];
const name = filenameForMime(`source_${i + 1}`, f.mimetype);
await writeFile(path.join(tempDir, name), f.buffer);
sourceNames.push(name);
}
if (sourceText.length > 0) {
await writeFile(path.join(tempDir, 'source_text.txt'), sourceText);
}
const result = await runClaude(tempDir, {
formFilename: formName,
sourceFilenames: sourceNames,
hasSourceText: sourceText.length > 0,
fields: fieldSpecs,
});
res.json(result);
} catch (e: unknown) {
next(e);
} finally {
if (existsSync(tempDir)) {
rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
}
);
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
const msg = err instanceof Error ? err.message : String(err);
console.error('[server error]', msg);
res.status(502).json({ error: 'Claude CLI failed', details: msg });
});
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
// Production: statische Assets + SPA-Fallback auf index.html.
if (IS_PROD && existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR));
app.get(/^\/(?!api\/).*/, (_req, res) => {
res.sendFile(path.join(DIST_DIR, 'index.html'));
});
}
app.listen(PORT, HOST, () => {
console.log(`[server] listening on http://${HOST}:${PORT}`);
});
function filenameForMime(base: string, mime: string): string {
switch (mime) {
case 'application/pdf':
return `${base}.pdf`;
case 'image/png':
return `${base}.png`;
case 'image/jpeg':
return `${base}.jpg`;
case 'image/webp':
return `${base}.webp`;
default:
return `${base}.bin`;
}
}
process.on('unhandledRejection', (reason) => {
console.error('[unhandledRejection]', reason);
});

48
services/api.ts Normal file
View file

@ -0,0 +1,48 @@
import type { FileData, FormResponse } from '../types';
import type { PdfFieldInfo } from './pdfService';
export async function processDocuments(
formFile: FileData,
sourceFiles: FileData[],
sourceText: string,
pdfFields: PdfFieldInfo[]
): Promise<FormResponse> {
if (pdfFields.length === 0) {
throw new Error(
'Das Ziel-PDF enthält keine AcroForm-Felder. ' +
'Nur Formulare mit interaktiven Feldern werden unterstützt.'
);
}
if (sourceFiles.length === 0 && sourceText.trim().length === 0) {
throw new Error('Mindestens ein Quelldokument oder Text wird benötigt.');
}
const body = new FormData();
body.append('form', formFile.file, formFile.file.name);
for (const f of sourceFiles) {
body.append('sources', f.file, f.file.name);
}
if (sourceText.trim().length > 0) {
body.append('sourceText', sourceText);
}
body.append('fields', JSON.stringify(pdfFields));
const res = await fetch('/api/process', {
method: 'POST',
body,
});
if (!res.ok) {
let message = `Server antwortete mit ${res.status}`;
try {
const data = await res.json();
if (data?.error) message = data.error;
if (data?.details) message += `${data.details}`;
} catch {
// fall through
}
throw new Error(message);
}
return (await res.json()) as FormResponse;
}

View file

@ -1,155 +0,0 @@
import { GoogleGenAI, Type, Schema } from "@google/genai";
import { FileData, FormResponse } from "../types";
import { PdfFieldInfo } from "./pdfService";
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const responseSchema: Schema = {
type: Type.OBJECT,
properties: {
summary: {
type: Type.STRING,
description: "A brief summary of what document was processed."
},
fields: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
key: {
type: Type.STRING,
description: "The PDF field name (if available)."
},
label: {
type: Type.STRING,
description: "A human-readable label for the field."
},
value: {
type: Type.STRING,
description: "The value to fill. For checkboxes, use 'X' if true, otherwise leave empty."
},
sourceContext: {
type: Type.STRING,
description: "The exact snippet of text from the source document used to derive this value."
},
coordinates: {
type: Type.OBJECT,
description: "REQUIRED if no specific PDF field names are provided. Visual location to draw text.",
properties: {
pageIndex: { type: Type.INTEGER, description: "0-based page index" },
x: { type: Type.INTEGER, description: "Horizontal position (0-1000) from Left" },
y: { type: Type.INTEGER, description: "Vertical position (0-1000) from Top" }
},
required: ["pageIndex", "x", "y"]
},
validation: {
type: Type.OBJECT,
properties: {
status: {
type: Type.STRING,
description: "VALID, WARNING, or INVALID."
},
message: {
type: Type.STRING,
description: "Validation message explaining any issues."
},
suggestion: {
type: Type.STRING,
description: "Alternative value suggestion."
}
},
required: ["status"]
}
},
required: ["label", "value", "validation"]
}
}
},
required: ["fields", "summary"]
};
export const processDocuments = async (
blankForm: FileData,
sourceDocument: FileData,
pdfFields: PdfFieldInfo[] = []
): Promise<FormResponse> => {
const formPart = {
inlineData: {
data: blankForm.base64,
mimeType: blankForm.type,
},
};
const sourcePart = {
inlineData: {
data: sourceDocument.base64,
mimeType: sourceDocument.type,
},
};
let systemPrompt = `
ROLE: Intelligent Document Processing AI (German Bureaucracy Expert).
TASK: Extract data from the SOURCE DOCUMENT and map it to the TARGET FORM visually or logically.
STRICT FORMATTING RULES (German Context):
1. DATES: Must be formatted as 'DD.MM.YYYY' (e.g., 24.01.1982). Do not use ISO or US formats.
2. NUMBERS/CURRENCY: Use comma as decimal separator (e.g., 1.425,00). Do NOT write the currency symbol () if the form already has it printed.
3. CHECKBOXES: If a condition is met (e.g., "Männlich", "Ja"), the 'value' must be "X". If not met, leave empty.
CRITICAL: Verify every extraction. If ambiguous, set validation.status to 'WARNING'.
`;
if (pdfFields.length > 0) {
const fieldList = pdfFields.map(f => `"${f.name}" (${f.type})`).join(", ");
systemPrompt += `
MODE: FILLABLE PDF (AcroForm).
Map extracted data to these exact field IDs: [${fieldList}].
`;
} else {
systemPrompt += `
MODE: VISUAL FILLING (Flat Scan/Image).
The target form has NO digital fields. You must estimate COORDINATES.
COORDINATE SYSTEM (0-1000):
- x=0, y=0 is Top-Left.
- x=1000, y=1000 is Bottom-Right.
STRATEGY:
1. Analyze the blank form image. Identify where user input belongs (lines, boxes).
2. For "Reisekosten" (Travel Expenses): Look for columns like "Fahrtkosten", "Übernachtung". accurately place the amounts in the "Betrag" column.
3. Place text slightly ABOVE the underline so it looks natural.
4. For Checkboxes: Estimate the center of the square box.
`;
}
try {
const modelId = "gemini-3-flash-preview";
const response = await ai.models.generateContent({
model: modelId,
contents: {
parts: [
formPart,
{ text: "TARGET FORM (Blank)" },
sourcePart,
{ text: "SOURCE DATA (Email/Receipts)" },
]
},
config: {
responseMimeType: "application/json",
responseSchema: responseSchema,
systemInstruction: systemPrompt
}
});
const text = response.text;
if (!text) throw new Error("No response from Gemini");
return JSON.parse(text) as FormResponse;
} catch (error) {
console.error("Gemini API Error:", error);
throw error;
}
};

View file

@ -1,105 +1,144 @@
import { PDFDocument, PDFTextField, PDFCheckBox, StandardFonts, rgb } from 'pdf-lib';
import { ExtractedField } from '../types';
import * as pdfjs from 'pdfjs-dist';
// @ts-expect-error - ?url-Import ist Vite-spezifisch, wird zur Build-Zeit aufgelöst
import workerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
import type { ExtractedField } from '../types';
// pdfjs rendert AcroForm-Text mit der im PDF definierten Default-Appearance
// (Font-Größe + Farbe). In Behördenformularen ist das oft 1011pt und blau,
// was im ausgefüllten PDF zu groß und farblich falsch aussieht.
//
// Lösung: Das pdf.worker.mjs-Script wird zur Laufzeit gezogen, zwei Stellen
// gepatcht und als Blob-URL als Worker-Src gesetzt:
// 1. fontSize → 0 ⇒ pdfjs schaltet auf Auto-Size (passt sich an Feldhöhe an)
// 2. fontColor → schwarz
// So bleibt der Rest des PDFs unverändert, nur Textwerte rendern kleiner/schwarz.
let workerPromise: Promise<void> | null = null;
function ensureWorker(): Promise<void> {
if (workerPromise) return workerPromise;
workerPromise = (async () => {
try {
const res = await fetch(workerUrl as string);
if (!res.ok) throw new Error(`worker fetch failed: ${res.status}`);
let src = await res.text();
// 1. Auto-Size erzwingen
src = src.replace(
/let\s*\{\s*fontSize\s*\}\s*=\s*this\.data\.defaultAppearanceData;/,
'let fontSize = 0; void this.data.defaultAppearanceData;'
);
// 2. Font-Farbe schwarz erzwingen (Arbeitsregel: Schrift schwarz)
src = src.replace(
/const\s*\{\s*fontName,\s*fontColor\s*\}\s*=\s*this\.data\.defaultAppearanceData;/,
'const { fontName } = this.data.defaultAppearanceData; const fontColor = new Uint8ClampedArray([0,0,0]);'
);
const blob = new Blob([src], { type: 'text/javascript' });
pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(blob);
} catch (e) {
// Fallback: ungepatchter Worker — lieber laufen lassen als App brechen.
console.warn('[pdfService] worker patch failed, falling back', e);
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl as string;
}
})();
return workerPromise;
}
export interface PdfFieldInfo {
name: string;
type: string;
type: string; // 'Tx' | 'Btn' | 'Ch' | 'Sig'
}
export const getPdfFields = async (base64: string): Promise<PdfFieldInfo[]> => {
interface WidgetInfo {
id: string;
fieldName: string;
fieldType: string;
}
function base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
async function collectWidgets(
data: Uint8Array
): Promise<{ doc: pdfjs.PDFDocumentProxy; widgets: WidgetInfo[] }> {
await ensureWorker();
const loadingTask = pdfjs.getDocument({ data, isEvalSupported: false });
const doc = await loadingTask.promise;
const widgets: WidgetInfo[] = [];
for (let pageIdx = 1; pageIdx <= doc.numPages; pageIdx++) {
const page = await doc.getPage(pageIdx);
const anns = await page.getAnnotations();
for (const ann of anns) {
if (ann.subtype !== 'Widget') continue;
if (!ann.fieldName) continue;
widgets.push({
id: ann.id,
fieldName: ann.fieldName,
fieldType: ann.fieldType,
});
}
}
return { doc, widgets };
}
export async function getPdfFields(base64: string): Promise<PdfFieldInfo[]> {
try {
const pdfDoc = await PDFDocument.load(base64);
const form = pdfDoc.getForm();
const fields = form.getFields();
return fields.map(f => ({
name: f.getName(),
type: f.constructor.name
}));
} catch (error) {
console.warn("Failed to extract PDF fields", error);
const data = base64ToUint8Array(base64);
const { widgets } = await collectWidgets(data);
const seen = new Set<string>();
const unique: PdfFieldInfo[] = [];
for (const w of widgets) {
if (seen.has(w.fieldName)) continue;
seen.add(w.fieldName);
unique.push({ name: w.fieldName, type: w.fieldType });
}
return unique;
} catch (e) {
console.warn('[pdfService] getPdfFields failed:', e);
return [];
}
};
}
export const createFilledPdf = async (base64: string, fields: ExtractedField[], isFillable: boolean): Promise<Uint8Array> => {
const pdfDoc = await PDFDocument.load(base64);
const pages = pdfDoc.getPages();
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
if (isFillable) {
try {
const form = pdfDoc.getForm();
const fieldMap: Record<string, string> = {};
fields.forEach(f => {
if (f.key) fieldMap[f.key] = f.value;
});
export async function createFilledPdf(
base64: string,
fields: ExtractedField[]
): Promise<Uint8Array> {
const data = base64ToUint8Array(base64);
const { doc, widgets } = await collectWidgets(data);
for (const [key, value] of Object.entries(fieldMap)) {
try {
const field = form.getField(key);
if (!field) continue;
if (field instanceof PDFTextField) {
field.setText(String(value));
} else if (field instanceof PDFCheckBox) {
const isChecked = String(value).toLowerCase() === 'true' || String(value).toLowerCase() === 'yes' || String(value).toLowerCase() === 'x';
if (isChecked) field.check();
else field.uncheck();
}
} catch (e) {
// Field might be read-only or tricky
}
}
} catch (e) {
console.warn("Error filling form fields", e);
}
} else {
// VISUAL OVERLAY MODE
// Iterate through fields and draw them at specific coordinates
for (const field of fields) {
// Skip if no value or no coordinates
if (!field.value || !field.coordinates) continue;
const { pageIndex, x, y } = field.coordinates;
// Safety check for page index and coordinates
if (typeof pageIndex !== 'number' || pageIndex < 0 || pageIndex >= pages.length) continue;
if (isNaN(x) || isNaN(y)) continue;
const page = pages[pageIndex];
const { width, height } = page.getSize();
// Convert 0-1000 coordinates to PDF Point coordinates
// PDF (0,0) is bottom-left.
// API (0,0) is top-left.
// x = (x / 1000) * width
// y = height - (y / 1000) * height
const pdfX = (x / 1000) * width;
const pdfY = height - (y / 1000) * height;
// Adjust slightly for font height (text is drawn from baseline)
// A small nudge down (subtract from Y) helps align with lines usually.
const adjustedY = pdfY - 4;
try {
page.drawText(field.value, {
x: pdfX,
y: adjustedY,
size: 10,
font: font,
color: rgb(0, 0, 0),
});
} catch (e) {
console.warn(`Failed to draw field ${field.label}`, e);
}
}
const byName = new Map<string, ExtractedField>();
for (const f of fields) {
if (f.key) byName.set(f.key, f);
}
return await pdfDoc.save();
};
export const fillPdf = async (base64: string, fieldValues: Record<string, string | boolean>): Promise<Uint8Array> => {
return new Uint8Array();
};
const store = doc.annotationStorage;
for (const w of widgets) {
const source = byName.get(w.fieldName);
if (!source) continue;
if (w.fieldType === 'Tx') {
store.setValue(w.id, { value: source.value ?? '' });
} else if (w.fieldType === 'Btn') {
const checked = isTruthyCheckbox(source.value);
store.setValue(w.id, { value: checked });
} else if (w.fieldType === 'Ch') {
store.setValue(w.id, { value: source.value ?? '' });
}
// Sig (Signature) wird übersprungen.
}
return await doc.saveDocument();
}
function isTruthyCheckbox(value: string): boolean {
const v = (value ?? '').trim().toLowerCase();
return v === 'x' || v === 'ja' || v === 'yes' || v === 'true' || v === '1';
}

12
styles.css Normal file
View file

@ -0,0 +1,12 @@
@import '../kanagawa-design-system/css/kanagawa.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
background-color: var(--kng-bg);
color: var(--kng-text);
font-family: var(--kng-font-sans);
}

12
tailwind.config.cjs Normal file
View file

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
presets: [require('../kanagawa-design-system/tailwind/kanagawa.preset.js')],
content: [
'./index.html',
'./**/*.{js,ts,jsx,tsx}',
'!./node_modules/**',
'!./dist/**',
'!./test-fixtures/**',
],
plugins: [],
};

View file

@ -1,11 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View file

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
@ -14,8 +14,9 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"types": ["node"]
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
"exclude": ["node_modules", "dist"]
}

View file

@ -1,14 +1,15 @@
import { defineConfig, loadEnv } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react()],
define: {
// This is necessary to make process.env.API_KEY work in the browser
// usually Vite uses import.meta.env
'process.env.API_KEY': JSON.stringify(env.API_KEY)
}
};
});
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3001',
changeOrigin: false,
},
},
},
});