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:
parent
d6cab4aeb5
commit
3c669fb003
28 changed files with 6756 additions and 934 deletions
269
server/claudeRunner.ts
Normal file
269
server/claudeRunner.ts
Normal 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
158
server/index.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue