Rentenversicherer/server/claudeRunner.ts
Kenearos 3c669fb003 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>
2026-04-20 22:48:32 +02:00

269 lines
7.6 KiB
TypeScript

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