commit 99569d2cf3ce84d1a5b397312842940d70812565 Author: syntaxbullet Date: Wed May 13 17:26:13 2026 +0200 initial commit diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 120000 index 0000000..6100270 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1 @@ +../../CLAUDE.md \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..79d2937 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.env +.env.* +!.env.example + +node_modules +coverage +dist +out +logs +.cache +.eslintcache +*.tsbuildinfo + +.DS_Store +.idea +.claude +.cursor + +packages/extractor/outputs +packages/summarizer/outputs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d6bc10d --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +BMP_DEMO_PASSWORD=change-this-to-a-long-random-password +OPENROUTER_API_KEY=replace-me + +# Optional: set this for public HTTPS through Caddy, for example demo.example.com. +# Leave unset for local HTTP on port 80 via docker compose. +# APP_DOMAIN=demo.example.com + +OPENROUTER_MODEL=openai/gpt-5.4-nano +BMP_MAX_UPLOAD_BYTES=15728640 +BMP_MAX_PDF_PAGES=35 +BMP_MAX_ACTIVE_SUMMARY_JOBS=10 +MAX_UPLOAD_SIZE=20MB diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8100b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..cb4b13c --- /dev/null +++ b/Caddyfile @@ -0,0 +1,9 @@ +{$APP_DOMAIN} { + encode zstd gzip + + request_body { + max_size {$MAX_UPLOAD_SIZE} + } + + reverse_proxy bmp:3000 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e228eed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM oven/bun:1.3.2 + +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV BMP_DATA_DIR=/data + +COPY package.json bun.lock ./ +COPY packages/extractor/package.json packages/extractor/package.json +COPY packages/summarizer/package.json packages/summarizer/package.json +COPY packages/templatebuilder/package.json packages/templatebuilder/package.json + +RUN bun install --frozen-lockfile +RUN bun run playwright:install-deps && bun run playwright:install + +COPY . . + +RUN mkdir -p /data/extractor/outputs /data/summarizer/outputs /data/templatebuilder + +EXPOSE 3000 + +CMD ["bun", "run", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa33234 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# bmp-rewrite + +To install dependencies: + +```bash +bun install +bun run playwright:install +``` + +On a minimal Linux VPS, install Chromium's system libraries as well: + +```bash +bun run playwright:install-deps +``` + +To run: + +```bash +BMP_DEMO_PASSWORD="choose-a-long-client-demo-password" \ +OPENROUTER_API_KEY="..." \ +bun run index.ts +``` + +## Docker deployment + +Create a production env file: + +```bash +cp .env.example .env +``` + +Set at least `BMP_DEMO_PASSWORD` and `OPENROUTER_API_KEY` in `.env`. For a public +VPS with automatic HTTPS, also set: + +```bash +APP_DOMAIN=demo.example.com +``` + +Then build and run the app behind Caddy: + +```bash +docker compose up -d --build +``` + +Without `APP_DOMAIN`, Caddy serves the app on plain HTTP port 80 for local or +private-network testing. With `APP_DOMAIN`, Caddy requests and renews TLS +certificates automatically on ports 80 and 443. + +Generated extractor outputs, summarizer reports, and the editable template live +in the `bmp_data` Docker volume. Caddy certificates live in `caddy_data`. + +The app is password-gated for demos. Set `BMP_DEMO_PASSWORD` before exposing it to +any network. If it is omitted, the server prints a one-time password to the +console for local development only. + +Security-related optional settings: + +```bash +OPENROUTER_MODEL="openai/gpt-5.4-nano" # only this model is accepted by the API +BMP_MAX_UPLOAD_BYTES=15728640 # default: 15 MB +BMP_MAX_PDF_PAGES=35 # default: 35 pages +BMP_MAX_ACTIVE_SUMMARY_JOBS=10 # default: 10 active LLM jobs +``` + +Protected surfaces include uploads, template save/load, summarizer jobs, result +JSON, LLM trace metadata, and generated report downloads. Browser clients use a +session cookie plus CSRF token for unsafe requests. + +This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/assets/bmp-logo.png b/assets/bmp-logo.png new file mode 100644 index 0000000..910e42e Binary files /dev/null and b/assets/bmp-logo.png differ diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..43252c4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,333 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bmp-rewrite", + "dependencies": { + "exceljs": "^4.4.0", + "openai": "^6.33.0", + "pdfjs-dist": "^5.6.205", + "pdfkit": "^0.18.0", + "playwright": "^1.59.1", + "xlsx": "^0.18.5", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pdfkit": "^0.17.5", + "typescript": "^5", + }, + }, + "packages/extractor": { + "name": "extractor", + "version": "0.1.0", + }, + "packages/summarizer": { + "name": "summarizer", + "version": "0.1.0", + }, + "packages/templatebuilder": { + "name": "templatebuilder", + "version": "0.1.0", + }, + }, + "packages": { + "@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A=="], + + "@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA=="], + + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.97", "", { "os": "darwin", "cpu": "x64" }, "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.97", "", { "os": "linux", "cpu": "arm" }, "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.97", "", { "os": "linux", "cpu": "none" }, "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.97", "", { "os": "win32", "cpu": "arm64" }, "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "@types/pdfkit": ["@types/pdfkit@0.17.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg=="], + + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + + "archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="], + + "archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + + "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], + + "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A=="], + + "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + + "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="], + + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + + "compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="], + + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg=="], + + "extractor": ["extractor@workspace:packages/extractor"], + + "fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="], + + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + + "listenercount": ["listenercount@1.0.1", "", {}, "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="], + + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + + "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], + + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], + + "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="], + + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + + "lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw=="], + + "lodash.isnil": ["lodash.isnil@4.0.0", "", {}, "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA=="], + + "lodash.union": ["lodash.union@4.6.0", "", {}, "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw=="], + + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], + + "minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + + "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "node-readable-to-web-readable-stream": ["node-readable-to-web-readable-stream@0.4.2", "", {}, "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "pdfjs-dist": ["pdfjs-dist@5.6.205", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.96", "node-readable-to-web-readable-stream": "^0.4.2" } }, "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg=="], + + "pdfkit": ["pdfkit@0.18.0", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, "sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug=="], + + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + + "png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + + "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="], + + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "summarizer": ["summarizer@workspace:packages/summarizer"], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "templatebuilder": ["templatebuilder@workspace:packages/templatebuilder"], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + + "traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + + "unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ=="], + + "@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], + + "@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ=="], + + "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "buffer/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], + + "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], + + "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + } +} diff --git a/deployPaths.ts b/deployPaths.ts new file mode 100644 index 0000000..4ce422f --- /dev/null +++ b/deployPaths.ts @@ -0,0 +1,7 @@ +import { join } from "node:path"; + +export const appDataDir = process.env.BMP_DATA_DIR; + +export function dataPath(defaultPath: string, ...parts: string[]): string { + return appDataDir ? join(appDataDir, ...parts) : defaultPath; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0fae754 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + bmp: + build: . + image: bmp-rewrite:latest + restart: unless-stopped + env_file: + - .env + environment: + NODE_ENV: production + PORT: 3000 + BMP_DATA_DIR: /data + volumes: + - bmp_data:/data + healthcheck: + test: ["CMD", "bun", "-e", "const r = await fetch('http://127.0.0.1:3000/healthz'); if (!r.ok) process.exit(1);"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + bmp: + condition: service_healthy + environment: + APP_DOMAIN: "${APP_DOMAIN:-:80}" + MAX_UPLOAD_SIZE: "${MAX_UPLOAD_SIZE:-20MB}" + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + bmp_data: + caddy_data: + caddy_config: diff --git a/index.html b/index.html new file mode 100644 index 0000000..d377fc2 --- /dev/null +++ b/index.html @@ -0,0 +1,916 @@ + + + + + + BMP Bewerbungscheck + + + +
+
+
+

Bewerbungscheck

+

PDF hochladen. Einschätzung und Bericht erhalten.

+

Dieses Werkzeug liest einen ausgefüllten Fragebogen aus, fasst die wichtigsten Angaben zusammen und erstellt eine klare Einschätzung mit passenden Exporten.

+
+ +
+ +
+
+
+

Start

+

Wählen Sie den ausgefüllten PDF-Fragebogen aus. Danach starten Sie die Auswertung mit einem Klick.

+
+ +
+ +
+ + PDF auswählen oder hier ablegen + Es wird nur eine PDF-Datei benötigt. +
+
+ +
+ +
+

Bereit, sobald eine PDF ausgewählt wurde.

+ Der Bayerische Mittelstandspreis 2026 +
+ +
+

Aktueller Stand

+
+
+
1
+
Datei erhaltenDie PDF wird übernommen.
+ wartet +
+
+
2
+
Angaben lesenDie Antworten werden aus dem Fragebogen ausgelesen.
+ wartet +
+
+
3
+
Datenschutz anwendenPersonenbezogene Angaben werden reduziert.
+ wartet +
+
+
4
+
Einschätzung erstellenDie wichtigsten Punkte werden zusammengefasst und eingeordnet.
+ wartet +
+
+
+ LLM-Aufrufe werden vorbereitet. + +
+
+
+
+
+
5
+
Downloads vorbereitenBericht und Rohdaten werden bereitgestellt.
+ wartet +
+
+ +
+

Nach der Auswertung sehen Sie hier das Ergebnis.

+
    +
  • Eine kurze Zusammenfassung, damit Sie schnell verstehen, worum es geht.
  • +
  • Eine Ampel-Einschätzung für den schnellen Überblick.
  • +
  • Downloads für den Bericht, die Tabelle und die Rohdaten.
  • +
+
+ + +
+
+
+ + + + diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..b0e664a --- /dev/null +++ b/index.ts @@ -0,0 +1,224 @@ +import { routes as templatebuilder, workerPath } from "./packages/templatebuilder/index"; +import { routes as extractor } from "./packages/extractor/index"; +import { routes as summarizer } from "./packages/summarizer/index"; +import { join } from "node:path"; +import { dataPath } from "./deployPaths"; +import { + addSecurityHeaders, + isSafeStem, + login, + loginPage, + logout, + requireAuth, + securityHeaders, + withAuth, +} from "./security"; + +const SUMMARIZER_OUTPUTS_DIR = + process.env.BMP_SUMMARIZER_OUTPUTS_DIR ?? + dataPath(join(import.meta.dir, "packages/summarizer/outputs"), "summarizer", "outputs"); +const ROOT_PAGE_PATH = join(import.meta.dir, "index.html"); +const BMP_LOGO_PATH = join(import.meta.dir, "assets/bmp-logo.png"); +const PORT = Number(process.env.PORT ?? 3000); + +function contentTypeFor(path: string): string { + if (path.endsWith(".json")) return "application/json; charset=utf-8"; + if (path.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + if (path.endsWith(".pdf")) return "application/pdf"; + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + return "application/octet-stream"; +} + +async function serveSummaryDownload(req: Request, stem: string, file: string): Promise { + const blocked = await requireAuth(req); + if (blocked) return blocked; + + if ( + !isSafeStem(stem) || + file !== `${stem}.json` && + file !== `${stem}.xlsx` && + file !== `${stem}.pdf` && + file !== `${stem}.report.html` && + file !== `${stem}.questions.pdf` && + file !== `${stem}.questions.html` + ) { + return new Response("Not found", { status: 404 }); + } + + const path = join(SUMMARIZER_OUTPUTS_DIR, stem, file); + const output = Bun.file(path); + if (!(await output.exists())) { + return new Response("Not found", { status: 404 }); + } + + return new Response(output, { + headers: { + ...securityHeaders, + "Content-Type": contentTypeFor(file), + "Cache-Control": "private, no-store", + "Content-Disposition": `attachment; filename="${file.replaceAll('"', "")}"`, + }, + }); +} + +Bun.serve({ + port: PORT, + routes: { + "/login": { + GET: loginPage, + POST: login, + }, + "/healthz": { + GET: () => Response.json({ ok: true }), + }, + "/logout": { + POST: withAuth(async () => logout(), { csrf: true }), + }, + "/": { + GET: withAuth(async () => new Response(Bun.file(ROOT_PAGE_PATH), { + headers: { "Content-Type": "text/html; charset=utf-8" }, + })), + }, + "/assets/bmp-logo.png": { + GET: withAuth(async () => new Response(Bun.file(BMP_LOGO_PATH), { + headers: { "Content-Type": "image/png", "Cache-Control": "private, max-age=86400" }, + })), + }, + "/api/pipeline/result/:stem": { + GET: withAuth(async (req: Request) => { + const stem = (req as Request & { params: Record }).params.stem ?? ""; + if (!isSafeStem(stem)) { + return Response.json({ error: "Invalid result id" }, { status: 400 }); + } + + const jsonPath = join(SUMMARIZER_OUTPUTS_DIR, stem, `${stem}.json`); + const xlsxPath = join(SUMMARIZER_OUTPUTS_DIR, stem, `${stem}.xlsx`); + const pdfPath = join(SUMMARIZER_OUTPUTS_DIR, stem, `${stem}.pdf`); + const reportHtmlPath = join(SUMMARIZER_OUTPUTS_DIR, stem, `${stem}.report.html`); + const questionsPdfPath = join(SUMMARIZER_OUTPUTS_DIR, stem, `${stem}.questions.pdf`); + const questionsHtmlPath = join(SUMMARIZER_OUTPUTS_DIR, stem, `${stem}.questions.html`); + const jsonFile = Bun.file(jsonPath); + if (!(await jsonFile.exists())) { + return Response.json({ error: "Summary result not found" }, { status: 404 }); + } + + const downloads = [ + { + label: "JSON herunterladen", + url: `/downloads/summarizer/${encodeURIComponent(stem)}/${encodeURIComponent(`${stem}.json`)}`, + }, + ]; + + if (await Bun.file(xlsxPath).exists()) { + downloads.push({ + label: "XLSX herunterladen", + url: `/downloads/summarizer/${encodeURIComponent(stem)}/${encodeURIComponent(`${stem}.xlsx`)}`, + }); + } + if (await Bun.file(pdfPath).exists()) { + downloads.push({ + label: "PDF herunterladen", + url: `/downloads/summarizer/${encodeURIComponent(stem)}/${encodeURIComponent(`${stem}.pdf`)}`, + }); + } + if (await Bun.file(reportHtmlPath).exists()) { + downloads.push({ + label: "Report-HTML herunterladen", + url: `/downloads/summarizer/${encodeURIComponent(stem)}/${encodeURIComponent(`${stem}.report.html`)}`, + }); + } + if (await Bun.file(questionsPdfPath).exists()) { + downloads.push({ + label: "Fragen-PDF herunterladen", + url: `/downloads/summarizer/${encodeURIComponent(stem)}/${encodeURIComponent(`${stem}.questions.pdf`)}`, + }); + } + if (await Bun.file(questionsHtmlPath).exists()) { + downloads.push({ + label: "Fragen-HTML herunterladen", + url: `/downloads/summarizer/${encodeURIComponent(stem)}/${encodeURIComponent(`${stem}.questions.html`)}`, + }); + } + + return Response.json({ + ok: true, + stem, + summary: await jsonFile.json(), + downloads, + }); + }), + }, + "/downloads/summarizer/:stem/:file": { + GET: async (req: Request) => { + const params = (req as Request & { params: Record }).params; + const stem = params.stem ?? ""; + const file = params.file ?? ""; + return serveSummaryDownload(req, stem, file); + }, + }, + ...templatebuilder, + ...extractor, + ...summarizer, + }, + async fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === "/pdf.worker.mjs") { + const blocked = await requireAuth(req); + if (blocked) return blocked; + return addSecurityHeaders(new Response(Bun.file(workerPath), { + headers: { "Content-Type": "application/javascript", "Cache-Control": "private, max-age=3600" }, + })); + } + if (pathname === "/login" && req.method === "POST") { + return login(req); + } + if (pathname === "/logout" && req.method === "POST") { + const blocked = await requireAuth(req, { csrf: true }); + if (blocked) return blocked; + return logout(); + } + if (pathname === "/login" && req.method === "GET") { + return loginPage(req); + } + if (pathname === "/healthz" && req.method === "GET") { + return Response.json({ ok: true }); + } + if (pathname === "/") { + const blocked = await requireAuth(req); + if (blocked) return blocked; + return addSecurityHeaders(new Response(Bun.file(ROOT_PAGE_PATH), { + headers: { "Content-Type": "text/html; charset=utf-8" }, + })); + } + if (pathname === "/assets/bmp-logo.png") { + const blocked = await requireAuth(req); + if (blocked) return blocked; + return addSecurityHeaders(new Response(Bun.file(BMP_LOGO_PATH), { + headers: { "Content-Type": "image/png", "Cache-Control": "private, max-age=86400" }, + })); + } + if (pathname.startsWith("/_bun/")) { + const blocked = await requireAuth(req); + if (blocked) return blocked; + return new Response("Not found", { + status: 404, + headers: securityHeaders, + }); + } + if (pathname.startsWith("/downloads/summarizer/")) { + const [stem = "", file = ""] = pathname + .slice("/downloads/summarizer/".length) + .split("/") + .map((part) => decodeURIComponent(part)); + return serveSummaryDownload(req, stem, file); + } + return new Response("Not found", { status: 404, headers: securityHeaders }); + }, + development: { hmr: true, console: true }, +}); + +console.log("bmp-rewrite"); +console.log(` pipeline → http://localhost:${PORT}/`); +console.log(` templatebuilder → http://localhost:${PORT}/builder`); +console.log(` extractor → http://localhost:${PORT}/extractor`); +console.log(` summarizer → http://localhost:${PORT}/summarizer`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7aa05e --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "bmp-rewrite", + "module": "index.ts", + "type": "module", + "private": true, + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@types/bun": "latest", + "@types/pdfkit": "^0.17.5", + "typescript": "^5" + }, + "scripts": { + "start": "bun run index.ts", + "playwright:install": "playwright install chromium", + "playwright:install-deps": "playwright install-deps chromium" + }, + "dependencies": { + "exceljs": "^4.4.0", + "openai": "^6.33.0", + "pdfjs-dist": "^5.6.205", + "pdfkit": "^0.18.0", + "playwright": "^1.59.1", + "xlsx": "^0.18.5" + } +} diff --git a/packages/extractor/extractor.ts b/packages/extractor/extractor.ts new file mode 100644 index 0000000..0ed572c --- /dev/null +++ b/packages/extractor/extractor.ts @@ -0,0 +1,176 @@ +// Use the legacy build — the main build relies on browser-only APIs (DOMMatrix +// etc.) that aren't available in a Bun/Node server context. +// @ts-ignore — legacy path has no separate .d.ts; types are compatible +import * as pdfjsLib from "pdfjs-dist/legacy/build/pdf.mjs"; + +// Point at the legacy worker. import.meta.resolve returns a file:// URL that +// Bun can load as a Worker, which pdfjs requires even in server contexts. +pdfjsLib.GlobalWorkerOptions.workerSrc = import.meta.resolve( + "pdfjs-dist/legacy/build/pdf.worker.mjs", +); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Raw extracted field values keyed by PDF field name. */ +export type FieldValues = Record; + +export interface FrageVorlage { + id?: string; + text: string; +} + +export interface TemplateLeafObject { + fieldName: string; + label?: string; + antwortFormat?: "einzelfrage" | "mehrfachfrage_ein_antwortfeld"; + fragen?: FrageVorlage[]; +} + +export type TemplateLeaf = string | TemplateLeafObject; + +export interface FieldIndexEntry { + path: string; + fieldName: string; + label?: string; + antwortFormat?: "einzelfrage" | "mehrfachfrage_ein_antwortfeld"; + fragen?: FrageVorlage[]; + isRich?: boolean; +} + +/** + * Flattened view of a template: PDF field name → metadata about the target path. + */ +export type FieldIndex = Record; + +// ─── Template helpers ───────────────────────────────────────────────────────── + +function isTemplateLeafObject(value: unknown): value is TemplateLeafObject { + return typeof value === "object" && value !== null && typeof (value as Record).fieldName === "string"; +} + +/** + * Recursively flattens the nested template produced by templatebuilder. + */ +export function flattenTemplate( + node: Record, + prefix = "", +): FieldIndex { + const index: FieldIndex = {}; + + for (const [key, value] of Object.entries(node)) { + const path = prefix ? `${prefix}.${key}` : key; + + if (typeof value === "string") { + index[value] = { + path, + fieldName: value, + antwortFormat: "einzelfrage", + isRich: false, + }; + } else if (isTemplateLeafObject(value)) { + index[value.fieldName] = { + path, + fieldName: value.fieldName, + label: value.label, + antwortFormat: value.antwortFormat ?? "einzelfrage", + fragen: value.fragen, + isRich: true, + }; + } else if (typeof value === "object" && value !== null) { + Object.assign(index, flattenTemplate(value as Record, path)); + } + } + + return index; +} + +// ─── PDF extraction ─────────────────────────────────────────────────────────── + +export async function extractFields( + data: ArrayBuffer, + options: { maxPages?: number } = {}, +): Promise { + const pdf = await pdfjsLib.getDocument({ data }).promise; + if (options.maxPages && pdf.numPages > options.maxPages) { + throw new Error(`PDF has ${pdf.numPages} pages; the configured limit is ${options.maxPages}.`); + } + const values: FieldValues = {}; + + for (let p = 1; p <= pdf.numPages; p++) { + const page = await pdf.getPage(p); + const annotations = await page.getAnnotations(); + + for (const ann of annotations) { + if (ann.subtype !== "Widget" || !ann.fieldName) continue; + + const raw = ann.fieldValue; + if (raw === undefined || raw === null) continue; + + values[ann.fieldName] = Array.isArray(raw) ? raw.join(", ") : String(raw); + } + } + + return values; +} + +// ─── Output builder ─────────────────────────────────────────────────────────── + +export function buildOutput( + values: FieldValues, + index: FieldIndex, +): Record { + const output: Record = {}; + + for (const entry of Object.values(index)) { + const value = values[entry.fieldName]; + if (value === undefined) continue; + + if (!entry.isRich) { + setNested(output, entry.path, value); + continue; + } + + const antwortFormat = entry.antwortFormat ?? "einzelfrage"; + const fragen = (entry.fragen ?? []).map((frage, i) => ({ + id: frage.id ?? `${entry.path.split(".").at(-1) ?? "frage"}_${i + 1}`, + text: frage.text, + ...(antwortFormat === "einzelfrage" ? { antwort: value } : {}), + })); + + setNested(output, entry.path, { + fieldName: entry.fieldName, + label: entry.label, + antwortFormat, + zuordnungsmodus: + antwortFormat === "mehrfachfrage_ein_antwortfeld" + ? "gemeinsame_antwort" + : "einzelfrage", + fragen, + antwort: value, + }); + } + + return output; +} + +export function getNested(obj: Record, path: string): unknown { + let cur: unknown = obj; + for (const key of path.split(".")) { + if (typeof cur !== "object" || cur === null) return undefined; + cur = (cur as Record)[key]; + } + return cur; +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +function setNested(obj: Record, path: string, value: unknown): void { + const parts = path.split("."); + let cur = obj; + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i]!; + if (typeof cur[key] !== "object" || cur[key] === null) cur[key] = {}; + cur = cur[key] as Record; + } + cur[parts[parts.length - 1]!] = value; +} diff --git a/packages/extractor/index.ts b/packages/extractor/index.ts new file mode 100644 index 0000000..dfdab05 --- /dev/null +++ b/packages/extractor/index.ts @@ -0,0 +1,154 @@ +import { join } from "node:path"; +import { dataPath } from "../../deployPaths"; +import { jsonError, normalizeStem, securityHeaders, withAuth } from "../../security"; + +const UPLOAD_PAGE_PATH = join(import.meta.dir, "upload.html"); +import { mkdir } from "node:fs/promises"; +import { flattenTemplate, extractFields, buildOutput, getNested } from "./extractor"; + +const TEMPLATE_PATH = + process.env.BMP_TEMPLATE_PATH ?? + dataPath(join(import.meta.dir, "../templatebuilder/template.json"), "templatebuilder", "template.json"); +const OUTPUTS_DIR = + process.env.BMP_EXTRACTOR_OUTPUTS_DIR ?? + dataPath(join(import.meta.dir, "outputs"), "extractor", "outputs"); +const MAX_UPLOAD_BYTES = Number(process.env.BMP_MAX_UPLOAD_BYTES ?? 15 * 1024 * 1024); +const MAX_PDF_PAGES = Number(process.env.BMP_MAX_PDF_PAGES ?? 35); + +await mkdir(OUTPUTS_DIR, { recursive: true }); + +async function loadTemplate(): Promise> { + const file = Bun.file(TEMPLATE_PATH); + if (!(await file.exists())) { + throw new Error( + `template.json not found at ${TEMPLATE_PATH}. Run the templatebuilder first.`, + ); + } + return file.json(); +} + +function normalizeFilenamePart(value: string): string { + return normalizeStem(value); +} + +function toSafeFilename(...parts: unknown[]): string { + const normalizedParts: string[] = []; + + for (const part of parts) { + if (typeof part !== "string") continue; + const trimmed = part.trim(); + if (!trimmed) continue; + + const normalized = normalizeFilenamePart(trimmed); + const previous = normalizedParts[normalizedParts.length - 1]; + + if (normalized && normalized !== previous) { + normalizedParts.push(normalized); + } + } + + return normalizedParts.join("_"); +} + +function extractScalarText(value: unknown): string | undefined { + if (typeof value === "string") return value.trim() || undefined; + if (typeof value === "object" && value !== null) { + const antwort = (value as Record).antwort; + if (typeof antwort === "string") return antwort.trim() || undefined; + } + return undefined; +} + +export const routes = { + "/extractor": { + GET: withAuth(async () => new Response(Bun.file(UPLOAD_PAGE_PATH), { + headers: { ...securityHeaders, "Content-Type": "text/html" }, + }), + ), + }, + + "/extract": { + POST: withAuth(async (req: Request) => { + const contentLength = Number(req.headers.get("content-length") ?? 0); + if (contentLength > MAX_UPLOAD_BYTES + 4096) { + return jsonError(`PDF is too large. Maximum size is ${Math.floor(MAX_UPLOAD_BYTES / 1024 / 1024)} MB.`, 413); + } + + let form; + try { + form = await req.formData(); + } catch { + return Response.json({ error: "Expected multipart/form-data" }, { status: 400 }); + } + + const entry = form.get("pdf"); + if (!(entry instanceof File)) { + return Response.json({ error: 'Missing "pdf" file field' }, { status: 400 }); + } + if (!entry.name.toLowerCase().endsWith(".pdf") || entry.size > MAX_UPLOAD_BYTES) { + return jsonError(`Only PDF uploads up to ${Math.floor(MAX_UPLOAD_BYTES / 1024 / 1024)} MB are allowed.`, 400); + } + + let template: Record; + try { + template = await loadTemplate(); + } catch (err) { + return Response.json({ error: String(err) }, { status: 500 }); + } + + const index = flattenTemplate(template); + const data = await entry.arrayBuffer(); + if (!isPdfMagic(data)) { + return jsonError("Uploaded file is not a valid PDF.", 400); + } + // pdfjs transfers the ArrayBuffer to its Worker, detaching it (byteLength → 0). + // Slice a copy first so we still have the original bytes to write to disk. + const pdfBytes = data.slice(0); + let values; + try { + values = await extractFields(data, { maxPages: MAX_PDF_PAGES }); + } catch (error) { + return jsonError(`PDF could not be processed: ${String(error).replace(/^Error:\s*/, "")}`, 400); + } + const output = buildOutput(values, index); + + const name = extractScalarText(getNested(output, "kontakt.unternehmen.name")); + const rechtsform = extractScalarText(getNested(output, "kontakt.unternehmen.rechtsform")); + const stem = + name && rechtsform && normalizeFilenamePart(name).endsWith(normalizeFilenamePart(rechtsform)) + ? toSafeFilename(name) + : toSafeFilename(name, rechtsform) || "output"; + + const outDir = join(OUTPUTS_DIR, stem); + await mkdir(outDir, { recursive: true }); + + const jsonPath = join(outDir, `${stem}.json`); + const pdfPath = join(outDir, `${stem}.pdf`); + + await Promise.all([ + Bun.write(jsonPath, JSON.stringify(output, null, 2)), + Bun.write(pdfPath, pdfBytes), + ]); + + return Response.json({ ok: true, stem, files: { json: `${stem}.json`, pdf: `${stem}.pdf` } }); + }, { + csrf: true, + limit: { key: "extract", max: 12, windowMs: 60_000 }, + }), + }, + + "/template": { + GET: withAuth(async () => { + try { + return Response.json(await loadTemplate()); + } catch (err) { + return Response.json({ error: String(err) }, { status: 500 }); + } + }), + }, +} as const; + +function isPdfMagic(data: ArrayBuffer): boolean { + const header = new TextDecoder().decode(new Uint8Array(data.slice(0, 5))); + return header === "%PDF-"; +} diff --git a/packages/extractor/package.json b/packages/extractor/package.json new file mode 100644 index 0000000..ddbaa95 --- /dev/null +++ b/packages/extractor/package.json @@ -0,0 +1,7 @@ +{ + "name": "extractor", + "version": "0.1.0", + "main": "index.ts", + "type": "module", + "private": true +} diff --git a/packages/extractor/tsconfig.json b/packages/extractor/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/extractor/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/extractor/upload.html b/packages/extractor/upload.html new file mode 100644 index 0000000..0659dfa --- /dev/null +++ b/packages/extractor/upload.html @@ -0,0 +1,131 @@ + + + + + Extractor — Test Upload + + + +
+

Extractor — Test Upload

+ +
+ + Drop a PDF here, or click to select +
+ + + +

+

+  
+ + + + diff --git a/packages/summarizer/index.html b/packages/summarizer/index.html new file mode 100644 index 0000000..bdfd0af --- /dev/null +++ b/packages/summarizer/index.html @@ -0,0 +1,294 @@ + + + + + + Summarizer + + + +
+
+

Summarizer

+

Fasst Bewerbungsantworten aus extrahierten JSON-Dateien via LLM zusammen.

+
+ +
+ Datenschutz + LLM-Eingaben werden vor dem Versand minimiert und pseudonymisiert. OpenRouter-Anfragen fordern ZDR-Routing und data_collection: deny an. +
+ +
+ + +
+ + + +
+
+
+ 0 / 0 + +
+
+ +

+
+
OpenRouter-Kosten
-
+
Tokens
-
+
LLM-Aufrufe
-
+
+
+

+  
+ + + + diff --git a/packages/summarizer/index.ts b/packages/summarizer/index.ts new file mode 100644 index 0000000..3c83c34 --- /dev/null +++ b/packages/summarizer/index.ts @@ -0,0 +1,406 @@ +import { basename, join } from "node:path"; +import { listCompanies, summarizeCompany, writeSummary, type LlmCallStatus, type LlmCallTrace } from "./summarizer"; +import { dataPath } from "../../deployPaths"; +import { allowedModelFromEnv, isSafeStem, jsonError, validateRequestedModel, withAuth } from "../../security"; + +const OUTPUTS_DIR = + process.env.BMP_SUMMARIZER_OUTPUTS_DIR ?? + dataPath(join(import.meta.dir, "outputs"), "summarizer", "outputs"); +const PAGE_PATH = join(import.meta.dir, "index.html"); +const MAX_PARALLEL_SUMMARIES = 2; +const MAX_ACTIVE_JOBS = Number(process.env.BMP_MAX_ACTIVE_SUMMARY_JOBS ?? 10); +const EXPECTED_LLM_CALLS_PER_COMPANY = 13; + +type JobStatus = "queued" | "running" | "done" | "error"; + +interface SummaryJob { + id: string; + status: JobStatus; + stem: string; + model: string; + total: number; + completed: number; + current?: string; + files: string[]; + downloads: SummaryDownload[]; + errors: string[]; + llmCalls: SafeLlmCallTrace[]; + usage: UsageSummary; + statusMessage?: string; + createdAt: string; + finishedAt?: string; +} + +type SafeLlmCallTrace = Pick< + LlmCallTrace, + "id" | "operation" | "label" | "model" | "status" | "startedAt" | "finishedAt" | "durationMs" | "error" +> & { + usage?: unknown; +}; + +interface SummaryDownload { + company: string; + label: string; + url: string; +} + +interface UsageSummary { + cost: number; + upstreamInferenceCost: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + reasoningTokens: number; + cachedTokens: number; + calls: number; +} + +interface LlmProgress { + total: number; + running: number; + done: number; + error: number; + latest?: { + label: string; + operation: LlmCallTrace["operation"]; + status: LlmCallStatus; + startedAt: string; + finishedAt?: string; + durationMs?: number; + }; +} + +function sanitizeTrace(trace: LlmCallTrace): SafeLlmCallTrace { + const response = trace.response as { usage?: unknown } | undefined; + return { + id: trace.id, + operation: trace.operation, + label: trace.label, + model: trace.model, + status: trace.status, + startedAt: trace.startedAt, + finishedAt: trace.finishedAt, + durationMs: trace.durationMs, + usage: response?.usage, + error: trace.error, + }; +} + +function emptyUsageSummary(): UsageSummary { + return { + cost: 0, + upstreamInferenceCost: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + reasoningTokens: 0, + cachedTokens: 0, + calls: 0, + }; +} + +function summarizeUsage(calls: SafeLlmCallTrace[]): UsageSummary { + const summary = emptyUsageSummary(); + for (const call of calls) { + const usage = readUsage(call.usage); + if (!usage) continue; + summary.calls += 1; + summary.cost += usage.cost; + summary.upstreamInferenceCost += usage.upstreamInferenceCost; + summary.promptTokens += usage.promptTokens; + summary.completionTokens += usage.completionTokens; + summary.totalTokens += usage.totalTokens; + summary.reasoningTokens += usage.reasoningTokens; + summary.cachedTokens += usage.cachedTokens; + } + return { + ...summary, + cost: Number(summary.cost.toFixed(8)), + upstreamInferenceCost: Number(summary.upstreamInferenceCost.toFixed(8)), + }; +} + +function summarizeLlmProgress(job: SummaryJob): LlmProgress { + const calls = job.llmCalls; + const progress: LlmProgress = { + total: Math.max(1, job.total) * EXPECTED_LLM_CALLS_PER_COMPANY, + running: 0, + done: 0, + error: 0, + }; + + for (const call of calls) { + progress[call.status] += 1; + } + + const latest = [...calls].sort((a, b) => { + const aTime = Date.parse(a.finishedAt ?? a.startedAt); + const bTime = Date.parse(b.finishedAt ?? b.startedAt); + return bTime - aTime; + })[0]; + + if (latest) { + progress.latest = { + label: latest.label, + operation: latest.operation, + status: latest.status, + startedAt: latest.startedAt, + finishedAt: latest.finishedAt, + durationMs: latest.durationMs, + }; + } + + return progress; +} + +function readUsage(value: unknown): UsageSummary | undefined { + if (typeof value !== "object" || value === null) return undefined; + const usage = value as Record; + const promptDetails = usage.prompt_tokens_details as Record | undefined; + const completionDetails = usage.completion_tokens_details as Record | undefined; + const costDetails = usage.cost_details as Record | undefined; + return { + cost: readNumber(usage.cost ?? usage.total_cost), + upstreamInferenceCost: readNumber(costDetails?.upstream_inference_cost ?? costDetails?.total_cost), + promptTokens: readNumber(usage.prompt_tokens), + completionTokens: readNumber(usage.completion_tokens), + totalTokens: readNumber(usage.total_tokens), + reasoningTokens: readNumber(completionDetails?.reasoning_tokens), + cachedTokens: readNumber(promptDetails?.cached_tokens), + calls: 1, + }; +} + +function readNumber(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function publicJob(job: SummaryJob) { + return { + id: job.id, + status: job.status, + stem: job.stem, + model: job.model, + total: job.total, + completed: job.completed, + current: job.current, + downloads: job.downloads, + errors: job.errors, + usage: job.usage, + llmProgress: summarizeLlmProgress(job), + statusMessage: job.statusMessage, + createdAt: job.createdAt, + finishedAt: job.finishedAt, + }; +} + +function activeJobCount(): number { + return [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length; +} + +const jobs = new Map(); + +function createJob(stem: string, model: string, total: number): SummaryJob { + const job: SummaryJob = { + id: crypto.randomUUID(), + status: "queued", + stem, + model, + total, + completed: 0, + files: [], + downloads: [], + errors: [], + llmCalls: [], + usage: emptyUsageSummary(), + createdAt: new Date().toISOString(), + }; + jobs.set(job.id, job); + return job; +} + +function labelForOutputFile(file: string): string { + if (file.endsWith(".json")) return "JSON herunterladen"; + if (file.endsWith(".xlsx")) return "XLSX herunterladen"; + if (file.endsWith(".questions.pdf")) return "Fragen-PDF herunterladen"; + if (file.endsWith(".questions.html")) return "Fragen-HTML herunterladen"; + if (file.endsWith(".pdf")) return "PDF herunterladen"; + if (file.endsWith(".report.html")) return "Report-HTML herunterladen"; + return basename(file); +} + +function buildDownload(company: string, filePath: string): SummaryDownload { + const file = basename(filePath); + return { + company, + label: labelForOutputFile(file), + url: `/downloads/summarizer/${encodeURIComponent(company)}/${encodeURIComponent(file)}`, + }; +} + +async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise, +): Promise { + let nextIndex = 0; + + async function runner() { + while (nextIndex < items.length) { + const index = nextIndex++; + await worker(items[index]!); + } + } + + const count = Math.min(limit, items.length); + await Promise.all(Array.from({ length: count }, () => runner())); +} + +async function executeJob(job: SummaryJob): Promise { + job.status = "running"; + job.statusMessage = "Auswertung wird vorbereitet."; + const companies = job.stem === "__all__" ? await listCompanies() : [job.stem]; + + try { + await runWithConcurrency(companies, MAX_PARALLEL_SUMMARIES, async (company) => { + job.current = company; + job.statusMessage = `LLM-Auswertung für ${company} läuft.`; + const summary = await summarizeCompany(company, job.model, { + onLlmCall: (trace) => { + const existingIndex = job.llmCalls.findIndex((call) => call.id === trace.id); + const safeTrace = sanitizeTrace(trace); + if (existingIndex >= 0) { + job.llmCalls[existingIndex] = safeTrace; + } else { + job.llmCalls.push(safeTrace); + } + job.usage = summarizeUsage(job.llmCalls); + job.statusMessage = trace.status === "running" + ? `${trace.label} wird verarbeitet.` + : `${trace.label} abgeschlossen.`; + }, + }); + job.statusMessage = `Downloads für ${company} werden vorbereitet.`; + const outputPaths = await writeSummary(company, summary, OUTPUTS_DIR); + job.files.push(...outputPaths.map((path) => basename(path))); + job.downloads.push(...outputPaths.map((path) => buildDownload(company, path))); + job.completed += 1; + }); + + job.status = "done"; + job.current = undefined; + job.statusMessage = "Auswertung fertig."; + job.finishedAt = new Date().toISOString(); + } catch (error) { + job.status = "error"; + job.errors.push(String(error)); + job.statusMessage = "Auswertung fehlgeschlagen."; + job.finishedAt = new Date().toISOString(); + } +} + +export const routes = { + "/summarizer": { + GET: withAuth(async () => new Response(Bun.file(PAGE_PATH), { + headers: { "Content-Type": "text/html; charset=utf-8" }, + })), + }, + + "/api/summarizer/companies": { + GET: withAuth(async () => { + const companies = await listCompanies(); + return Response.json({ companies }); + }), + }, + + "/api/summarizer/summarize": { + POST: withAuth(async (req: Request) => { + let body: { stem?: string; model?: string }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Expected JSON body" }, { status: 400 }); + } + + const { stem } = body; + const model = validateRequestedModel(body.model); + + if (!stem || (stem !== "__all__" && !isSafeStem(stem))) { + return Response.json({ error: 'Missing or invalid "stem" field' }, { status: 400 }); + } + if (!model) { + return Response.json( + { error: `Model is not allowed for this demo. Use ${allowedModelFromEnv()}.` }, + { status: 400 }, + ); + } + + if (!process.env.OPENROUTER_API_KEY) { + return Response.json( + { error: "OPENROUTER_API_KEY is not set in environment" }, + { status: 500 }, + ); + } + + if (activeJobCount() >= MAX_ACTIVE_JOBS) { + return jsonError(`Es laufen bereits ${MAX_ACTIVE_JOBS} Auswertungen. Bitte warten Sie, bis eine davon fertig ist.`, 429); + } + + const companies = await listCompanies(); + if (stem !== "__all__" && !companies.includes(stem)) { + return Response.json({ error: "Company not found" }, { status: 404 }); + } + const total = stem === "__all__" ? (await listCompanies()).length : 1; + const job = createJob(stem, model, total); + setTimeout(() => { + void executeJob(job); + }, 0); + + return Response.json({ ok: true, jobId: job.id }); + }, { + csrf: true, + limit: { key: "summarize", max: Math.max(20, MAX_ACTIVE_JOBS * 2), windowMs: 60_000 }, + }), + }, + + "/api/summarizer/jobs/:id/llm-calls": { + GET: withAuth(async (req: Request) => { + const id = (req as Request & { params: Record }).params.id; + if (!id) { + return Response.json({ error: "Job not found" }, { status: 404 }); + } + const job = jobs.get(id); + if (!job) { + return Response.json({ error: "Job not found" }, { status: 404 }); + } + return Response.json({ + jobId: job.id, + status: job.status, + total: job.total, + completed: job.completed, + current: job.current, + usage: job.usage, + llmCalls: job.llmCalls, + }); + }), + }, + + "/api/summarizer/jobs/:id": { + GET: withAuth(async (req: Request) => { + const id = (req as Request & { params: Record }).params.id; + if (!id) { + return Response.json({ error: "Job not found" }, { status: 404 }); + } + const job = jobs.get(id); + if (!job) { + return Response.json({ error: "Job not found" }, { status: 404 }); + } + return Response.json(publicJob(job)); + }), + }, +} as const; diff --git a/packages/summarizer/package.json b/packages/summarizer/package.json new file mode 100644 index 0000000..adaf68e --- /dev/null +++ b/packages/summarizer/package.json @@ -0,0 +1,7 @@ +{ + "name": "summarizer", + "version": "0.1.0", + "main": "index.ts", + "type": "module", + "private": true +} diff --git a/packages/summarizer/privacy.test.ts b/packages/summarizer/privacy.test.ts new file mode 100644 index 0000000..53cd2bc --- /dev/null +++ b/packages/summarizer/privacy.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from "bun:test"; +import { + buildLlmDossier, + createPseudonymizer, + reversePseudonymsInText, + reversePseudonymsInDossier, +} from "./privacy"; +import type { LlmDossier, SummaryData } from "./privacy"; + +const sample: SummaryData = { + kontakt: { + unternehmen: { + name: "Muster GmbH", + adresse: "Hauptstrasse 1", + plz: "80331 Muenchen", + telefon: "+49 89 123456", + email: "kontakt@muster.de", + web: "www.muster.de", + }, + ansprechpartner: { + name: "Max Mustermann", + telefon: "0170 1234567", + email: "max.mustermann@muster.de", + }, + }, + unternehmen: { + branche: "Maschinenbau", + gruendungsjahr: "1998", + anzahl_mitarbeiter: "120", + interne_notiz: "Nicht fuer LLM", + }, + fragen: { + frage1: { + label: "Frage 1", + antwort: "Max Mustermann beschreibt die Entwicklung der Muster GmbH. Muster investiert weiter. Kontakt: max.mustermann@muster.de.", + fragen: [ + { + id: "frage1_1", + text: "Was macht das Unternehmen aus?", + antwort: "Die Muster GmbH ist unter +49 89 123456 erreichbar.", + }, + ], + }, + }, + kriterium: { + robustheit_resilienz: { + label: "Robustheit", + antwort: "Die Muster GmbH hat ein Risikomanagement etabliert. Muster nutzt Fruehwarnprozesse.", + }, + }, +}; + +test("builds a minimized dossier without contact data or unknown company fields", () => { + const dossier = buildLlmDossier(sample); + const serialized = JSON.stringify(dossier); + + expect(dossier.unternehmen).toEqual({ + branche: "Maschinenbau", + gruendungsjahr: "1998", + anzahl_mitarbeiter: "120", + }); + expect(serialized).not.toContain("kontakt"); + expect(serialized).not.toContain("interne_notiz"); + expect(serialized).not.toContain("Hauptstrasse"); +}); + +test("pseudonymizes known personal and contact values in LLM-bound strings", () => { + const privacy = createPseudonymizer(sample); + const prepared = privacy.pseudonymizeDossier(buildLlmDossier(sample)); + const serialized = JSON.stringify(prepared.safe); + + expect(serialized).toContain("[PERSON_1]"); + expect(serialized).toContain("[UNTERNEHMEN]"); + expect(serialized).toContain("[EMAIL_2]"); + expect(serialized).toContain("[TELEFON_1]"); + expect(serialized).not.toContain("Max Mustermann"); + expect(serialized).not.toContain("Muster GmbH"); + expect(serialized).not.toContain("Muster investiert"); + expect(serialized).not.toContain("Muster nutzt"); + expect(serialized).not.toContain("max.mustermann@muster.de"); + expect(prepared.audit.mode).toBe("minimized+pseudonymized"); + expect(prepared.audit.companyName).toBe("Muster GmbH"); + expect(prepared.audit.replacements.length).toBeGreaterThan(0); +}); + +test("reversePseudonymsInText restores original values from known replacements", () => { + const text = "[PERSON_1] von [UNTERNEHMEN] nutzt [EMAIL_2]."; + const reversed = reversePseudonymsInText(text, [ + { value: "Max Mustermann", type: "person", placeholder: "[PERSON_1]" }, + { value: "Muster GmbH", type: "company", placeholder: "[UNTERNEHMEN]" }, + { value: "max.mustermann@muster.de", type: "email", placeholder: "[EMAIL_2]" }, + ]); + + expect(reversed).toBe("Max Mustermann von Muster GmbH nutzt max.mustermann@muster.de."); +}); + +test("reversePseudonyms works via the Pseudonymizer API", () => { + const privacy = createPseudonymizer(sample); + const reversed = privacy.reversePseudonyms("[PERSON_1] arbeitet bei [UNTERNEHMEN]."); + + expect(reversed).toContain("Max Mustermann"); + expect(reversed).toContain("Muster GmbH"); +}); + +test("reversePseudonymsInText handles text without placeholders", () => { + const text = "Kein Platzhalter hier."; + const reversed = reversePseudonymsInText(text, [ + { value: "Max Mustermann", type: "person", placeholder: "[PERSON_1]" }, + ]); + expect(reversed).toBe(text); +}); + +test("reversePseudonymsInDossier reverses nested text fields including zusammenfassung", () => { + const dossier: LlmDossier = { + fragen: { + frage1: { + label: "[PERSON_1] von [UNTERNEHMEN] beantwortet", + zusammenfassung: "[PERSON_1] arbeitet bei [UNTERNEHMEN] und nutzt [EMAIL_2].", + fragen: [ + { id: "f1", text: "Was macht [UNTERNEHMEN]?", antwort: "[PERSON_1] sagt [EMAIL_2]." }, + ], + }, + }, + kriterium: { + robustheit: { + label: "Robustheit von [PERSON_1]", + zusammenfassung: "[UNTERNEHMEN] ist robust.", + }, + }, + unternehmen: { + referenzen: "Referenz von [PERSON_1] bei [UNTERNEHMEN].", + }, + }; + + const reversed = reversePseudonymsInDossier(dossier, [ + { value: "Max Mustermann", type: "person", placeholder: "[PERSON_1]" }, + { value: "Muster GmbH", type: "company", placeholder: "[UNTERNEHMEN]" }, + { value: "max@muster.de", type: "email", placeholder: "[EMAIL_2]" }, + ]); + + expect(reversed.fragen?.frage1?.zusammenfassung).toBe( + "Max Mustermann arbeitet bei Muster GmbH und nutzt max@muster.de.", + ); + expect(reversed.fragen?.frage1?.fragen?.[0]?.antwort).toBe( + "Max Mustermann sagt max@muster.de.", + ); + expect(reversed.fragen?.frage1?.label).toBe( + "Max Mustermann von Muster GmbH beantwortet", + ); + expect(reversed.kriterium?.robustheit?.zusammenfassung).toBe( + "Muster GmbH ist robust.", + ); + expect(reversed.unternehmen?.referenzen).toBe( + "Referenz von Max Mustermann bei Muster GmbH.", + ); +}); + +test("reversePseudonymsInText handles edge case with numeric suffixes in text", () => { + const text = "[PERSON_1] und [PERSON_1]."; + const reversed = reversePseudonymsInText(text, [ + { value: "Max Mustermann", type: "person", placeholder: "[PERSON_1]" }, + ]); + expect(reversed).toBe("Max Mustermann und Max Mustermann."); +}); diff --git a/packages/summarizer/privacy.ts b/packages/summarizer/privacy.ts new file mode 100644 index 0000000..e5df629 --- /dev/null +++ b/packages/summarizer/privacy.ts @@ -0,0 +1,360 @@ +import type { AntwortEintrag, ExtractedData, FrageMitAntwort, SummaryData } from "./summarizer"; + +export type { SummaryData }; + +export interface LlmFrage { + id?: string; + text: string; + antwort?: string; + confidence?: number; + abgedeckt?: boolean; +} + +export interface LlmAnswer { + label?: string; + antwortFormat?: AntwortEintrag["antwortFormat"]; + fragen?: LlmFrage[]; + antwort?: string; + zusammenfassung?: string; + segmentierung?: AntwortEintrag["segmentierung"]; +} + +export interface LlmDossier { + unternehmen?: Record; + fragen?: Record; + kriterium?: Record; +} + +export interface PseudonymReplacement { + type: "company" | "person" | "email" | "phone" | "url" | "address" | "known"; + placeholder: string; +} + +export interface PrivacyAudit { + mode: "minimized+pseudonymized"; + companyName?: string; + removedFields: string[]; + replacements: PseudonymReplacement[]; +} + +export interface PreparedLlmInput { + safe: LlmDossier; + audit: PrivacyAudit; +} + +export interface Pseudonymizer { + pseudonymizeText(text: string): string; + reversePseudonyms(text: string): string; + pseudonymizeEntry(entry: T): T; + pseudonymizeDossier(dossier: LlmDossier): PreparedLlmInput; + audit(): PrivacyAudit; +} + +const REMOVED_FIELDS = [ + "kontakt", + "kontakt.unternehmen.name", + "kontakt.unternehmen.adresse", + "kontakt.unternehmen.plz", + "kontakt.unternehmen.telefon", + "kontakt.unternehmen.email", + "kontakt.unternehmen.web", + "kontakt.ansprechpartner", +]; + +const COMPANY_FACT_ALLOWLIST = [ + "branche", + "rechtsform", + "gruendungsjahr", + "anzahl_mitarbeiter", + "anzahl_azubi", + "standorte_deutschland", + "standorte_ausland", + "umsatzvolumen1", + "umsatzvolumen2", + "umsatzvolumen3", + "referenzen", +]; + +interface KnownReplacement { + value: string; + type: PseudonymReplacement["type"]; + placeholder: string; +} + +const COMPANY_LEGAL_SUFFIX_PATTERN = + /\b(?:gmbh|ag|kg|ohg|ug|eg|ev|e\.v\.|gbr|mbh|co\.?|kgaa|se|ltd\.?|inc\.?|corp\.?)\b/gi; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalize(value: string): string { + return value.trim().replace(/\s+/g, " "); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function addKnown( + replacements: KnownReplacement[], + value: unknown, + type: PseudonymReplacement["type"], + placeholder: string, +): void { + if (typeof value !== "string") return; + const normalized = normalize(value); + if (normalized.length < 3) return; + if (replacements.some((item) => item.value.toLowerCase() === normalized.toLowerCase())) return; + replacements.push({ value: normalized, type, placeholder }); +} + +function companyAliasCandidates(companyName: string): string[] { + const normalized = normalize(companyName); + const withoutSuffix = normalize( + normalized + .replace(/&/g, " ") + .replace(COMPANY_LEGAL_SUFFIX_PATTERN, " ") + .replace(/\s+/g, " "), + ); + const firstToken = withoutSuffix.split(/\s+/).find((token) => token.length >= 4); + + return [normalized, withoutSuffix, firstToken] + .filter((value): value is string => Boolean(value && value.length >= 4)) + .filter((value, index, values) => values.findIndex((item) => item.toLowerCase() === value.toLowerCase()) === index); +} + +function getCompanyName(data: ExtractedData): string | undefined { + const kontakt = isRecord(data.kontakt) ? data.kontakt : {}; + const kontaktUnternehmen = isRecord(kontakt.unternehmen) ? kontakt.unternehmen : {}; + return typeof kontaktUnternehmen.name === "string" && kontaktUnternehmen.name.trim() + ? normalize(kontaktUnternehmen.name) + : undefined; +} + +function collectKnownReplacements(data: ExtractedData, companyName?: string): KnownReplacement[] { + const kontakt = isRecord(data.kontakt) ? data.kontakt : {}; + const kontaktUnternehmen = isRecord(kontakt.unternehmen) ? kontakt.unternehmen : {}; + const ansprechpartner = isRecord(kontakt.ansprechpartner) ? kontakt.ansprechpartner : {}; + const replacements: KnownReplacement[] = []; + + for (const alias of companyName ? companyAliasCandidates(companyName) : []) { + addKnown(replacements, alias, "company", "[UNTERNEHMEN]"); + } + addKnown(replacements, kontaktUnternehmen.email, "email", "[EMAIL_1]"); + addKnown(replacements, kontaktUnternehmen.telefon, "phone", "[TELEFON_1]"); + addKnown(replacements, kontaktUnternehmen.web, "url", "[URL_1]"); + addKnown(replacements, kontaktUnternehmen.adresse, "address", "[ADRESSE_1]"); + addKnown(replacements, kontaktUnternehmen.plz, "address", "[ORT_1]"); + addKnown(replacements, ansprechpartner.name, "person", "[PERSON_1]"); + addKnown(replacements, ansprechpartner.email, "email", "[EMAIL_2]"); + addKnown(replacements, ansprechpartner.telefon, "phone", "[TELEFON_2]"); + + return replacements.toSorted((a, b) => b.value.length - a.value.length); +} + +const PLACEHOLDER_RE = /\[([A-Z][A-Z0-9_]*)_(\d+)\]/g; +const PLACEHOLDER_ALL_RE = /\[([A-Z][A-Z0-9_]*)(?:_\d+)?\]/g; + +function recordReplacement( + seen: Map, + type: PseudonymReplacement["type"], + placeholder: string, +): void { + const key = `${type}:${placeholder}`; + if (!seen.has(key)) seen.set(key, { type, placeholder }); +} + +function pseudonymizeUnknownPatterns( + text: string, + seen: Map, +): string { + let emailIndex = 10; + let phoneIndex = 10; + let urlIndex = 10; + + return text + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, () => { + const placeholder = `[EMAIL_${emailIndex++}]`; + recordReplacement(seen, "email", placeholder); + return placeholder; + }) + .replace(/\b(?:https?:\/\/)?(?:www\.)[^\s<>"')]+/gi, () => { + const placeholder = `[URL_${urlIndex++}]`; + recordReplacement(seen, "url", placeholder); + return placeholder; + }) + .replace(/(?:\+49|0049|0)[\d\s()/.-]{6,}\d/g, (match) => { + const digits = match.replace(/\D/g, ""); + if (digits.length < 7) return match; + const placeholder = `[TELEFON_${phoneIndex++}]`; + recordReplacement(seen, "phone", placeholder); + return placeholder; + }); +} + +export function reversePseudonymsInText( + text: string, + knownReplacements: KnownReplacement[], +): string { + let output = text; + for (const replacement of knownReplacements) { + output = output.replaceAll(replacement.placeholder, replacement.value); + } + return output; +} + +function pseudonymizeValue(value: unknown, pseudonymizeText: (text: string) => string): unknown { + if (typeof value === "string") return pseudonymizeText(value); + if (Array.isArray(value)) return value.map((item) => pseudonymizeValue(item, pseudonymizeText)); + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, pseudonymizeValue(item, pseudonymizeText)]), + ); + } + return value; +} + +function minimizeQuestion(frage: FrageMitAntwort, pseudonymizeText?: (text: string) => string): LlmFrage { + const map = pseudonymizeText ?? ((text: string) => text); + return { + id: frage.id, + text: map(frage.text), + antwort: frage.antwort ? map(frage.antwort) : frage.antwort, + confidence: frage.confidence, + abgedeckt: frage.abgedeckt, + }; +} + +function minimizeAnswer(entry: AntwortEintrag, pseudonymizeText?: (text: string) => string): LlmAnswer { + const map = pseudonymizeText ?? ((text: string) => text); + const hasSummary = Boolean(entry.zusammenfassung?.trim()); + return { + label: entry.label ? map(entry.label) : entry.label, + antwortFormat: entry.antwortFormat, + fragen: entry.fragen?.map((frage) => minimizeQuestion(frage, map)), + antwort: !hasSummary && entry.antwort ? map(entry.antwort) : undefined, + zusammenfassung: entry.zusammenfassung ? map(entry.zusammenfassung) : entry.zusammenfassung, + segmentierung: entry.segmentierung, + }; +} + +export function reversePseudonymsInDossier( + dossier: LlmDossier, + knownReplacements: KnownReplacement[], +): LlmDossier { + return { + unternehmen: dossier.unternehmen + ? pseudonymizeValue(dossier.unternehmen, (text) => reversePseudonymsInText(text, knownReplacements)) as Record + : undefined, + fragen: dossier.fragen + ? Object.fromEntries( + Object.entries(dossier.fragen).map(([key, entry]) => { + const reverse = (text: string) => reversePseudonymsInText(text, knownReplacements); + return [ + key, + { + ...entry, + label: entry.label ? reverse(entry.label) : entry.label, + zusammenfassung: entry.zusammenfassung ? reverse(entry.zusammenfassung) : entry.zusammenfassung, + fragen: entry.fragen?.map((frage) => ({ + ...frage, + text: reverse(frage.text), + antwort: frage.antwort ? reverse(frage.antwort) : undefined, + })) ?? entry.fragen, + }, + ]; + }), + ) + : undefined, + kriterium: dossier.kriterium + ? Object.fromEntries( + Object.entries(dossier.kriterium).map(([key, entry]) => { + const reverse = (text: string) => reversePseudonymsInText(text, knownReplacements); + return [ + key, + { + ...entry, + label: entry.label ? reverse(entry.label) : entry.label, + zusammenfassung: entry.zusammenfassung ? reverse(entry.zusammenfassung) : entry.zusammenfassung, + fragen: entry.fragen?.map((frage) => ({ + ...frage, + text: reverse(frage.text), + antwort: frage.antwort ? reverse(frage.antwort) : undefined, + })) ?? entry.fragen, + }, + ]; + }), + ) + : undefined, + }; +} + +export function buildLlmDossier(data: SummaryData, pseudonymizeText?: (text: string) => string): LlmDossier { + const map = pseudonymizeText ?? ((text: string) => text); + const unternehmen = isRecord(data.unternehmen) + ? Object.fromEntries( + COMPANY_FACT_ALLOWLIST + .filter((key) => key in data.unternehmen!) + .map((key) => [key, pseudonymizeValue(data.unternehmen![key], map)]), + ) + : undefined; + + return { + unternehmen, + fragen: data.fragen + ? Object.fromEntries(Object.entries(data.fragen).map(([key, entry]) => [key, minimizeAnswer(entry, map)])) + : undefined, + kriterium: data.kriterium + ? Object.fromEntries(Object.entries(data.kriterium).map(([key, entry]) => [key, minimizeAnswer(entry, map)])) + : undefined, + }; +} + +export function createPseudonymizer(data: ExtractedData): Pseudonymizer { + const companyName = getCompanyName(data); + const known = collectKnownReplacements(data, companyName); + const seen = new Map(); + + function pseudonymizeText(text: string): string { + let output = text; + for (const replacement of known) { + const before = output; + output = output.replace(new RegExp(escapeRegExp(replacement.value), "gi"), replacement.placeholder); + if (output !== before) { + recordReplacement(seen, replacement.type, replacement.placeholder); + } + } + return pseudonymizeUnknownPatterns(output, seen); + } + + function pseudonymizeEntry(entry: T): T { + return { + ...entry, + label: entry.label ? pseudonymizeText(entry.label) : entry.label, + fragen: entry.fragen?.map((frage) => minimizeQuestion(frage, pseudonymizeText)), + antwort: entry.antwort ? pseudonymizeText(entry.antwort) : entry.antwort, + zusammenfassung: entry.zusammenfassung ? pseudonymizeText(entry.zusammenfassung) : entry.zusammenfassung, + } as T; + } + + function audit(): PrivacyAudit { + return { + mode: "minimized+pseudonymized", + companyName, + removedFields: REMOVED_FIELDS, + replacements: Array.from(seen.values()), + }; + } + + function pseudonymizeDossier(dossier: LlmDossier): PreparedLlmInput { + const safe = pseudonymizeValue(dossier, pseudonymizeText) as LlmDossier; + return { safe, audit: audit() }; + } + + function reversePseudonyms(text: string): string { + return reversePseudonymsInText(text, known); + } + + return { pseudonymizeText, reversePseudonyms, pseudonymizeEntry, pseudonymizeDossier, audit }; +} diff --git a/packages/summarizer/report.ts b/packages/summarizer/report.ts new file mode 100644 index 0000000..193ecbb --- /dev/null +++ b/packages/summarizer/report.ts @@ -0,0 +1,1466 @@ +import { readFileSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { Ampelbewertung, SummaryData } from "./summarizer"; +import type { ScoringDimension } from "./scoring"; + +interface JuryReportModel { + companyName: string; + generatedAt: string; + trafficLight: string; + verdict: string; + companyFacts: Array<{ label: string; value: string }>; + dimensions: ScoringDimension[]; + differentiationProfile: DifferentiationProfile; + credibilityProfile: CredibilityProfile; + strengths: ReportFinding[]; + concerns: ReportFinding[]; + improvementPotentials: string[]; + missingDataWarnings: string[]; + swot: { + staerken: string[]; + schwaechen: string[]; + chancen: string[]; + risiken: string[]; + }; +} + +interface ReportFinding { + dimension: string; + name: string; + color: string; + text: string; +} + +interface DifferentiationProfile { + uniqueSellingPoint: string; + category: string; + evidenceStrength: "hoch" | "mittel" | "gering"; + comparability: "einzigartig" | "selten" | "häufig"; + signals: string[]; +} + +interface CredibilityProfile { + hardMetricCount: number; + examplesOrCaseEvidence: "vorhanden" | "nicht vorhanden"; + externalVerifiability: "hoch" | "mittel" | "gering"; + answerConsistency: "hoch" | "mittel" | "niedrig"; + signals: string[]; +} + +interface QuestionSummaryRow { + label: string; + question: string; + originalAnswer: string; + aiAnswer: string; +} + +export const AI_DISCLAIMER_TITLE = "Wichtiger Hinweis"; +export const AI_DISCLAIMER_TEXT = + "Die KI-generierten Zusammenfassungen, Bewertungen und Scoring-Ergebnisse können aufgrund nicht-deterministischen Modellverhaltens zwischen Durchläufen variieren. KI-Systeme können außerdem halluzinieren, Inhalte falsch gewichten oder Aussagen erzeugen, die nicht zuverlässig sind. Diese Ergebnisse dürfen nicht als verlässliche Entscheidungsgrundlage vertraut werden und müssen vor jeder Nutzung fachlich geprüft werden."; +const BRAND_LOGO_PATH = join(import.meta.dir, "../../assets/bmp-logo.png"); + +function brandLogoDataUri(): string { + try { + return `data:image/png;base64,${readFileSync(BRAND_LOGO_PATH).toString("base64")}`; + } catch { + return ""; + } +} + +function readPath(value: unknown, path: string[]): unknown { + let current = value; + for (const key of path) { + if (!current || typeof current !== "object" || !(key in current)) { + return undefined; + } + current = (current as Record)[key]; + } + return current; +} + +function asText(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value.trim(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + return ""; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function trafficLightLabel(value: string): string { + if (value === "gruen") return "Grün"; + if (value === "gelb") return "Gelb"; + if (value === "rot") return "Rot"; + if (value === "unbewertbar") return "Unbewertbar"; + return value || "-"; +} + +function renderTrafficSignal(value: string): string { + const color = value === "gruen" || value === "gelb" || value === "rot" ? value : ""; + return ` +
+ +
+ Ampelbewertung + ${escapeHtml(trafficLightLabel(value))} +
+
+ `; +} + +function sentenceFrom(text: string): string[] { + return text + .replaceAll(/\s+/g, " ") + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter(Boolean); +} + +function collectReportText(data: SummaryData): string[] { + const values: string[] = []; + const add = (value: unknown) => { + const text = asText(value); + if (text) values.push(text); + }; + + add(data.ampelbewertung?.begruendung); + for (const warning of data.ampelbewertung?.missingDataWarnings ?? []) add(warning); + for (const items of Object.values(data.swot_analyse ?? {})) { + if (Array.isArray(items)) { + for (const item of items) add(item); + } + } + + for (const dimension of data.ampelbewertung?.dimensionen ?? []) { + add(dimension.name); + for (const subcriterion of dimension.subcriteria) { + add(subcriterion.name); + add(subcriterion.indikator); + add(subcriterion.evidence); + add(subcriterion.begruendung); + add(subcriterion.missingReason); + } + } + + for (const group of [data.fragen, data.kriterium]) { + for (const entry of Object.values(group ?? {})) { + add(entry.label); + add(entry.antwort); + add(entry.zusammenfassung); + for (const frage of entry.fragen ?? []) { + add(frage.text); + add(frage.antwort); + } + } + } + + return values; +} + +function collectMatchingSignals(texts: string[], pattern: RegExp, limit = 3): string[] { + const seen = new Set(); + const signals: string[] = []; + + for (const text of texts) { + for (const sentence of sentenceFrom(text)) { + if (!pattern.test(sentence)) continue; + pattern.lastIndex = 0; + if (seen.has(sentence)) continue; + seen.add(sentence); + signals.push(sentence); + if (signals.length >= limit) return signals; + } + } + + return signals; +} + +function countHardMetrics(texts: string[]): number { + const matches = texts + .join("\n") + .match(/\b\d+(?:[.,]\d+)?\s?(?:%|Prozent|Mio\.?|Millionen|Mrd\.?|Milliarden|Tsd\.?|Tausend|€|EUR|Jahre|Standorte|Mitarbeitende|Azubi|Auszubildende|Labels|Füllungen|Kunden|p\.a\.)\b/gi); + return matches?.length ?? 0; +} + +function averageConfidence(data: SummaryData): number | undefined { + const values: number[] = []; + for (const dimension of data.ampelbewertung?.dimensionen ?? []) { + for (const subcriterion of dimension.subcriteria) { + values.push(subcriterion.confidence); + } + } + if (!values.length) return undefined; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function buildDifferentiationProfile(data: SummaryData): DifferentiationProfile { + const texts = collectReportText(data); + const uniqueSignals = collectMatchingSignals( + texts, + /\b(weltmarktführer|marktführer|einzigartig|einziger|einzige|alleinstell|revolution|patent|führend|integriert|innovativ|skalierbar|plattform|ki|künstliche intelligenz)\b/gi, + ); + const fallbackSignals = collectMatchingSignals(texts, /\b(innovation|nachhaltigkeit|resilienz|ausbildung|netzwerk|zertifiz|iso)\b/gi); + const signals = uniqueSignals.length ? uniqueSignals : fallbackSignals; + const coreSentence = signals[0] ?? data.ampelbewertung?.begruendung ?? "Kein klares Alleinstellungsmerkmal ableitbar."; + const combined = signals.join(" ").toLowerCase(); + + let category = "Marktstellung"; + if (/\b(innovation|ki|künstliche intelligenz|plattform|patent|technologie|digital)\b/i.test(combined)) { + category = "Innovation"; + } else if (/\b(nachhaltigkeit|ressourcen|co2|umwelt|sozial)\b/i.test(combined)) { + category = "Nachhaltigkeit"; + } else if (/\b(integration|mitarbeiter|ausbildung|kultur|diversity)\b/i.test(combined)) { + category = "Integration / Kultur"; + } + + const hardMetricCount = countHardMetrics(texts); + const evidenceStrength = hardMetricCount >= 5 || uniqueSignals.length >= 2 ? "hoch" : signals.length ? "mittel" : "gering"; + const comparability = /\b(einzigartig|einziger|einzige|weltmarktführer)\b/i.test(combined) + ? "einzigartig" + : /\b(marktführer|revolution|patent|führend)\b/i.test(combined) + ? "selten" + : "häufig"; + + return { + uniqueSellingPoint: coreSentence, + category, + evidenceStrength, + comparability, + signals, + }; +} + +function buildCredibilityProfile(data: SummaryData): CredibilityProfile { + const texts = collectReportText(data); + const hardMetricCount = countHardMetrics(texts); + const evidenceSignals = collectMatchingSignals( + texts, + /\b(\d+(?:[.,]\d+)?\s?(?:%|Mio\.?|Millionen|€|EUR)|case|referenz|kunde|projekt|zertifiz|iso|audit|ranking|award|preis|wachstum|umsatz)\b/gi, + 4, + ); + const externalSignals = collectMatchingSignals( + texts, + /\b(iso|zertifiz|audit|ranking|award|preis|verband|mitgliedschaft|referenz|kunde|standard)\b/gi, + 3, + ); + const confidence = averageConfidence(data); + const missingCount = data.ampelbewertung?.missingDataWarnings?.length ?? 0; + + return { + hardMetricCount, + examplesOrCaseEvidence: evidenceSignals.length ? "vorhanden" : "nicht vorhanden", + externalVerifiability: externalSignals.length >= 2 ? "hoch" : externalSignals.length ? "mittel" : "gering", + answerConsistency: confidence == null + ? "mittel" + : confidence >= 0.8 && missingCount <= 6 + ? "hoch" + : confidence >= 0.65 && missingCount <= 12 + ? "mittel" + : "niedrig", + signals: evidenceSignals, + }; +} + +function buildCompanyFacts(data: SummaryData): Array<{ label: string; value: string }> { + const candidates: Array<[string, string[]]> = [ + ["Branche", ["kontakt", "unternehmen", "branche"]], + ["Rechtsform", ["kontakt", "unternehmen", "rechtsform"]], + ["PLZ", ["kontakt", "unternehmen", "plz"]], + ["Regierungsbezirk", ["kontakt", "unternehmen", "regierungsbezirk"]], + ["Gründungsjahr", ["unternehmen", "gruendungsjahr"]], + ["Mitarbeitende", ["unternehmen", "anzahl_mitarbeiter"]], + ["Auszubildende", ["unternehmen", "anzahl_azubi"]], + ["Standorte Deutschland", ["unternehmen", "standorte_deutschland"]], + ["Standorte Ausland", ["unternehmen", "standorte_ausland"]], + ]; + + return candidates + .map(([label, path]) => ({ label, value: asText(readPath(data, path)) })) + .filter((entry) => entry.value); +} + +function collectFindings(ampel: Ampelbewertung | undefined): { + strengths: ReportFinding[]; + concerns: ReportFinding[]; +} { + const strengths: ReportFinding[] = []; + const concerns: ReportFinding[] = []; + + for (const dimension of ampel?.dimensionen ?? []) { + for (const subcriterion of dimension.subcriteria) { + const text = subcriterion.evidence || subcriterion.begruendung || subcriterion.missingReason || ""; + if (!text.trim()) continue; + + const finding: ReportFinding = { + dimension: dimension.name, + name: subcriterion.name, + color: subcriterion.farbe, + text, + }; + + if (subcriterion.farbe === "gruen") { + strengths.push(finding); + } else if (subcriterion.farbe === "rot" || subcriterion.farbe === "gelb" || subcriterion.farbe === "unbewertbar") { + concerns.push(finding); + } + } + } + + return { + strengths: strengths.slice(0, 6), + concerns: concerns.slice(0, 6), + }; +} + +function buildJuryReportModel(data: SummaryData): JuryReportModel { + const ampel = data.ampelbewertung; + const companyName = + asText(readPath(data, ["kontakt", "unternehmen", "name"])) || + asText(readPath(data, ["unternehmen", "name"])) || + "Unternehmen"; + const findings = collectFindings(ampel); + + return { + companyName, + generatedAt: formatDate(data._summarizedAt ?? new Date().toISOString()), + trafficLight: ampel?.farbe ?? "", + verdict: ampel?.begruendung ?? "Keine Bewertung vorhanden.", + companyFacts: buildCompanyFacts(data), + dimensions: ampel?.dimensionen ?? [], + differentiationProfile: buildDifferentiationProfile(data), + credibilityProfile: buildCredibilityProfile(data), + strengths: findings.strengths, + concerns: findings.concerns, + improvementPotentials: [ + ...(data.swot_analyse?.schwaechen ?? []), + ...(data.swot_analyse?.risiken ?? []), + ...(ampel?.missingDataWarnings ?? []), + ].slice(0, 8), + missingDataWarnings: ampel?.missingDataWarnings?.slice(0, 10) ?? [], + swot: { + staerken: data.swot_analyse?.staerken ?? [], + schwaechen: data.swot_analyse?.schwaechen ?? [], + chancen: data.swot_analyse?.chancen ?? [], + risiken: data.swot_analyse?.risiken ?? [], + }, + }; +} + +function renderList(items: string[], emptyText: string): string { + if (!items.length) return `

${escapeHtml(emptyText)}

`; + return `
    ${items.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
`; +} + +function renderFindings(items: ReportFinding[], emptyText: string): string { + if (!items.length) return `

${escapeHtml(emptyText)}

`; + return items + .map((item) => ` +
+
+ ${escapeHtml(item.dimension)} + ${escapeHtml(trafficLightLabel(item.color))} +
+

${escapeHtml(item.name)}

+

${escapeHtml(item.text)}

+
+ `) + .join(""); +} + +function normalizedScoreWidth(score: number): string { + return `${Math.max(0, Math.min(100, score))}%`; +} + +function renderScoringBars(dimensions: ScoringDimension[]): string { + if (!dimensions.length) { + return `

Keine Scoring-Dimensionen vorhanden.

`; + } + + return ` +
+ ${dimensions.map((dimension) => ` +
+

${escapeHtml(dimension.name)}

+ ${escapeHtml(trafficLightLabel(dimension.farbe))} +
+
+
+
+
+ ${escapeHtml(String(dimension.weight))}% +
+ `).join("")} +
+ `; +} + +function collectQuestionSummaryRows(data: SummaryData): QuestionSummaryRow[] { + const rows: QuestionSummaryRow[] = []; + const addRows = (entries: Record; antwort?: string; zusammenfassung?: string }> | undefined) => { + for (const [key, entry] of Object.entries(entries ?? {})) { + const label = entry.label?.trim() || key; + const questions = entry.fragen?.length ? entry.fragen : [{ text: label }]; + const hasMultipleQuestions = questions.length > 1; + const originalAnswer = entry.antwort?.trim() || "Keine Originalantwort vorhanden."; + + for (const question of questions) { + const text = question.text?.trim(); + if (!text) continue; + rows.push({ + label, + question: text, + originalAnswer, + aiAnswer: hasMultipleQuestions + ? question.antwort?.trim() || "Keine KI-Zuordnung zu dieser Frage vorhanden." + : entry.zusammenfassung?.trim() || question.antwort?.trim() || "Keine KI-Antwort vorhanden.", + }); + } + } + }; + + addRows(data.fragen); + addRows(data.kriterium); + return rows; +} + +function estimateQuestionRowUnits(row: QuestionSummaryRow): number { + const questionLines = Math.ceil(row.question.length / 38); + const answerLines = Math.ceil(row.originalAnswer.length / 58); + const aiAnswerLines = Math.ceil(row.aiAnswer.length / 58); + return Math.max(5, Math.max(questionLines, answerLines, aiAnswerLines) + 2); +} + +function paginateQuestionRows(rows: QuestionSummaryRow[]): QuestionSummaryRow[][] { + const pages: QuestionSummaryRow[][] = []; + let current: QuestionSummaryRow[] = []; + let units = 0; + const maxUnits = 38; + + for (const row of rows) { + const rowUnits = estimateQuestionRowUnits(row); + if (current.length && units + rowUnits > maxUnits) { + pages.push(current); + current = []; + units = 0; + } + current.push(row); + units += rowUnits; + } + + if (current.length) pages.push(current); + return pages; +} + +function renderQuestionRows(rows: QuestionSummaryRow[]): string { + return rows + .map((row) => ` +
+
+ ${escapeHtml(row.label)} +

${escapeHtml(row.question)}

+
+
+ Originalantwort +

${escapeHtml(row.originalAnswer)}

+
+
+ KI-Antwort +

${escapeHtml(row.aiAnswer)}

+
+
+ `) + .join(""); +} + +export function renderQuestionSummaryReportHtml(data: SummaryData): string { + const model = buildJuryReportModel(data); + const rows = collectQuestionSummaryRows(data); + const pages = paginateQuestionRows(rows); + const questionPages = pages.length ? pages : [[]]; + const title = `Fragen, Originalantworten & KI-Antworten - ${model.companyName}`; + + return ` + + + + + ${escapeHtml(title)} + + + +
+ ${questionPages.map((pageRows, index) => ` +
+
+
+
Fragen, Originalantworten & KI-Antworten · ${escapeHtml(model.generatedAt)}
+

${escapeHtml(model.companyName)}

+
+
Seite ${index + 1} / ${questionPages.length}
+
+
+ ${pageRows.length ? renderQuestionRows(pageRows) : `

Keine Fragen vorhanden.

`} +
+ ${index === questionPages.length - 1 ? ` + + ` : ""} +
+ `).join("")} +
+ +`; +} + +export function renderJuryReportHtml(data: SummaryData): string { + const model = buildJuryReportModel(data); + const title = `Jury-Report - ${model.companyName}`; + const logoSrc = brandLogoDataUri(); + + return ` + + + + + ${escapeHtml(title)} + + + +
+
+ ${logoSrc ? `` : ""} +
Jury-Report · ${escapeHtml(model.generatedAt)}
+
+
+

${escapeHtml(model.companyName)}

+

${escapeHtml(model.verdict)}

+
+ ${renderTrafficSignal(model.trafficLight)} +
+
+ ${model.companyFacts.map((fact) => ` +
+ ${escapeHtml(fact.label)} + ${escapeHtml(fact.value)} +
+ `).join("")} +
+
+ +
+
+
+

Differenzierungsprofil

+

${escapeHtml(model.differentiationProfile.uniqueSellingPoint)}

+ + + + + + + + + + + + + + + +
Kategorie${escapeHtml(model.differentiationProfile.category)}
Belegstärke${escapeHtml(model.differentiationProfile.evidenceStrength)}
Vergleichbarkeit${escapeHtml(model.differentiationProfile.comparability)}
+
+

Ableitbare Signale

+ ${renderList(model.differentiationProfile.signals, "Keine expliziten Differenzierungssignale gefunden.")} +
+
+ +
+

Belegbarkeit / Glaubwürdigkeit

+ + + + + + + + + + + + + + + + + + + +
Anzahl harter Kennzahlen${model.credibilityProfile.hardMetricCount}
Beispiele / Case Evidence${escapeHtml(model.credibilityProfile.examplesOrCaseEvidence)}
Externe Validierbarkeit${escapeHtml(model.credibilityProfile.externalVerifiability)}
Konsistenz über Antworten${escapeHtml(model.credibilityProfile.answerConsistency)}
+
+

Ableitbare Signale

+ ${renderList(model.credibilityProfile.signals, "Keine harten Kennzahlen oder externen Belege gefunden.")} +
+
+
+
+ +
+
+
+

Stärkste Argumente

+ ${renderFindings(model.strengths, "Keine belastbaren Stärken vorhanden.")} +
+
+

Kritische Punkte

+ ${renderFindings(model.concerns, "Keine kritischen Punkte vorhanden.")} +
+
+
+ +
+
+

Scoring-Überblick

+ ${renderScoringBars(model.dimensions)} +
+
+

Verbesserungspotenziale

+ ${renderList(model.improvementPotentials, "Keine Verbesserungspotenziale ableitbar.")} +
+
+ +
+
+
+

SWOT: Stärken

+ ${renderList(model.swot.staerken, "Keine Stärken vorhanden.")} +
+
+

SWOT: Schwächen

+ ${renderList(model.swot.schwaechen, "Keine Schwächen vorhanden.")} +
+
+

SWOT: Chancen

+ ${renderList(model.swot.chancen, "Keine Chancen vorhanden.")} +
+
+

SWOT: Risiken

+ ${renderList(model.swot.risiken, "Keine Risiken vorhanden.")} +
+
+
+ +
+
+

Fehlende Daten

+ ${renderList(model.missingDataWarnings, "Keine Warnungen zu fehlenden Daten vorhanden.")} + +
+
+
+ +`; +} + +function chromeCandidates(): string[] { + return [ + process.env.CHROME_BIN ?? "", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "google-chrome", + "chromium", + "chromium-browser", + ].filter(Boolean); +} + +async function commandExists(command: string): Promise { + if (command.includes("/")) return Bun.file(command).exists(); + const proc = Bun.spawn(["/bin/sh", "-lc", `command -v ${command}`], { + stdout: "ignore", + stderr: "ignore", + }); + return (await proc.exited) === 0; +} + +async function findChrome(): Promise { + for (const candidate of chromeCandidates()) { + if (await commandExists(candidate)) return candidate; + } + throw new Error("Could not find Chrome/Chromium for PDF generation. Set CHROME_BIN to a Chrome executable."); +} + +async function renderPdfWithPlaywright(htmlPath: string, pdfPath: string): Promise { + await rm(pdfPath, { force: true }); + const { chromium } = await import("playwright"); + const browser = await chromium.launch({ headless: true }); + + try { + const page = await browser.newPage(); + await page.goto(pathToFileURL(htmlPath).toString(), { waitUntil: "load" }); + await page.emulateMedia({ media: "print" }); + await page.pdf({ + path: pdfPath, + format: "A4", + printBackground: true, + preferCSSPageSize: true, + margin: { + top: "0", + right: "0", + bottom: "0", + left: "0", + }, + }); + } finally { + await browser.close(); + } + + if (!(await hasNonEmptyFile(pdfPath))) { + throw new Error("Playwright PDF generation finished without creating a PDF."); + } +} + +async function renderPdfWithSystemChrome(htmlPath: string, pdfPath: string): Promise { + const chrome = await findChrome(); + const userDataDir = await mkdtemp(join(tmpdir(), "bmp-report-chrome-")); + await rm(pdfPath, { force: true }); + const proc = Bun.spawn([ + chrome, + "--headless=new", + "--disable-gpu", + "--disable-background-networking", + "--disable-component-update", + "--disable-sync", + "--no-sandbox", + "--no-first-run", + "--no-default-browser-check", + "--allow-file-access-from-files", + "--print-to-pdf-no-header", + `--user-data-dir=${userDataDir}`, + `--print-to-pdf=${pdfPath}`, + pathToFileURL(htmlPath).toString(), + ], { + stdout: "pipe", + stderr: "pipe", + }); + + const stdoutPromise = new Response(proc.stdout).text(); + const stderrPromise = new Response(proc.stderr).text(); + let exitCode: number | "pdf-ready" | "timeout" = await Promise.race([ + proc.exited, + waitForPdf(pdfPath, 20_000), + ]); + + if (exitCode === "pdf-ready" || exitCode === "timeout") { + proc.kill("SIGKILL"); + exitCode = await proc.exited.catch(() => 0); + } + + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + await rm(userDataDir, { recursive: true, force: true }); + + if (exitCode !== 0 && !(await hasNonEmptyFile(pdfPath))) { + throw new Error(`Chrome PDF generation failed: ${stderr || stdout || `exit code ${exitCode}`}`); + } +} + +async function renderPdfFromHtml(htmlPath: string, pdfPath: string): Promise { + try { + await renderPdfWithPlaywright(htmlPath, pdfPath); + return; + } catch (playwrightError) { + try { + await renderPdfWithSystemChrome(htmlPath, pdfPath); + return; + } catch (chromeError) { + throw new Error([ + "PDF generation failed with Playwright Chromium and system Chrome fallback.", + "For VPS deployment, run `bun run playwright:install` after `bun install`.", + "On minimal Linux servers, also run `bun run playwright:install-deps` or install the equivalent system packages.", + `Playwright error: ${String(playwrightError)}`, + `Chrome fallback error: ${String(chromeError)}`, + ].join("\n")); + } + } +} + +async function hasNonEmptyFile(path: string): Promise { + const file = Bun.file(path); + return (await file.exists()) && file.size > 0; +} + +async function waitForPdf(path: string, timeoutMs: number): Promise<"pdf-ready" | "timeout"> { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (await hasNonEmptyFile(path)) return "pdf-ready"; + await Bun.sleep(250); + } + return "timeout"; +} + +export async function writeJuryReport( + stem: string, + data: SummaryData, + outDir: string, +): Promise { + const htmlPath = join(outDir, `${stem}.report.html`); + const pdfPath = join(outDir, `${stem}.pdf`); + const questionsHtmlPath = join(outDir, `${stem}.questions.html`); + const questionsPdfPath = join(outDir, `${stem}.questions.pdf`); + + await Bun.write(htmlPath, renderJuryReportHtml(data)); + await renderPdfFromHtml(htmlPath, pdfPath); + await Bun.write(questionsHtmlPath, renderQuestionSummaryReportHtml(data)); + await renderPdfFromHtml(questionsHtmlPath, questionsPdfPath); + + return [htmlPath, pdfPath, questionsHtmlPath, questionsPdfPath]; +} diff --git a/packages/summarizer/scoring-model.ts b/packages/summarizer/scoring-model.ts new file mode 100644 index 0000000..7395020 --- /dev/null +++ b/packages/summarizer/scoring-model.ts @@ -0,0 +1,318 @@ +export type Ampelfarbe = "gruen" | "gelb" | "rot"; +export type ScoringFarbe = Ampelfarbe | "unbewertbar"; + +export interface ScoringThresholds { + gruen: string; + gelb: string; + rot: string; +} + +export interface SubcriterionDefinition { + id: string; + name: string; + operationalisierung: string; + indikator: string; + thresholds: ScoringThresholds; + weight: number; +} + +export interface ScoringDimensionDefinition { + id: string; + name: string; + weight: number; + subcriteria: SubcriterionDefinition[]; +} + +export const SCORING_MODEL: ScoringDimensionDefinition[] = [ + { + id: "resilienz", + name: "Resilienz", + weight: 25, + subcriteria: [ + { + id: "resilienz_finanzielle_stabilitaet", + name: "Finanzielle Stabilitaet", + operationalisierung: "Faehigkeit zur Ueberbrueckung von Krisen", + indikator: "Eigenkapitalquote / Liquiditaetsreserve", + thresholds: { gruen: ">30% EK oder >6 Monate Liquiditaet", gelb: "15-30% / 3-6 Monate", rot: "<15% / <3 Monate" }, + weight: 30, + }, + { + id: "resilienz_reaktionsfaehigkeit", + name: "Reaktionsfaehigkeit", + operationalisierung: "Geschwindigkeit bei Anpassung an Marktveraenderungen", + indikator: "Zeit bis Umsetzung strategischer Anpassung", + thresholds: { gruen: "<6 Monate", gelb: "6-12 Monate", rot: ">12 Monate" }, + weight: 20, + }, + { + id: "resilienz_risikomanagement", + name: "Risikomanagement", + operationalisierung: "Strukturierte Risikoerkennung", + indikator: "Existenz + Reifegrad RMS", + thresholds: { gruen: "integriert & regelmaessig genutzt", gelb: "teilweise vorhanden", rot: "kein System" }, + weight: 15, + }, + { + id: "resilienz_markt_trendmonitoring", + name: "Markt- & Trendmonitoring", + operationalisierung: "Frueherkennung von Veraenderungen", + indikator: "Anzahl systematischer Analysen p.a.", + thresholds: { gruen: ">4 p.a. + strukturiert", gelb: "1-4 p.a.", rot: "ad hoc / keine" }, + weight: 10, + }, + { + id: "resilienz_stakeholder_integration", + name: "Stakeholder-Integration", + operationalisierung: "Einbindung von Kunden, MA, Lieferanten", + indikator: "strukturierte Feedbackprozesse", + thresholds: { gruen: "systematisch & regelmaessig", gelb: "punktuell", rot: "nicht vorhanden" }, + weight: 10, + }, + { + id: "resilienz_netzwerk_kooperation", + name: "Netzwerk & Kooperation", + operationalisierung: "Einbindung in Oekosystem", + indikator: "Anzahl aktiver Kooperationen", + thresholds: { gruen: ">5 aktiv", gelb: "2-5", rot: "<2" }, + weight: 5, + }, + { + id: "resilienz_diversifikation", + name: "Diversifikation", + operationalisierung: "Risikostreuung (Maerkte/Produkte)", + indikator: "Umsatzanteile", + thresholds: { gruen: "kein Segment >40%", gelb: "40-70%", rot: ">70%" }, + weight: 10, + }, + ], + }, + { + id: "innovation", + name: "Innovation", + weight: 25, + subcriteria: [ + { + id: "innovation_output", + name: "Innovationsoutput", + operationalisierung: "Marktrelevante Innovationen", + indikator: "% Umsatz mit neuen Produkten (<5 Jahre)", + thresholds: { gruen: ">25%", gelb: "10-25%", rot: "<10%" }, + weight: 25, + }, + { + id: "innovation_fue_investitionen", + name: "F&E / Investitionen", + operationalisierung: "Zukunftsinvestitionen", + indikator: "F&E-Quote / Investitionsquote", + thresholds: { gruen: ">5%", gelb: "2-5%", rot: "<2%" }, + weight: 15, + }, + { + id: "innovation_trendadaption", + name: "Trendadaption", + operationalisierung: "Reaktion auf Megatrends", + indikator: "dokumentierte Strategien", + thresholds: { gruen: "proaktiv + umgesetzt", gelb: "erkannt", rot: "ignoriert" }, + weight: 15, + }, + { + id: "innovation_digitalisierung", + name: "Digitalisierung", + operationalisierung: "Digitale Reife", + indikator: "Digitalisierungsgrad Prozesse", + thresholds: { gruen: "hoch integriert", gelb: "teilweise", rot: "gering" }, + weight: 15, + }, + { + id: "innovation_kooperationen", + name: "Kooperationen Innovation", + operationalisierung: "Externe Innovationsnetzwerke", + indikator: "Anzahl Kooperationen", + thresholds: { gruen: ">5", gelb: "2-5", rot: "<2" }, + weight: 10, + }, + { + id: "innovation_skalierbarkeit", + name: "Skalierbarkeit", + operationalisierung: "Uebertragbarkeit Geschaeftsmodell", + indikator: "Anteil skalierbarer Umsaetze", + thresholds: { gruen: ">50%", gelb: "20-50%", rot: "<20%" }, + weight: 10, + }, + { + id: "innovation_geschwindigkeit", + name: "Geschwindigkeit Innovation", + operationalisierung: "Time-to-Market", + indikator: "Dauer von Idee zu Markteinfuehrung", + thresholds: { gruen: "<12 Monate", gelb: "12-24 Monate", rot: ">24 Monate" }, + weight: 10, + }, + ], + }, + { + id: "nachhaltigkeit", + name: "Nachhaltigkeit", + weight: 20, + subcriteria: [ + { + id: "nachhaltigkeit_oekologisch", + name: "Oekologische Nachhaltigkeit", + operationalisierung: "Umweltwirkung", + indikator: "CO2-Reduktion / Massnahmen", + thresholds: { gruen: "klare Ziele + Fortschritt", gelb: "Massnahmen vorhanden", rot: "keine Strategie" }, + weight: 25, + }, + { + id: "nachhaltigkeit_soziale_verantwortung", + name: "Soziale Verantwortung", + operationalisierung: "Mitarbeiter & Gesellschaft", + indikator: "Fluktuation / Engagement", + thresholds: { gruen: "<5% Fluktuation + Programme", gelb: "5-10%", rot: ">10%" }, + weight: 20, + }, + { + id: "nachhaltigkeit_werteorientierung", + name: "Werteorientierung", + operationalisierung: "Purpose / Leitbild", + indikator: "dokumentierte Werte + Umsetzung", + thresholds: { gruen: "klar verankert", gelb: "teilweise", rot: "nicht vorhanden" }, + weight: 15, + }, + { + id: "nachhaltigkeit_regionale_verantwortung", + name: "Regionale Verantwortung", + operationalisierung: "Beitrag Standort Bayern", + indikator: "Anteil regionale Wertschoepfung", + thresholds: { gruen: ">50%", gelb: "20-50%", rot: "<20%" }, + weight: 15, + }, + { + id: "nachhaltigkeit_lieferkette", + name: "Nachhaltige Lieferkette", + operationalisierung: "ESG in Beschaffung", + indikator: "Anteil gepruefter Lieferanten", + thresholds: { gruen: ">80%", gelb: "40-80%", rot: "<40%" }, + weight: 15, + }, + { + id: "nachhaltigkeit_ressourceneffizienz", + name: "Ressourceneffizienz", + operationalisierung: "Energie-/Materialeffizienz", + indikator: "Reduktionsrate p.a.", + thresholds: { gruen: ">5%", gelb: "1-5%", rot: "<1%" }, + weight: 10, + }, + ], + }, + { + id: "erfolg", + name: "Erfolg", + weight: 20, + subcriteria: [ + { + id: "erfolg_umsatzwachstum", + name: "Umsatzwachstum", + operationalisierung: "Entwicklung", + indikator: "CAGR (5 Jahre)", + thresholds: { gruen: ">5%", gelb: "0-5%", rot: "<0%" }, + weight: 25, + }, + { + id: "erfolg_profitabilitaet", + name: "Profitabilitaet", + operationalisierung: "Wirtschaftlichkeit", + indikator: "EBIT-Marge", + thresholds: { gruen: ">10%", gelb: "5-10%", rot: "<5%" }, + weight: 20, + }, + { + id: "erfolg_marktposition", + name: "Marktposition", + operationalisierung: "Wettbewerbsfaehigkeit", + indikator: "Marktanteil / Ranking", + thresholds: { gruen: "Top 3", gelb: "Top 10", rot: "sonst" }, + weight: 15, + }, + { + id: "erfolg_krisenstabilitaet", + name: "Krisenstabilitaet", + operationalisierung: "Stabilitaet ueber Zeit", + indikator: "Umsatzvolatilitaet", + thresholds: { gruen: "stabil", gelb: "moderat", rot: "stark schwankend" }, + weight: 15, + }, + { + id: "erfolg_internationalisierung", + name: "Internationalisierung", + operationalisierung: "Markterschliessung", + indikator: "Auslandsumsatzanteil", + thresholds: { gruen: ">40%", gelb: "10-40%", rot: "<10%" }, + weight: 10, + }, + { + id: "erfolg_kundenbindung", + name: "Kundenbindung", + operationalisierung: "Loyalitaet", + indikator: "Wiederkaufsrate / NPS", + thresholds: { gruen: "hoch", gelb: "mittel", rot: "niedrig" }, + weight: 15, + }, + ], + }, + { + id: "mitarbeiter_kultur", + name: "Mitarbeiter/Kultur", + weight: 10, + subcriteria: [ + { + id: "mitarbeiter_bindung", + name: "Mitarbeiterbindung", + operationalisierung: "Attraktivitaet Arbeitgeber", + indikator: "Fluktuation", + thresholds: { gruen: "<5%", gelb: "5-10%", rot: ">10%" }, + weight: 25, + }, + { + id: "mitarbeiter_ausbildung_nachwuchs", + name: "Ausbildung & Nachwuchs", + operationalisierung: "Talentfoerderung", + indikator: "Ausbildungsquote", + thresholds: { gruen: ">5%", gelb: "2-5%", rot: "<2%" }, + weight: 20, + }, + { + id: "mitarbeiter_zufriedenheit", + name: "Mitarbeiterzufriedenheit", + operationalisierung: "Engagement", + indikator: "Umfragen / Scores", + thresholds: { gruen: ">80%", gelb: "60-80%", rot: "<60%" }, + weight: 20, + }, + { + id: "mitarbeiter_fuehrung_kultur", + name: "Fuehrung & Kultur", + operationalisierung: "Wertebasierte Fuehrung", + indikator: "dokumentiert + gelebt", + thresholds: { gruen: "klar sichtbar", gelb: "teilweise", rot: "nicht vorhanden" }, + weight: 15, + }, + { + id: "mitarbeiter_weiterbildung", + name: "Weiterbildung", + operationalisierung: "Kompetenzaufbau", + indikator: "Stunden pro MA/Jahr", + thresholds: { gruen: ">40h", gelb: "20-40h", rot: "<20h" }, + weight: 10, + }, + { + id: "mitarbeiter_diversity_integration", + name: "Diversity & Integration", + operationalisierung: "Vielfalt", + indikator: "Anteil Programme / Kennzahlen", + thresholds: { gruen: "aktiv gemanagt", gelb: "punktuell", rot: "keine" }, + weight: 10, + }, + ], + }, +]; diff --git a/packages/summarizer/scoring.test.ts b/packages/summarizer/scoring.test.ts new file mode 100644 index 0000000..1a14470 --- /dev/null +++ b/packages/summarizer/scoring.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "bun:test"; +import { SCORING_MODEL } from "./scoring-model"; +import { calculateScoringResult, deriveTrafficLight, type LlmScoringAssessment } from "./scoring"; + +function assessmentWithColor(farbe: "gruen" | "gelb" | "rot" | "unbewertbar"): LlmScoringAssessment { + return { + ausschlussgruende: [], + dimensionen: SCORING_MODEL.map((dimension) => ({ + id: dimension.id, + subcriteria: dimension.subcriteria.map((subcriterion) => ({ + id: subcriterion.id, + farbe, + evidence: farbe === "unbewertbar" ? "" : "Test evidence", + begruendung: "Test begruendung", + confidence: 0.9, + missingReason: farbe === "unbewertbar" ? "Keine belastbaren Angaben" : "", + })), + })), + }; +} + +test("calculates a green weighted score when all criteria are green", () => { + const scoring = calculateScoringResult(SCORING_MODEL, assessmentWithColor("gruen")); + + expect(scoring.gesamtScore).toBe(100); + expect(scoring.farbe).toBe("gruen"); + expect(scoring.unbewertbareKriterien).toBe(0); +}); + +test("scores against the maximum achievable by assessable dimensions", () => { + const assessment = assessmentWithColor("gruen"); + for (const subcriterion of assessment.dimensionen[4]!.subcriteria) { + subcriterion.farbe = "unbewertbar"; + subcriterion.evidence = ""; + subcriterion.missingReason = "Keine belastbaren Angaben"; + } + + const scoring = calculateScoringResult(SCORING_MODEL, assessment); + + expect(scoring.gesamtScore).toBe(100); + expect(scoring.farbe).toBe("gruen"); + expect(scoring.dimensionen[4]!.score).toBe(0); + expect(scoring.dimensionen[4]!.scorableWeight).toBe(0); + expect(scoring.unbewertbareKriterien).toBeGreaterThan(0); +}); + +test("normalizes a partially assessable dimension by answered criterion weight", () => { + const assessment = assessmentWithColor("gruen"); + const innovation = assessment.dimensionen.find((dimension) => dimension.id === "innovation")!; + for (const subcriterion of innovation.subcriteria) { + if (subcriterion.id === "innovation_output") { + subcriterion.farbe = "unbewertbar"; + subcriterion.evidence = ""; + subcriterion.missingReason = "Keine belastbaren Angaben"; + } + } + + const scoring = calculateScoringResult(SCORING_MODEL, assessment); + const innovationScore = scoring.dimensionen.find((dimension) => dimension.id === "innovation")!; + + expect(innovationScore.scorableWeight).toBe(75); + expect(innovationScore.score).toBe(100); + expect(scoring.gesamtScore).toBe(100); + expect(scoring.farbe).toBe("gruen"); +}); + +test("derives red when automatic exclusion reasons are present", () => { + const scoring = calculateScoringResult(SCORING_MODEL, assessmentWithColor("gruen")); + + expect(deriveTrafficLight(scoring, ["Stiftung als Bewerber"])).toBe("rot"); +}); + +test("keeps low-scoring non-excluded applications yellow rather than discarded", () => { + const scoring = calculateScoringResult(SCORING_MODEL, assessmentWithColor("rot")); + + expect(scoring.gesamtScore).toBe(0); + expect(scoring.farbe).toBe("gelb"); + expect(deriveTrafficLight(scoring, [])).toBe("gelb"); +}); diff --git a/packages/summarizer/scoring.ts b/packages/summarizer/scoring.ts new file mode 100644 index 0000000..f7da319 --- /dev/null +++ b/packages/summarizer/scoring.ts @@ -0,0 +1,172 @@ +import type { Ampelfarbe, ScoringDimensionDefinition, ScoringFarbe } from "./scoring-model"; + +export interface LlmSubcriterionAssessment { + id: string; + farbe: ScoringFarbe; + evidence: string; + begruendung: string; + confidence: number; + missingReason: string; +} + +export interface LlmDimensionAssessment { + id: string; + subcriteria: LlmSubcriterionAssessment[]; +} + +export interface LlmScoringAssessment { + ausschlussgruende: string[]; + dimensionen: LlmDimensionAssessment[]; +} + +export interface ScoredSubcriterion extends LlmSubcriterionAssessment { + name: string; + indikator: string; + weight: number; + score: number | null; + weightedScore: number; +} + +export interface ScoringDimension { + id: string; + name: string; + weight: number; + farbe: ScoringFarbe; + score: number; + weightedScore: number; + scorableWeight: number; + subcriteria: ScoredSubcriterion[]; +} + +export interface ScoringResult { + farbe: Ampelfarbe; + gesamtScore: number; + unbewertbareKriterien: number; + missingDataWarnings: string[]; + dimensionen: ScoringDimension[]; +} + +const COLOR_SCORE: Record = { + gruen: 100, + gelb: 50, + rot: 0, + unbewertbar: null, +}; + +export function normalizeScoringFarbe(value: unknown): ScoringFarbe { + return value === "gruen" || value === "gelb" || value === "rot" || value === "unbewertbar" + ? value + : "unbewertbar"; +} + +export function scoreToFarbe(score: number): Ampelfarbe { + if (score >= 75) return "gruen"; + return "gelb"; +} + +export function calculateScoringResult( + model: ScoringDimensionDefinition[], + assessment: LlmScoringAssessment, +): ScoringResult { + const assessmentByDimension = new Map(assessment.dimensionen.map((dimension) => [dimension.id, dimension])); + let totalWeightedScore = 0; + let unbewertbareKriterien = 0; + const missingDataWarnings: string[] = []; + + const dimensionen = model.map((dimensionDefinition): ScoringDimension => { + const dimensionAssessment = assessmentByDimension.get(dimensionDefinition.id); + const assessmentBySubcriterion = new Map( + (dimensionAssessment?.subcriteria ?? []).map((subcriterion) => [subcriterion.id, subcriterion]), + ); + + let achievedScore = 0; + let scorableWeight = 0; + const subcriteria = dimensionDefinition.subcriteria.map((subcriterionDefinition): ScoredSubcriterion => { + const rawAssessment = assessmentBySubcriterion.get(subcriterionDefinition.id); + const farbe = normalizeScoringFarbe(rawAssessment?.farbe); + const score = COLOR_SCORE[farbe]; + const confidence = Number.isFinite(rawAssessment?.confidence) + ? Math.max(0, Math.min(1, Number(rawAssessment?.confidence))) + : 0; + const missingReason = String(rawAssessment?.missingReason ?? "").trim(); + const evidence = String(rawAssessment?.evidence ?? "").trim(); + + if (score == null) { + unbewertbareKriterien += 1; + if (missingReason) { + missingDataWarnings.push(`${dimensionDefinition.name} / ${subcriterionDefinition.name}: ${missingReason}`); + } + } else { + scorableWeight += subcriterionDefinition.weight; + } + + achievedScore += ((score ?? 0) * subcriterionDefinition.weight) / 100; + + return { + id: subcriterionDefinition.id, + name: subcriterionDefinition.name, + indikator: subcriterionDefinition.indikator, + farbe, + evidence, + begruendung: String(rawAssessment?.begruendung ?? "").trim(), + confidence, + missingReason, + weight: subcriterionDefinition.weight, + score, + weightedScore: 0, + }; + }); + + const roundedScorableWeight = Number(scorableWeight.toFixed(2)); + const roundedDimensionScore = roundedScorableWeight + ? Number(((achievedScore / roundedScorableWeight) * 100).toFixed(2)) + : 0; + const weightedScore = roundedScorableWeight ? (roundedDimensionScore * dimensionDefinition.weight) / 100 : 0; + if (roundedScorableWeight) { + totalWeightedScore += weightedScore; + } + const normalizedSubcriteria = subcriteria.map((subcriterion) => ({ + ...subcriterion, + weightedScore: + subcriterion.score == null || !roundedScorableWeight + ? 0 + : Number(((subcriterion.score * subcriterion.weight) / roundedScorableWeight).toFixed(2)), + })); + + return { + id: dimensionDefinition.id, + name: dimensionDefinition.name, + weight: dimensionDefinition.weight, + farbe: scoreToFarbe(roundedDimensionScore), + score: roundedDimensionScore, + weightedScore: Number(weightedScore.toFixed(2)), + scorableWeight: roundedScorableWeight, + subcriteria: normalizedSubcriteria, + }; + }); + + const scorableDimensionWeight = dimensionen + .filter((dimension) => dimension.scorableWeight > 0) + .reduce((sum, dimension) => sum + dimension.weight, 0); + const gesamtScore = scorableDimensionWeight + ? Number(((totalWeightedScore / scorableDimensionWeight) * 100).toFixed(2)) + : 0; + const hasWeakDimension = dimensionen.some((dimension) => dimension.scorableWeight > 0 && dimension.score < 35); + const rawFarbe = scoreToFarbe(gesamtScore); + const farbe = rawFarbe === "gruen" && hasWeakDimension ? "gelb" : rawFarbe; + + return { + farbe, + gesamtScore, + unbewertbareKriterien, + missingDataWarnings, + dimensionen, + }; +} + +export function deriveTrafficLight( + scoring: ScoringResult, + ausschlussgruende: string[], +): Ampelfarbe { + return ausschlussgruende.length ? "rot" : scoring.farbe; +} diff --git a/packages/summarizer/summarizer.ts b/packages/summarizer/summarizer.ts new file mode 100644 index 0000000..1363457 --- /dev/null +++ b/packages/summarizer/summarizer.ts @@ -0,0 +1,1331 @@ +import { existsSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import OpenAI from "openai"; +import * as XLSX from "xlsx"; +import { SCORING_MODEL, type Ampelfarbe } from "./scoring-model"; +import { + calculateScoringResult, + deriveTrafficLight, + type LlmScoringAssessment, + type ScoringDimension, +} from "./scoring"; +import { + buildLlmDossier, + createPseudonymizer, + type LlmDossier, + type PrivacyAudit, + type Pseudonymizer, +} from "./privacy"; +import { AI_DISCLAIMER_TEXT, AI_DISCLAIMER_TITLE, writeJuryReport } from "./report"; +import { isSafeStem } from "../../security"; +import { dataPath } from "../../deployPaths"; + +export interface FrageMitAntwort { + id?: string; + text: string; + antwort?: string; + confidence?: number; + abgedeckt?: boolean; + quelle?: "pdf_direkt" | "llm_segmentierung"; +} + +export interface SegmentierungsQualitaet { + durchschnitt_confidence: number; + abgedeckte_fragen: number; + gesamt_fragen: number; + niedriges_vertrauen: boolean; +} + +export interface AntwortEintrag { + fieldName?: string; + label?: string; + antwortFormat?: "einzelfrage" | "mehrfachfrage_ein_antwortfeld"; + zuordnungsmodus?: "einzelfrage" | "gemeinsame_antwort" | "llm_segmentiert"; + fragen?: FrageMitAntwort[]; + antwort?: string; + zusammenfassung?: string; + segmentierung?: SegmentierungsQualitaet; +} + +export interface ExtractedData { + kontakt?: Record; + unternehmen?: Record; + fragen?: Record; + kriterium?: Record; + [key: string]: unknown; +} + +export interface Ampelbewertung { + farbe: Ampelfarbe; + gesamtScore?: number; + begruendung: string; + ausschlussgruende?: string[]; + unbewertbareKriterien?: number; + missingDataWarnings?: string[]; + dimensionen?: ScoringDimension[]; +} + +export interface SWOTAnalyse { + staerken: string[]; + schwaechen: string[]; + chancen: string[]; + risiken: string[]; +} + +export interface SummaryData extends ExtractedData { + fragen?: Record; + kriterium?: Record; + ampelbewertung?: Ampelbewertung; + swot_analyse?: SWOTAnalyse; + _privacy?: PrivacyAudit; + _schemaVersion?: number; + _summarizedAt?: string; +} + +export type LlmCallStatus = "running" | "done" | "error"; + +export interface LlmCallTrace { + id: string; + operation: "segmentierung" | "zusammenfassung" | "scoring" | "swot"; + label: string; + model: string; + status: LlmCallStatus; + startedAt: string; + finishedAt?: string; + durationMs?: number; + request: Record; + response?: Record; + error?: string; +} + +export interface SummarizeCompanyOptions { + onLlmCall?: (trace: LlmCallTrace) => void; +} + +const OUTPUTS_DIR = + process.env.BMP_EXTRACTOR_OUTPUTS_DIR ?? + dataPath(join(import.meta.dir, "../extractor/outputs"), "extractor", "outputs"); +const XLSX_TEMPLATE_PATH = join(import.meta.dir, "template.xlsx"); +const OPENROUTER_PRIVACY_PROVIDER = { + data_collection: "deny", + zdr: true, +} as const; + +function createClient(): OpenAI { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) throw new Error("OPENROUTER_API_KEY is not set in environment"); + + return new OpenAI({ + baseURL: "https://openrouter.ai/api/v1", + apiKey, + defaultHeaders: { + "HTTP-Referer": "https://github.com/bmp-rewrite", + "X-Title": "bmp-rewrite summarizer", + }, + }); +} + +function buildQuestionContext(entry: AntwortEintrag, fallbackLabel: string): string { + const frageText = (entry.fragen ?? []).map((frage, index) => `${index + 1}. ${frage.text}`).join("\n"); + const label = entry.label?.trim() || fallbackLabel; + return frageText ? `${label}\nZugehörige Fragen:\n${frageText}` : label; +} + +function jsonSchemaFormat(name: string, schema: Record) { + return { + type: "json_schema", + json_schema: { + name, + strict: true, + schema, + }, + } as never; +} + +function serializeCompletion(completion: unknown): Record { + const value = completion as { + id?: unknown; + model?: unknown; + created?: unknown; + usage?: unknown; + choices?: Array<{ finish_reason?: unknown; message?: unknown }>; + }; + return { + id: value.id, + model: value.model, + created: value.created, + usage: value.usage, + choices: value.choices?.map((choice) => ({ + finish_reason: choice.finish_reason, + message: choice.message, + })), + }; +} + +function beginLlmTrace( + onLlmCall: SummarizeCompanyOptions["onLlmCall"] | undefined, + operation: LlmCallTrace["operation"], + label: string, + model: string, + request: Record, +): LlmCallTrace { + const trace: LlmCallTrace = { + id: crypto.randomUUID(), + operation, + label, + model, + status: "running", + startedAt: new Date().toISOString(), + request, + }; + onLlmCall?.(trace); + return trace; +} + +function finishLlmTrace( + onLlmCall: SummarizeCompanyOptions["onLlmCall"] | undefined, + trace: LlmCallTrace, + result: { response?: unknown; error?: unknown }, +): void { + const finishedAt = new Date(); + const startedAt = new Date(trace.startedAt); + onLlmCall?.({ + ...trace, + status: result.error ? "error" : "done", + finishedAt: finishedAt.toISOString(), + durationMs: finishedAt.getTime() - startedAt.getTime(), + response: result.response ? serializeCompletion(result.response) : undefined, + error: result.error ? String(result.error) : undefined, + }); +} + +async function createStructuredCompletion( + client: OpenAI, + model: string, + messages: Array<{ role: "system" | "user"; content: string }>, + schemaName: string, + schema: Record, + sessionId?: string, + trace?: { + operation: LlmCallTrace["operation"]; + label: string; + onLlmCall?: SummarizeCompanyOptions["onLlmCall"]; + }, +) { + const structuredRequest = { + model, + response_format: jsonSchemaFormat(schemaName, schema), + messages, + provider: OPENROUTER_PRIVACY_PROVIDER, + usage: { include: true }, + session_id: sessionId, + }; + const primaryTrace = trace + ? beginLlmTrace(trace.onLlmCall, trace.operation, trace.label, model, structuredRequest as Record) + : undefined; + try { + const completion = await client.chat.completions.create(structuredRequest as never); + if (primaryTrace) finishLlmTrace(trace?.onLlmCall, primaryTrace, { response: completion }); + return completion; + } catch (error) { + const message = String(error); + if (!message.includes("400") && !message.toLowerCase().includes("provider returned error")) { + if (primaryTrace) finishLlmTrace(trace?.onLlmCall, primaryTrace, { error }); + throw error; + } + if (primaryTrace) finishLlmTrace(trace?.onLlmCall, primaryTrace, { error }); + + const fallbackRequest = { + model, + response_format: { type: "json_object" } as never, + messages, + provider: OPENROUTER_PRIVACY_PROVIDER, + usage: { include: true }, + session_id: sessionId, + }; + const fallbackTrace = trace + ? beginLlmTrace(trace.onLlmCall, trace.operation, `${trace.label} (JSON fallback)`, model, fallbackRequest as Record) + : undefined; + try { + const completion = await client.chat.completions.create(fallbackRequest as never); + if (fallbackTrace) finishLlmTrace(trace?.onLlmCall, fallbackTrace, { response: completion }); + return completion; + } catch (fallbackError) { + if (fallbackTrace) finishLlmTrace(trace?.onLlmCall, fallbackTrace, { error: fallbackError }); + throw fallbackError; + } + } +} + +async function summarizeText( + client: OpenAI, + model: string, + text: string, + context: string, + sessionId?: string, + trace?: { + label: string; + onLlmCall?: SummarizeCompanyOptions["onLlmCall"]; + }, +): Promise { + const request = { + model, + messages: [ + { + role: "system", + content: + "Du bist ein präziser Assistent, der Unternehmensantworten aus Bewerbungsunterlagen auf Deutsch verdichtet. Erstelle eine kurze, prägnante Zusammenfassung, die zwingend kürzer als die Originalantwort ist. Nenne nur Informationen, die in der Originalantwort ausdrücklich enthalten sind. Füge keine Details, Kennzahlen, Bewertungen, Schlussfolgerungen oder Interpretationen hinzu, die nicht explizit genannt werden. Behalte die wichtigsten Aussagen bei, streiche Redundanzen und bleibe sachlich nah am Original.", + }, + { + role: "user", + content: `Kontext:\n${context}\n\nFasse die folgende Antwort zusammen:\n\n${text}`, + }, + ], + provider: OPENROUTER_PRIVACY_PROVIDER, + usage: { include: true }, + session_id: sessionId, + }; + const llmTrace = trace + ? beginLlmTrace(trace.onLlmCall, "zusammenfassung", trace.label, model, request as Record) + : undefined; + let completion: Awaited>; + try { + completion = await client.chat.completions.create(request as never); + if (llmTrace) finishLlmTrace(trace?.onLlmCall, llmTrace, { response: completion }); + } catch (error) { + if (llmTrace) finishLlmTrace(trace?.onLlmCall, llmTrace, { error }); + throw error; + } + + const msg = completion.choices[0]?.message as Record | undefined; + const content = (msg?.content ?? msg?.reasoning) as string | null | undefined; + + if (!content?.trim()) { + throw new Error( + `Model returned no content (finish_reason: ${completion.choices[0]?.finish_reason}). Try a non-reasoning model such as openai/gpt-4o-mini.`, + ); + } + + return content.trim(); +} + +function extractJsonObject(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith("```")) { + const match = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); + if (match?.[1]) return match[1].trim(); + } + return trimmed; +} + +function summarizeSegmentierungsQualitaet(fragen: FrageMitAntwort[] | undefined): SegmentierungsQualitaet | undefined { + const items = fragen ?? []; + if (!items.length) return undefined; + + const covered = items.filter((frage) => frage.abgedeckt).length; + const confidences = items + .map((frage) => frage.confidence) + .filter((value): value is number => typeof value === "number"); + const average = confidences.length + ? confidences.reduce((sum, value) => sum + value, 0) / confidences.length + : 0; + const lowConfidenceCount = items.filter((frage) => (frage.confidence ?? 0) < 0.5).length; + + return { + durchschnitt_confidence: Number(average.toFixed(2)), + abgedeckte_fragen: covered, + gesamt_fragen: items.length, + niedriges_vertrauen: average < 0.6 || lowConfidenceCount > Math.floor(items.length / 2), + }; +} + +function formatQuestionAnswers(fragen: FrageMitAntwort[] | undefined): string { + return (fragen ?? []) + .map((frage, index) => { + const titel = frage.id || `frage_${index + 1}`; + const antwort = frage.antwort?.trim() || ""; + const confidence = typeof frage.confidence === "number" + ? ` (Confidence: ${frage.confidence.toFixed(2)})` + : ""; + const status = frage.abgedeckt === false ? " [nicht abgedeckt]" : ""; + return antwort ? `${titel}${confidence}${status}: ${antwort}` : status ? `${titel}${confidence}${status}` : ""; + }) + .filter(Boolean) + .join("\n\n"); +} + +function formatValue(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return value.map((item) => formatValue(item)).join("; "); + return JSON.stringify(value); +} + +function prettifyKey(key: string): string { + return key + .replaceAll("_", " ") + .replace(/([a-zäöüß])([A-Z])/g, "$1 $2") + .replace(/^./, (char) => char.toUpperCase()); +} + +function appendSectionRows( + rows: Array>, + titel: string, + data: Record | undefined, +): void { + rows.push([titel, "", ""]); + if (!data) { + rows.push(["", "Keine Daten", ""]); + rows.push(["", "", ""]); + return; + } + + for (const [key, value] of Object.entries(data)) { + rows.push(["", prettifyKey(key), formatValue(value)]); + } + rows.push(["", "", ""]); +} + +interface UnterfrageLayout { + bereich: "fragen" | "kriterium"; + key: string; + frageId: string; + kategorie: string; + unterkategorie: string; +} + +const UNTERFRAGE_LAYOUT: UnterfrageLayout[] = [ + { + bereich: "fragen", + key: "frage1", + frageId: "frage1_1", + kategorie: "ALLGEMEIN:K1", + unterkategorie: "Organisationale Kernkompetenz: K1a", + }, + { + bereich: "fragen", + key: "frage2", + frageId: "frage2_1", + kategorie: "ALLGEMEIN", + unterkategorie: "Leistungsorientierung & Erfolgsmessung: K1B", + }, + { + bereich: "fragen", + key: "frage3", + frageId: "frage3_1", + kategorie: "ALLGEMEIN", + unterkategorie: "Geschäftsmodell-Transformation: K1C", + }, + { + bereich: "kriterium", + key: "robustheit_resilienz", + frageId: "robustheit_resilienz_1", + kategorie: "RESILIENZ", + unterkategorie: "Krisenmanagement & Reaktionsfähigkeit: K2A", + }, + { + bereich: "kriterium", + key: "robustheit_resilienz", + frageId: "robustheit_resilienz_2", + kategorie: "RESILIENZ", + unterkategorie: "Risikomanagement & Frühwarnsystem: K2B", + }, + { + bereich: "kriterium", + key: "robustheit_resilienz", + frageId: "robustheit_resilienz_3", + kategorie: "RESILIENZ", + unterkategorie: "Stakeholder-Management & Kommunikation: K3B", + }, + { + bereich: "kriterium", + key: "robustheit_resilienz", + frageId: "robustheit_resilienz_4", + kategorie: "RESILIENZ", + unterkategorie: "Finanzielle Stabilität & Liquiditätsmanagement: K4B", + }, + { + bereich: "kriterium", + key: "robustheit_resilienz", + frageId: "robustheit_resilienz_5", + kategorie: "RESILIENZ", + unterkategorie: "Regionale Vernetzung & Standortsicherung: K5B", + }, + { + bereich: "kriterium", + key: "zukunftsfaehigkeit_innovation", + frageId: "zukunftsfaehigkeit_innovation_1", + kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT", + unterkategorie: "Kundenorientierte Innovationsziele: K1C", + }, + { + bereich: "kriterium", + key: "zukunftsfaehigkeit_innovation", + frageId: "zukunftsfaehigkeit_innovation_2", + kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT", + unterkategorie: "Trendmonitoring & Experimentierkultur: K2C", + }, + { + bereich: "kriterium", + key: "zukunftsfaehigkeit_innovation", + frageId: "zukunftsfaehigkeit_innovation_3", + kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT", + unterkategorie: "Produkt- & Prozessinnovation: K3C", + }, + { + bereich: "kriterium", + key: "zukunftsfaehigkeit_innovation", + frageId: "zukunftsfaehigkeit_innovation_4", + kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT", + unterkategorie: "Innovationsnetzwerke & Partnerintegration: K4C", + }, + { + bereich: "kriterium", + key: "zukunftsfaehigkeit_innovation", + frageId: "zukunftsfaehigkeit_innovation_5", + kategorie: "INNOVATION & ZUKUNFTSFÄHIGKEIT", + unterkategorie: "Wissensmanagement & Wissensinfrastruktur: K5C", + }, + { + bereich: "kriterium", + key: "nachhaltigkeit_verantwortung", + frageId: "nachhaltigkeit_verantwortung_1", + kategorie: "NACHHALTIGKEIT & VERANTWORTUNG", + unterkategorie: "Ökologische Ressourceneffizienz: K6A", + }, + { + bereich: "kriterium", + key: "nachhaltigkeit_verantwortung", + frageId: "nachhaltigkeit_verantwortung_2", + kategorie: "NACHHALTIGKEIT & VERANTWORTUNG", + unterkategorie: "Regionale Lieferkette & Corporate Social Responsibility:K6B", + }, + { + bereich: "kriterium", + key: "nachhaltigkeit_verantwortung", + frageId: "nachhaltigkeit_verantwortung_3", + kategorie: "NACHHALTIGKEIT & VERANTWORTUNG", + unterkategorie: "Standortbindung & Regionalentwicklung: K6C", + }, + { + bereich: "kriterium", + key: "attraktivitaet", + frageId: "attraktivitaet_1", + kategorie: "ARBEITGEBERATTRAKTIVITÄT", + unterkategorie: "Mitarbeitermotivation & Durchhaltevermögen: K7A", + }, + { + bereich: "kriterium", + key: "attraktivitaet", + frageId: "attraktivitaet_2", + kategorie: "ARBEITGEBERATTRAKTIVITÄT", + unterkategorie: "Sinn & Purpose-Orientierung: K7B", + }, + { + bereich: "kriterium", + key: "attraktivitaet", + frageId: "attraktivitaet_3", + kategorie: "ARBEITGEBERATTRAKTIVITÄT", + unterkategorie: "Personalentwicklung & Weiterbildung: K7C", + }, + { + bereich: "kriterium", + key: "attraktivitaet", + frageId: "attraktivitaet_4", + kategorie: "ARBEITGEBERATTRAKTIVITÄT", + unterkategorie: "Employer Branding & Recruiting: K7D", + }, + { + bereich: "kriterium", + key: "attraktivitaet", + frageId: "attraktivitaet_5", + kategorie: "ARBEITGEBERATTRAKTIVITÄT", + unterkategorie: "Unternehmenskultur & Kulturmessung: K7E", + }, +]; + +function getAntwortEintrag( + data: SummaryData, + bereich: "fragen" | "kriterium", + key: string, +): AntwortEintrag | undefined { + return bereich === "fragen" ? data.fragen?.[key] : data.kriterium?.[key]; +} + +function buildStammdatenRows(data: SummaryData): string[][] { + const kontakt = (data.kontakt as Record | undefined) ?? {}; + const kontaktUnternehmen = (kontakt.unternehmen as Record | undefined) ?? {}; + const ansprechpartner = (kontakt.ansprechpartner as Record | undefined) ?? {}; + const unternehmensdaten = (data.unternehmen as Record | undefined) ?? {}; + + const rows: string[][] = [["Bereich", "Feld", "Wert"]]; + appendSectionRows(rows, "Kontakt", kontaktUnternehmen); + appendSectionRows(rows, "Ansprechpartner", ansprechpartner); + appendSectionRows(rows, "Unternehmensdaten", unternehmensdaten); + return rows; +} + +function buildKategorienRows(data: SummaryData): string[][] { + const rows: string[][] = [["Label", "Fragen", "Originale Antwort", "KI - Zusammenfassung"]]; + + for (const value of Object.values(data.fragen ?? {})) { + rows.push([ + value?.label ?? "", + (value?.fragen ?? []).map((frage) => frage.text).join("\n"), + value?.antwort ?? "", + value?.zusammenfassung ?? "", + ]); + } + + for (const value of Object.values(data.kriterium ?? {})) { + rows.push([ + value?.label ?? "", + (value?.fragen ?? []).map((frage) => frage.text).join("\n"), + value?.antwort ?? "", + value?.zusammenfassung ?? "", + ]); + } + + return rows; +} + +function buildFragenUndAntwortenRows(data: SummaryData): string[][] { + const rows: string[][] = [[ + "Kategorie", + "Unterkategorie", + "Frage", + "Zugeordnete Orginale Antworten (Mehrfachfragen KI Segmentiert)", + ]]; + + for (const layout of UNTERFRAGE_LAYOUT) { + const entry = getAntwortEintrag(data, layout.bereich, layout.key); + const frage = entry?.fragen?.find((item) => item.id === layout.frageId); + rows.push([ + layout.kategorie, + layout.unterkategorie, + frage?.text ?? "", + frage?.antwort ?? "", + ]); + } + + return rows; +} + +function buildAnalysenRows(data: SummaryData): string[][] { + const ampel = data.ampelbewertung; + return [ + ["Bereich", "Wert"], + ["Ampelfarbe", ampel?.farbe ?? ""], + ["Begruendung", ampel?.begruendung ?? ""], + ["Ausschlussgruende", (ampel?.ausschlussgruende ?? []).join("; ")], + ["Unbewertbare Kriterien", ampel?.unbewertbareKriterien == null ? "" : String(ampel.unbewertbareKriterien)], + ["Fehlende Daten", (ampel?.missingDataWarnings ?? []).join("; ")], + ["", ""], + ["SWOT - Staerken", (data.swot_analyse?.staerken ?? []).join("; ")], + ["SWOT - Schwaechen", (data.swot_analyse?.schwaechen ?? []).join("; ")], + ["SWOT - Chancen", (data.swot_analyse?.chancen ?? []).join("; ")], + ["SWOT - Risiken", (data.swot_analyse?.risiken ?? []).join("; ")], + ]; +} + +function buildScoringRows(data: SummaryData): string[][] { + const rows: string[][] = [[ + "Dimension", + "Dimension Gewicht (%)", + "Dimension Score", + "Subkriterium", + "Indikator", + "Subkriterium Gewicht (%)", + "Farbe", + "Score", + "Gewichteter Score", + "Confidence", + "Evidenz", + "Begruendung", + "Fehlende Daten", + ]]; + + for (const dimension of data.ampelbewertung?.dimensionen ?? []) { + for (const subcriterion of dimension.subcriteria) { + rows.push([ + dimension.name, + String(dimension.weight), + String(dimension.score), + subcriterion.name, + subcriterion.indikator, + String(subcriterion.weight), + subcriterion.farbe, + subcriterion.score == null ? "" : String(subcriterion.score), + String(subcriterion.weightedScore), + String(subcriterion.confidence), + subcriterion.evidence, + subcriterion.begruendung, + subcriterion.missingReason, + ]); + } + } + + return rows; +} + +function buildDisclaimerRows(): string[][] { + return [ + [AI_DISCLAIMER_TITLE], + [AI_DISCLAIMER_TEXT], + ]; +} + +async function writeTemplateWorkbook(data: SummaryData, xlsxPath: string): Promise { + if (!existsSync(XLSX_TEMPLATE_PATH)) { + return false; + } + + const ExcelJS = await import("exceljs"); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(XLSX_TEMPLATE_PATH); + + const populateWorksheet = (sheetName: string, rows: string[][]) => { + const worksheet = workbook.getWorksheet(sheetName) ?? workbook.addWorksheet(sheetName); + + const templateRowCount = worksheet.rowCount; + const templateColumnCount = worksheet.columnCount; + const rowCount = Math.max(templateRowCount, rows.length); + const colCount = Math.max( + templateColumnCount, + rows.reduce((max, row) => Math.max(max, row.length), 0), + ); + + for (let rowIndex = 1; rowIndex <= rowCount; rowIndex += 1) { + if (rowIndex > templateRowCount && templateRowCount > 0) { + const sourceRow = worksheet.getRow(templateRowCount); + const targetRow = worksheet.getRow(rowIndex); + if (sourceRow.height != null) { + targetRow.height = sourceRow.height; + } + } + + for (let colIndex = 1; colIndex <= colCount; colIndex += 1) { + const cell = worksheet.getCell(rowIndex, colIndex); + + if ((rowIndex > templateRowCount || colIndex > templateColumnCount) && templateRowCount > 0 && templateColumnCount > 0) { + const sourceCell = worksheet.getCell( + Math.min(rowIndex, templateRowCount), + Math.min(colIndex, templateColumnCount), + ); + cell.style = structuredClone(sourceCell.style); + } + + cell.value = rows[rowIndex - 1]?.[colIndex - 1] ?? ""; + } + } + }; + + populateWorksheet("Stammdaten", buildStammdatenRows(data)); + populateWorksheet("Kategorien", buildKategorienRows(data)); + populateWorksheet("Fragen und Antworten", buildFragenUndAntwortenRows(data)); + populateWorksheet("Analysen", buildAnalysenRows(data)); + populateWorksheet("Scoring", buildScoringRows(data)); + populateWorksheet("Disclaimer", buildDisclaimerRows()); + + await workbook.xlsx.writeFile(xlsxPath); + return true; +} + +function createWorkbook(data: SummaryData): XLSX.WorkBook { + const workbook = XLSX.utils.book_new(); + + const stammdatenSheet = XLSX.utils.aoa_to_sheet(buildStammdatenRows(data)); + stammdatenSheet["!cols"] = [{ wch: 22 }, { wch: 28 }, { wch: 177.33 }]; + XLSX.utils.book_append_sheet(workbook, stammdatenSheet, "Stammdaten"); + + const kategorienSheet = XLSX.utils.aoa_to_sheet(buildKategorienRows(data)); + kategorienSheet["!cols"] = [{ wch: 25.93 }, { wch: 255.29 }, { wch: 255.29 }, { wch: 255.29 }]; + XLSX.utils.book_append_sheet(workbook, kategorienSheet, "Kategorien"); + + const fragenSheet = XLSX.utils.aoa_to_sheet(buildFragenUndAntwortenRows(data)); + fragenSheet["!cols"] = [{ wch: 28 }, { wch: 28 }, { wch: 255.29 }, { wch: 255.29 }]; + XLSX.utils.book_append_sheet(workbook, fragenSheet, "Fragen und Antworten"); + + const analysenSheet = XLSX.utils.aoa_to_sheet(buildAnalysenRows(data)); + analysenSheet["!cols"] = [{ wch: 24 }, { wch: 254.83 }]; + XLSX.utils.book_append_sheet(workbook, analysenSheet, "Analysen"); + + const scoringSheet = XLSX.utils.aoa_to_sheet(buildScoringRows(data)); + scoringSheet["!cols"] = [ + { wch: 24 }, + { wch: 18 }, + { wch: 16 }, + { wch: 30 }, + { wch: 30 }, + { wch: 18 }, + { wch: 14 }, + { wch: 12 }, + { wch: 18 }, + { wch: 12 }, + { wch: 80 }, + { wch: 80 }, + { wch: 50 }, + ]; + XLSX.utils.book_append_sheet(workbook, scoringSheet, "Scoring"); + + const disclaimerSheet = XLSX.utils.aoa_to_sheet(buildDisclaimerRows()); + disclaimerSheet["!cols"] = [{ wch: 140 }]; + XLSX.utils.book_append_sheet(workbook, disclaimerSheet, "Disclaimer"); + + return workbook; +} + +function buildScoringFallbackBegruendung( + farbe: Ampelfarbe, + scoring: ReturnType, + ausschlussgruende: string[], +): string { + if (ausschlussgruende.length) { + return `Rot wegen automatischem Ausschluss: ${ausschlussgruende.join("; ")}.`; + } + + const strongestDimensions = scoring.dimensionen + .toSorted((a, b) => b.score - a.score) + .slice(0, 2) + .map((dimension) => `${dimension.name} (${dimension.score})`) + .join(", "); + const missing = scoring.unbewertbareKriterien + ? ` ${scoring.unbewertbareKriterien} Subkriterien waren mangels belastbarer Angaben unbewertbar.` + : ""; + + const meaning = farbe === "gruen" + ? "fuer den weiteren Vergleich geeignet" + : "mit begruendeten Zweifeln fuer die weitere Betrachtung"; + return `${farbe} (${meaning}). Staerkste Bereiche: ${strongestDimensions || "keine"}.${missing}`; +} + +async function assessTrafficLight( + client: OpenAI, + model: string, + data: LlmDossier, + sessionId?: string, + onLlmCall?: SummarizeCompanyOptions["onLlmCall"], +): Promise { + const scoringInstructions = { + ausschlussgruende: [ + "Inkubator / Grosskonzern-Tochter: Nicht Inhaber-gefuehrt bzw. staatlich", + "Stiftung als Bewerber: Stiftungen duerfen sich nicht bewerben", + "Zu kleines Unternehmen: Arbeitsbedingungen nicht Bayern-praegend bei sehr kleinen Betrieben", + ], + scoringModel: SCORING_MODEL, + }; + + const completion = await createStructuredCompletion( + client, + model, + [ + { + role: "system", + content: + "Du bewertest Bewerbungen fuer einen Unternehmenspreis anhand eines gewichteten Kriterienmodells. Antworte ausschliesslich auf Deutsch und ausschliesslich als JSON.", + }, + { + role: "user", + content: `Bewerte die folgende Bewerbung nach dem bereitgestellten Ampel- und Gewichtungsmodell. + +Modell: +${JSON.stringify(scoringInstructions, null, 2)} + +Vorgehen: +- Pruefe zuerst die automatischen Ausschlussgruende. Nenne nur Ausschlussgruende, die aus den Daten belastbar erkennbar sind. +- Bewerte danach jedes Subkriterium des scoringModel mit genau einer Farbe: gruen, gelb, rot oder unbewertbar. +- Verwende unbewertbar, wenn die Daten fuer ein Subkriterium fehlen oder zu uneindeutig sind. Erfinde keine Kennzahlen. +- Nutze die Schwellenwerte als Bewertungsrahmen. Wenn qualitative Angaben klar einem Schwellenwert entsprechen, darfst du qualitativ bewerten. +- Wichtig: rot auf Ebene der Gesamtbewertung bedeutet ausschliesslich "Bewerbung verwerfen" und wird nachgelagert nur bei automatischen Ausschlussgruenden vergeben. Niedrige oder fehlende Scores ohne Ausschlussgrund fuehren zu gelb, nicht rot. +- evidence muss eine kurze, konkrete Fundstelle oder Zusammenfassung aus den Bewerbungsdaten sein. Bei unbewertbar bleibt evidence leer. +- confidence muss zwischen 0 und 1 liegen. +- missingReason ist bei unbewertbar Pflicht, sonst leer. +- Die Gesamtfarbe und Gewichtung werden nachgelagert berechnet; liefere nur die Faktorbewertungen und eine kurze Gesamtbegruendung. + +Wichtige Vorgaben: +- Antworte ausschliesslich als JSON. +- Verwende genau die Felder begruendung, ausschlussgruende und dimensionen. +- Gib jede Dimension und jedes Subkriterium aus dem Modell mit der jeweiligen id zurueck. +- Die begruendung soll kurz, konkret und auf Deutsch sein. +- Pruefe nur auf Basis der vorliegenden Daten. + +Bewerbungsdaten: +${JSON.stringify(data, null, 2)}`, + }, + ], + "ampelbewertung", + { + type: "object", + additionalProperties: false, + properties: { + begruendung: { type: "string" }, + ausschlussgruende: { + type: "array", + items: { type: "string" }, + }, + dimensionen: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + id: { type: "string" }, + subcriteria: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + id: { type: "string" }, + farbe: { type: "string", enum: ["gruen", "gelb", "rot", "unbewertbar"] }, + evidence: { type: "string" }, + begruendung: { type: "string" }, + confidence: { type: "number" }, + missingReason: { type: "string" }, + }, + required: ["id", "farbe", "evidence", "begruendung", "confidence", "missingReason"], + }, + }, + }, + required: ["id", "subcriteria"], + }, + }, + }, + required: ["begruendung", "ausschlussgruende", "dimensionen"], + }, + sessionId, + { operation: "scoring", label: "Ampelbewertung", onLlmCall }, + ); + + const msg = completion.choices[0]?.message as Record | undefined; + const content = (msg?.content ?? msg?.reasoning) as string | null | undefined; + + if (!content?.trim()) { + throw new Error( + `Model returned no content for traffic light assessment (finish_reason: ${completion.choices[0]?.finish_reason}).`, + ); + } + + const parsed = JSON.parse(extractJsonObject(content)) as Partial & { + begruendung?: string; + }; + const ausschlussgruende = Array.isArray(parsed.ausschlussgruende) + ? parsed.ausschlussgruende.map((flag) => String(flag).trim()).filter(Boolean) + : []; + const scoring = calculateScoringResult(SCORING_MODEL, { + ausschlussgruende, + dimensionen: Array.isArray(parsed.dimensionen) ? parsed.dimensionen : [], + }); + const farbe = deriveTrafficLight(scoring, ausschlussgruende); + + return { + farbe, + gesamtScore: scoring.gesamtScore, + begruendung: String(parsed.begruendung ?? "").trim() || buildScoringFallbackBegruendung(farbe, scoring, ausschlussgruende), + ausschlussgruende, + unbewertbareKriterien: scoring.unbewertbareKriterien, + missingDataWarnings: scoring.missingDataWarnings, + dimensionen: scoring.dimensionen, + }; +} + +async function segmentMultiQuestionAnswer( + client: OpenAI, + model: string, + entry: AntwortEintrag, + fallbackLabel: string, + sessionId?: string, + onLlmCall?: SummarizeCompanyOptions["onLlmCall"], +): Promise { + const fragen = entry.fragen ?? []; + if (!fragen.length || entry.antwortFormat !== "mehrfachfrage_ein_antwortfeld") { + return fragen; + } + + const completion = await createStructuredCompletion( + client, + model, + [ + { + role: "system", + content: + "Du ordnest eine gemeinsame Freitextantwort mehreren Unterfragen zu. Antworte ausschließlich auf Deutsch und ausschließlich als JSON mit dem Feld fragen. fragen muss ein Array mit Objekten der Form { id, text, antwort, confidence } sein. confidence muss zwischen 0 und 1 liegen.", + }, + { + role: "user", + content: `Ordne die folgende gemeinsame Antwort den Unterfragen zu. + +Bereich: ${entry.label?.trim() || fallbackLabel} + +Unterfragen: +${fragen.map((frage, index) => `${index + 1}. [${frage.id ?? `frage_${index + 1}`}] ${frage.text}`).join("\n")} + +Gemeinsame Antwort: +${entry.antwort ?? ""} + +Vorgaben: +- Antworte ausschließlich als JSON mit dem Feld fragen. +- Gib für jede Unterfrage genau ein Objekt mit id, text, antwort und confidence zurück. +- antwort soll möglichst aus wörtlichen oder sehr nahen Textpassagen der Gesamtantwort bestehen. +- Füge keine Auslassungspunkte wie "..." oder „…“ ein, außer sie stehen exakt so im Original. +- confidence muss zwischen 0 und 1 liegen und angeben, wie sicher die Zuordnung ist. +- Wenn die Antwort eine Unterfrage nicht belastbar abdeckt, setze antwort auf einen leeren String und confidence auf 0. +- Erfinde nichts und paraphrasiere nicht unnötig. Bleibe möglichst nah am Originalinhalt. +- Mehrfachnennungen sind erlaubt, wenn ein Abschnitt mehrere Unterfragen klar beantwortet. +- Lasse keine Unterfrage aus.`, + }, + ], + "segmentierung_unterfragen", + { + type: "object", + additionalProperties: false, + properties: { + fragen: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + id: { type: "string" }, + text: { type: "string" }, + antwort: { type: "string" }, + confidence: { type: "number" }, + }, + required: ["id", "text", "antwort", "confidence"], + }, + }, + }, + required: ["fragen"], + }, + sessionId, + { operation: "segmentierung", label: fallbackLabel, onLlmCall }, + ); + + const msg = completion.choices[0]?.message as Record | undefined; + const content = (msg?.content ?? msg?.reasoning) as string | null | undefined; + + if (!content?.trim()) { + throw new Error( + `Model returned no content for answer segmentation (finish_reason: ${completion.choices[0]?.finish_reason}).`, + ); + } + + const parsed = JSON.parse(extractJsonObject(content)) as { fragen?: Array> }; + const byId = new Map((parsed.fragen ?? []).map((frage) => [String(frage.id ?? ""), frage])); + + return fragen.map((frage) => { + const mapped = byId.get(String(frage.id ?? "")); + const rawConfidence = mapped?.confidence; + const confidence = + typeof rawConfidence === "number" + ? Math.max(0, Math.min(1, rawConfidence)) + : Number.isFinite(Number(rawConfidence)) + ? Math.max(0, Math.min(1, Number(rawConfidence))) + : undefined; + const antwort = String(mapped?.antwort ?? "").trim(); + + return { + id: frage.id, + text: frage.text, + antwort, + confidence, + abgedeckt: Boolean(antwort), + quelle: "llm_segmentierung", + }; + }); +} + +async function analyzeSWOT( + client: OpenAI, + model: string, + data: LlmDossier, + sessionId?: string, + onLlmCall?: SummarizeCompanyOptions["onLlmCall"], +): Promise { + const completion = await createStructuredCompletion( + client, + model, + [ + { + role: "system", + content: + "Du analysierst Unternehmensbewerbungen und erstellst eine knappe SWOT-Analyse. Antworte ausschließlich auf Deutsch und ausschließlich als JSON mit den Feldern staerken, schwaechen, chancen und risiken. Jedes Feld muss ein Array aus kurzen Stichpunkten sein.", + }, + { + role: "user", + content: `Erstelle auf Basis der folgenden Bewerbungsdaten eine SWOT-Analyse. + +Vorgaben: +- Antworte ausschließlich als JSON. +- Verwende genau die Felder staerken, schwaechen, chancen und risiken. +- Jedes Feld muss ein Array aus kurzen, konkreten deutschen Stichpunkten sein. +- Nenne nur Punkte, die aus den vorliegenden Daten klar ableitbar sind. +- Erfinde nichts und ergänze keine externen Annahmen. +- Vermeide schwache oder spekulative Schwächen und Risiken. +- Wenn zu einem Bereich wenig belastbare Hinweise vorliegen, liefere lieber wenige Punkte als vage Aussagen. +- Die Stichpunkte sollen prägnant und entscheidungsrelevant sein. + +Bewerbungsdaten: +${JSON.stringify(data, null, 2)}`, + }, + ], + "swot_analyse", + { + type: "object", + additionalProperties: false, + properties: { + staerken: { type: "array", items: { type: "string" } }, + schwaechen: { type: "array", items: { type: "string" } }, + chancen: { type: "array", items: { type: "string" } }, + risiken: { type: "array", items: { type: "string" } }, + }, + required: ["staerken", "schwaechen", "chancen", "risiken"], + }, + sessionId, + { operation: "swot", label: "SWOT-Analyse", onLlmCall }, + ); + + const msg = completion.choices[0]?.message as Record | undefined; + const content = (msg?.content ?? msg?.reasoning) as string | null | undefined; + + if (!content?.trim()) { + throw new Error( + `Model returned no content for SWOT analysis (finish_reason: ${completion.choices[0]?.finish_reason}).`, + ); + } + + const parsed = JSON.parse(extractJsonObject(content)) as Partial; + const toStringArray = (value: unknown): string[] => + Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : []; + + return { + staerken: toStringArray(parsed.staerken), + schwaechen: toStringArray(parsed.schwaechen), + chancen: toStringArray(parsed.chancen), + risiken: toStringArray(parsed.risiken), + }; +} + +export async function listCompanies(): Promise { + let entries: string[]; + try { + entries = await readdir(OUTPUTS_DIR); + } catch { + return []; + } + + const companies: string[] = []; + for (const entry of entries) { + if (!isSafeStem(entry)) continue; + const jsonPath = join(OUTPUTS_DIR, entry, `${entry}.json`); + if (await Bun.file(jsonPath).exists()) { + companies.push(entry); + } + } + return companies; +} + +export async function summarizeCompany( + stem: string, + model: string, + options: SummarizeCompanyOptions = {}, +): Promise { + if (!isSafeStem(stem)) { + throw new Error(`Invalid company id "${stem}"`); + } + const jsonPath = join(OUTPUTS_DIR, stem, `${stem}.json`); + const file = Bun.file(jsonPath); + if (!(await file.exists())) { + throw new Error(`No extracted JSON found for "${stem}"`); + } + + const data: ExtractedData = await file.json(); + const client = createClient(); + const output: SummaryData = structuredClone(data); + const privacy: Pseudonymizer = createPseudonymizer(data); + const sessionId = `summarizer-${stem}-${crypto.randomUUID()}`; + + // Summarize fragen answers + if (data.fragen) { + output.fragen = {}; + for (const [key, value] of Object.entries(data.fragen)) { + const antwort = value?.antwort ?? ""; + const safeValue = privacy.pseudonymizeEntry(value); + const safeAntwort = safeValue?.antwort ?? ""; + const enrichedFragen = + antwort.trim() && value?.antwortFormat === "mehrfachfrage_ein_antwortfeld" + ? await segmentMultiQuestionAnswer(client, model, safeValue, `Frage ${key}`, sessionId, options.onLlmCall) + : safeValue?.fragen; + const zusammenfassung = safeAntwort.trim() + ? await summarizeText( + client, + model, + safeAntwort, + buildQuestionContext({ ...safeValue, fragen: enrichedFragen }, `Frage ${key}`), + sessionId, + { label: `Frage ${key}`, onLlmCall: options.onLlmCall }, + ) + : ""; + const segmentierung = summarizeSegmentierungsQualitaet(enrichedFragen); + output.fragen[key] = { + ...value, + fragen: enrichedFragen, + zuordnungsmodus: + value?.antwortFormat === "mehrfachfrage_ein_antwortfeld" + ? segmentierung?.niedriges_vertrauen + ? "gemeinsame_antwort" + : "llm_segmentiert" + : value?.zuordnungsmodus, + antwort, + zusammenfassung, + segmentierung, + }; + } + } + + // Summarize kriterium answers + if (data.kriterium) { + output.kriterium = {}; + const kriteriumLabels: Record = { + robustheit_resilienz: "Robustheit & Resilienz", + zukunftsfaehigkeit_innovation: "Zukunftsfähigkeit & Innovation", + nachhaltigkeit_verantwortung: "Nachhaltigkeit & Verantwortung", + attraktivitaet: "Attraktivität", + }; + for (const [key, value] of Object.entries(data.kriterium)) { + const antwort = value?.antwort ?? ""; + const label = value?.label?.trim() || kriteriumLabels[key] || key; + const safeValue = privacy.pseudonymizeEntry(value); + const safeAntwort = safeValue?.antwort ?? ""; + const safeLabel = safeValue?.label?.trim() || label; + const enrichedFragen = + antwort.trim() && value?.antwortFormat === "mehrfachfrage_ein_antwortfeld" + ? await segmentMultiQuestionAnswer(client, model, safeValue, safeLabel, sessionId, options.onLlmCall) + : safeValue?.fragen; + const zusammenfassung = safeAntwort.trim() + ? await summarizeText( + client, + model, + safeAntwort, + buildQuestionContext({ ...safeValue, fragen: enrichedFragen }, safeLabel), + sessionId, + { label: safeLabel, onLlmCall: options.onLlmCall }, + ) + : ""; + const segmentierung = summarizeSegmentierungsQualitaet(enrichedFragen); + output.kriterium[key] = { + ...value, + fragen: enrichedFragen, + zuordnungsmodus: + value?.antwortFormat === "mehrfachfrage_ein_antwortfeld" + ? segmentierung?.niedriges_vertrauen + ? "gemeinsame_antwort" + : "llm_segmentiert" + : value?.zuordnungsmodus, + antwort, + zusammenfassung, + segmentierung, + }; + } + } + + const preparedScoringInput = privacy.pseudonymizeDossier(buildLlmDossier(output)); + output.ampelbewertung = await assessTrafficLight(client, model, preparedScoringInput.safe, sessionId, options.onLlmCall); + + const preparedSwotInput = privacy.pseudonymizeDossier(buildLlmDossier(output)); + output.swot_analyse = await analyzeSWOT(client, model, preparedSwotInput.safe, sessionId, options.onLlmCall); + + // Reverse pseudonyms in LLM-generated content + const reverse = privacy.reversePseudonyms; + for (const [key, frage] of Object.entries(output.fragen ?? {})) { + if (frage.zusammenfassung) { + frage.zusammenfassung = reverse(frage.zusammenfassung); + } + if (frage.fragen) { + for (const f of frage.fragen) { + if (f.antwort) { + f.antwort = reverse(f.antwort); + } + if (f.text) { + f.text = reverse(f.text); + } + } + } + } + for (const [key, kriterium] of Object.entries(output.kriterium ?? {})) { + if (kriterium.zusammenfassung) { + kriterium.zusammenfassung = reverse(kriterium.zusammenfassung); + } + if (kriterium.fragen) { + for (const f of kriterium.fragen) { + if (f.antwort) { + f.antwort = reverse(f.antwort); + } + if (f.text) { + f.text = reverse(f.text); + } + } + } + } + const ampel = output.ampelbewertung; + if (ampel) { + ampel.begruendung = reverse(ampel.begruendung); + ampel.ausschlussgruende = (ampel.ausschlussgruende ?? []).map(reverse); + for (const dimension of ampel.dimensionen ?? []) { + for (const sub of dimension.subcriteria) { + sub.evidence = reverse(sub.evidence); + sub.begruendung = reverse(sub.begruendung); + sub.missingReason = reverse(sub.missingReason); + } + } + } + const swot = output.swot_analyse; + if (swot) { + swot.staerken = swot.staerken.map(reverse); + swot.schwaechen = swot.schwaechen.map(reverse); + swot.chancen = swot.chancen.map(reverse); + swot.risiken = swot.risiken.map(reverse); + } + + output._privacy = privacy.audit(); + output._schemaVersion = 3; + output._summarizedAt = new Date().toISOString(); + return output; +} + +export async function writeSummary( + stem: string, + data: SummaryData, + outputsDir: string, +): Promise { + if (!isSafeStem(stem)) { + throw new Error(`Invalid company id "${stem}"`); + } + const { mkdir } = await import("node:fs/promises"); + const outDir = join(outputsDir, stem); + await mkdir(outDir, { recursive: true }); + const outputData = structuredClone(data) as SummaryData; + if (outputData.ampelbewertung) { + delete outputData.ampelbewertung.gesamtScore; + } + + const jsonPath = join(outDir, `${stem}.json`); + await Bun.write(jsonPath, JSON.stringify(outputData, null, 2)); + + const xlsxPath = join(outDir, `${stem}.xlsx`); + if (!(await writeTemplateWorkbook(outputData, xlsxPath))) { + const workbook = createWorkbook(outputData); + const xlsxBuffer = XLSX.write(workbook, { + bookType: "xlsx", + type: "buffer", + cellStyles: true, + }); + await Bun.write(xlsxPath, xlsxBuffer); + } + + const reportPaths = await writeJuryReport(stem, outputData, outDir); + + return [jsonPath, xlsxPath, ...reportPaths]; +} diff --git a/packages/summarizer/template.xlsx b/packages/summarizer/template.xlsx new file mode 100644 index 0000000..7cb4c2c Binary files /dev/null and b/packages/summarizer/template.xlsx differ diff --git a/packages/summarizer/tsconfig.json b/packages/summarizer/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/summarizer/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/templatebuilder/CLAUDE.md b/packages/templatebuilder/CLAUDE.md new file mode 100644 index 0000000..57cd3be --- /dev/null +++ b/packages/templatebuilder/CLAUDE.md @@ -0,0 +1,2 @@ +The template builder is a custom built web application designed to create a JSON mapping from the names of input fields within a PDF file, to corresponding nested JSON keys. +The Application is designed to allow the user to visually select any input field within a loaded and rendered PDF file, and assign a specific nested json key to it, e.g "information.company.name". diff --git a/packages/templatebuilder/frontend.ts b/packages/templatebuilder/frontend.ts new file mode 100644 index 0000000..8b85554 --- /dev/null +++ b/packages/templatebuilder/frontend.ts @@ -0,0 +1,610 @@ +import * as pdfjsLib from "pdfjs-dist"; +import type { PDFDocumentProxy, PageViewport } from "pdfjs-dist"; + +pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.mjs"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type AntwortFormat = "einzelfrage" | "mehrfachfrage_ein_antwortfeld"; + +interface FrageVorlage { + id: string; + text: string; +} + +interface FieldEntry { + id: string; + fieldName: string; // PDF AcroForm field name, or user-defined for manual + jsonKey: string; // Target nested JSON path + rect: [number, number, number, number]; // PDF user-space [x1, y1, x2, y2] + page: number; // 1-indexed + type: "acroform" | "manual"; + label?: string; + antwortFormat?: AntwortFormat; + fragen?: FrageVorlage[]; +} + +// ─── State ──────────────────────────────────────────────────────────────────── + +let pdfDoc: PDFDocumentProxy | null = null; +let currentPage = 1; +let viewport: PageViewport | null = null; +let fields: FieldEntry[] = []; +let selectedFieldId: string | null = null; +let isDrawMode = false; +let drawStart: { x: number; y: number } | null = null; +let pendingManualRect: [number, number, number, number] | null = null; +let currentFilename = ""; + +// ─── DOM refs ───────────────────────────────────────────────────────────────── + +const fileInput = q("#file-input"); +const uploadBtn = q("#upload-btn"); +const drawBtn = q("#draw-btn"); +const saveBtn = q("#save-btn"); +const prevPageBtn = q("#prev-page"); +const nextPageBtn = q("#next-page"); +const pageInfo = q("#page-info"); +const fieldList = q("#field-list"); +const fieldCount = q("#field-count"); +const filenameDisp = q("#filename-display"); +const uploadPrompt = q("#upload-prompt"); +const canvases = q("#canvases"); +const pdfCanvas = q("#pdf-canvas"); +const overlayCanvas = q("#overlay-canvas"); + +// Key panel +const keyPanel = q("#key-panel"); +const kpFieldName = q("#kp-field-name"); +const kpTypeBadge = q("#kp-type-badge"); +const kpNameRow = q("#kp-name-row"); +const kpFieldId = q("#kp-field-id"); +const kpJsonKey = q("#kp-json-key"); +const kpLabel = q("#kp-label"); +const kpAntwortFormat = q("#kp-antwort-format"); +const kpFragen = q("#kp-fragen"); +const kpAssign = q("#kp-assign"); +const kpCancel = q("#kp-cancel"); +const kpRemove = q("#kp-remove"); + +function q(sel: string): T { + return document.querySelector(sel) as T; +} + +function csrfHeaders(extra: Record = {}): Record { + const csrf = document.cookie + .split("; ") + .find((part) => part.startsWith("bmp_demo_csrf=")) + ?.slice("bmp_demo_csrf=".length); + return csrf ? { ...extra, "X-BMP-CSRF": decodeURIComponent(csrf) } : extra; +} + +// ─── PDF Loading ────────────────────────────────────────────────────────────── + +uploadBtn.addEventListener("click", () => fileInput.click()); + +fileInput.addEventListener("change", async () => { + const file = fileInput.files?.[0]; + if (file) await loadPDF(file); +}); + +async function loadPDF(file: File) { + currentFilename = file.name; + filenameDisp.textContent = file.name; + selectedFieldId = null; + hideKeyPanel(); + + const buf = await file.arrayBuffer(); + pdfDoc = await pdfjsLib.getDocument({ data: buf }).promise; + + const acroFields = await extractAcroFields(pdfDoc); + fields = mergeWithSaved(file.name, acroFields); + + currentPage = 1; + uploadPrompt.style.display = "none"; + canvases.style.display = "inline-block"; + drawBtn.disabled = false; + saveBtn.disabled = false; + + await renderPage(1); + renderFieldList(); +} + +async function extractAcroFields(doc: PDFDocumentProxy): Promise { + const result: FieldEntry[] = []; + const seen = new Set(); + + for (let p = 1; p <= doc.numPages; p++) { + const page = await doc.getPage(p); + const annotations = await page.getAnnotations(); + + for (const ann of annotations) { + if (ann.subtype === "Widget" && ann.fieldName) { + const key = `${ann.fieldName}::p${p}`; + if (!seen.has(key)) { + seen.add(key); + result.push({ + id: `acro-p${p}-${ann.id ?? ann.fieldName}`, + fieldName: ann.fieldName, + jsonKey: "", + rect: ann.rect as [number, number, number, number], + page: p, + type: "acroform", + }); + } + } + } + } + return result; +} + +function mergeWithSaved(filename: string, acroFields: FieldEntry[]): FieldEntry[] { + const raw = localStorage.getItem(`tb:${filename}`); + if (!raw) return acroFields; + + const saved: FieldEntry[] = JSON.parse(raw); + const savedByKey = new Map(saved.map(f => [`${f.fieldName}::p${f.page}`, f])); + + const merged = acroFields.map(f => { + const s = savedByKey.get(`${f.fieldName}::p${f.page}`); + return s + ? { + ...f, + jsonKey: s.jsonKey, + label: s.label, + antwortFormat: s.antwortFormat, + fragen: s.fragen, + } + : f; + }); + + const manuals = saved.filter(f => f.type === "manual"); + return [...merged, ...manuals]; +} + +// ─── Page Rendering ─────────────────────────────────────────────────────────── + +async function renderPage(pageNum: number) { + if (!pdfDoc) return; + + const page = await pdfDoc.getPage(pageNum); + viewport = page.getViewport({ scale: 1.5 }); + + pdfCanvas.width = viewport.width; + pdfCanvas.height = viewport.height; + overlayCanvas.width = viewport.width; + overlayCanvas.height = viewport.height; + + const ctx = pdfCanvas.getContext("2d")!; + await page.render({ canvasContext: ctx, viewport, canvas: pdfCanvas }).promise; + + renderOverlay(); + updatePagination(); +} + +function updatePagination() { + const total = pdfDoc?.numPages ?? 0; + pageInfo.textContent = total ? `Page ${currentPage} of ${total}` : "—"; + prevPageBtn.disabled = currentPage <= 1; + nextPageBtn.disabled = !total || currentPage >= total; +} + +prevPageBtn.addEventListener("click", async () => { + if (currentPage > 1) { + currentPage--; + selectedFieldId = null; + hideKeyPanel(); + await renderPage(currentPage); + } +}); + +nextPageBtn.addEventListener("click", async () => { + if (pdfDoc && currentPage < pdfDoc.numPages) { + currentPage++; + selectedFieldId = null; + hideKeyPanel(); + await renderPage(currentPage); + } +}); + +// ─── Overlay ───────────────────────────────────────────────────────────────── + +function renderOverlay(drawPreview?: { x: number; y: number; w: number; h: number }) { + if (!viewport) return; + const ctx = overlayCanvas.getContext("2d")!; + ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); + + for (const field of fields.filter(f => f.page === currentPage)) { + const [l, t, w, h] = rectToCanvas(field.rect, viewport!); + const sel = field.id === selectedFieldId; + const mapped = field.jsonKey.trim() !== ""; + + if (sel) { + ctx.fillStyle = "rgba(37,99,235,0.12)"; + ctx.fillRect(l, t, w, h); + ctx.strokeStyle = "#2563eb"; + ctx.lineWidth = 2; + } else { + ctx.strokeStyle = mapped ? "#16a34a" : "#ea580c"; + ctx.lineWidth = 1.5; + } + ctx.strokeRect(l, t, w, h); + + // Label + const label = field.jsonKey || field.fieldName; + ctx.font = "10px monospace"; + ctx.fillStyle = sel ? "#2563eb" : mapped ? "#16a34a" : "#ea580c"; + const labelY = t > 14 ? t - 3 : t + h + 11; + ctx.fillText(label, l + 2, labelY); + } + + if (drawPreview) { + ctx.strokeStyle = "#2563eb"; + ctx.lineWidth = 2; + ctx.setLineDash([5, 4]); + ctx.strokeRect(drawPreview.x, drawPreview.y, drawPreview.w, drawPreview.h); + ctx.setLineDash([]); + } +} + +function rectToCanvas( + rect: [number, number, number, number], + vp: PageViewport, +): [number, number, number, number] { + const [cx1, cy1, cx2, cy2] = vp.convertToViewportRectangle(rect); + const l = Math.min(cx1, cx2); + const t = Math.min(cy1, cy2); + return [l, t, Math.abs(cx2 - cx1), Math.abs(cy2 - cy1)]; +} + +function canvasToRect( + x1: number, y1: number, x2: number, y2: number, + vp: PageViewport, +): [number, number, number, number] { + const [a, b, c, d, e, f] = vp.transform as [number, number, number, number, number, number]; + const det = a * d - b * c; + + function inv(vx: number, vy: number): [number, number] { + return [ + (d * vx - c * vy + (c * f - d * e)) / det, + (-b * vx + a * vy + (b * e - a * f)) / det, + ]; + } + + const [px1, py1] = inv(x1, y1); + const [px2, py2] = inv(x2, y2); + return [ + Math.min(px1, px2), Math.min(py1, py2), + Math.max(px1, px2), Math.max(py1, py2), + ]; +} + +// ─── Mouse on overlay ──────────────────────────────────────────────────────── + +function canvasPos(e: MouseEvent) { + const r = overlayCanvas.getBoundingClientRect(); + return { + x: (e.clientX - r.left) * (overlayCanvas.width / r.width), + y: (e.clientY - r.top) * (overlayCanvas.height / r.height), + }; +} + +overlayCanvas.addEventListener("click", (e) => { + if (isDrawMode || !viewport) return; + const { x, y } = canvasPos(e); + + for (const field of fields.filter(f => f.page === currentPage)) { + const [l, t, w, h] = rectToCanvas(field.rect, viewport!); + if (x >= l && x <= l + w && y >= t && y <= t + h) { + selectField(field.id); + return; + } + } + + selectedFieldId = null; + renderOverlay(); + hideKeyPanel(); +}); + +overlayCanvas.addEventListener("mousedown", (e) => { + if (!isDrawMode) return; + drawStart = canvasPos(e); +}); + +overlayCanvas.addEventListener("mousemove", (e) => { + if (!isDrawMode || !drawStart) return; + const { x, y } = canvasPos(e); + renderOverlay({ + x: Math.min(drawStart.x, x), + y: Math.min(drawStart.y, y), + w: Math.abs(x - drawStart.x), + h: Math.abs(y - drawStart.y), + }); +}); + +overlayCanvas.addEventListener("mouseup", (e) => { + if (!isDrawMode || !drawStart || !viewport) return; + const { x, y } = canvasPos(e); + const dx = Math.abs(x - drawStart.x); + const dy = Math.abs(y - drawStart.y); + + if (dx > 8 && dy > 8) { + pendingManualRect = canvasToRect( + Math.min(drawStart.x, x), Math.min(drawStart.y, y), + Math.max(drawStart.x, x), Math.max(drawStart.y, y), + viewport, + ); + exitDrawMode(); + showNewManualPanel(); + } else { + drawStart = null; + renderOverlay(); + } + + drawStart = null; +}); + +// ─── Draw mode ──────────────────────────────────────────────────────────────── + +drawBtn.addEventListener("click", () => { + if (isDrawMode) exitDrawMode(); + else enterDrawMode(); +}); + +function enterDrawMode() { + isDrawMode = true; + drawBtn.textContent = "Cancel Draw"; + drawBtn.classList.add("active"); + overlayCanvas.style.cursor = "crosshair"; + hideKeyPanel(); +} + +function exitDrawMode() { + isDrawMode = false; + drawBtn.textContent = "Draw Field"; + drawBtn.classList.remove("active"); + overlayCanvas.style.cursor = "default"; + drawStart = null; + renderOverlay(); +} + +// ─── Field selection ───────────────────────────────────────────────────────── + +function selectField(id: string) { + selectedFieldId = id; + renderOverlay(); + + const field = fields.find(f => f.id === id)!; + + kpFieldName.textContent = field.fieldName; + kpTypeBadge.textContent = field.type === "acroform" ? "AcroForm" : "Manual"; + kpTypeBadge.className = `kp-badge kp-badge-${field.type}`; + kpJsonKey.value = field.jsonKey; + kpLabel.value = field.label ?? ""; + kpAntwortFormat.value = field.antwortFormat ?? "einzelfrage"; + kpFragen.value = (field.fragen ?? []).map((frage) => frage.text).join("\n"); + kpNameRow.classList.add("hidden"); + kpRemove.style.display = field.type === "manual" ? "" : "none"; + keyPanel.classList.remove("hidden"); + kpJsonKey.focus(); + + // Sync sidebar highlight + document.querySelectorAll(".field-item").forEach(el => + el.classList.toggle("selected", el.getAttribute("data-id") === id)); + document.querySelector(`.field-item[data-id="${id}"]`) + ?.scrollIntoView({ block: "nearest" }); +} + +function showNewManualPanel() { + kpFieldName.textContent = "(new manual field)"; + kpTypeBadge.textContent = "Manual"; + kpTypeBadge.className = "kp-badge kp-badge-manual"; + kpJsonKey.value = ""; + kpLabel.value = ""; + kpAntwortFormat.value = "einzelfrage"; + kpFragen.value = ""; + kpNameRow.classList.remove("hidden"); + kpFieldId.value = ""; + kpRemove.style.display = "none"; + keyPanel.classList.remove("hidden"); + kpFieldId.focus(); +} + +function hideKeyPanel() { + keyPanel.classList.add("hidden"); + pendingManualRect = null; +} + +// ─── Key panel actions ──────────────────────────────────────────────────────── + +function normalizeJsonKey(jsonKey: string): string { + return jsonKey.replace(/\.antwort$/, "").trim(); +} + +function parseFragen(lines: string, jsonKey: string): FrageVorlage[] { + const clean = lines + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + + const base = jsonKey.split(".").filter(Boolean).at(-1) || "frage"; + return clean.map((text, index) => ({ + id: `${base}_${index + 1}`, + text, + })); +} + +kpAssign.addEventListener("click", () => { + const jsonKey = normalizeJsonKey(kpJsonKey.value); + const label = kpLabel.value.trim(); + const antwortFormat = kpAntwortFormat.value as AntwortFormat; + const fragen = parseFragen(kpFragen.value, jsonKey); + + kpJsonKey.value = jsonKey; + + if (pendingManualRect) { + const name = kpFieldId.value.trim(); + if (!name) { kpFieldId.focus(); return; } + + const entry: FieldEntry = { + id: `manual-${Date.now()}`, + fieldName: name, + jsonKey, + rect: pendingManualRect, + page: currentPage, + type: "manual", + label: label || undefined, + antwortFormat, + fragen, + }; + fields.push(entry); + selectedFieldId = entry.id; + pendingManualRect = null; + } else if (selectedFieldId) { + const field = fields.find(f => f.id === selectedFieldId); + if (field) { + field.jsonKey = jsonKey; + field.label = label || undefined; + field.antwortFormat = antwortFormat; + field.fragen = fragen; + } + } + + persist(); + hideKeyPanel(); + renderOverlay(); + renderFieldList(); +}); + +kpCancel.addEventListener("click", () => { + pendingManualRect = null; + selectedFieldId = null; + hideKeyPanel(); + renderOverlay(); +}); + +kpRemove.addEventListener("click", () => { + if (!selectedFieldId) return; + fields = fields.filter(f => f.id !== selectedFieldId); + selectedFieldId = null; + persist(); + hideKeyPanel(); + renderOverlay(); + renderFieldList(); +}); + +kpJsonKey.addEventListener("keydown", e => { + if (e.key === "Enter") kpAssign.click(); + if (e.key === "Escape") kpCancel.click(); +}); + +kpFieldId.addEventListener("keydown", e => { + if (e.key === "Enter") kpJsonKey.focus(); +}); + +// ─── Global keyboard shortcuts ──────────────────────────────────────────────── + +document.addEventListener("keydown", e => { + if (e.key === "Escape") { + if (isDrawMode) { exitDrawMode(); return; } + if (!keyPanel.classList.contains("hidden")) kpCancel.click(); + } +}); + +// ─── Field list sidebar ─────────────────────────────────────────────────────── + +function renderFieldList() { + const mapped = fields.filter(f => f.jsonKey).length; + fieldCount.textContent = `${mapped} / ${fields.length} mapped`; + + if (fields.length === 0) { + fieldList.innerHTML = '

No fields detected.
Use "Draw Field" to add manually.

'; + return; + } + + const byPage = new Map(); + for (const f of fields) { + if (!byPage.has(f.page)) byPage.set(f.page, []); + byPage.get(f.page)!.push(f); + } + + fieldList.innerHTML = ""; + for (const [page, pf] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) { + const label = document.createElement("div"); + label.className = "field-page-label"; + label.textContent = `Page ${page}`; + fieldList.appendChild(label); + + for (const field of pf) { + const item = document.createElement("div"); + item.className = "field-item" + + (field.id === selectedFieldId ? " selected" : "") + + (field.jsonKey ? " mapped" : ""); + item.dataset.id = field.id; + item.innerHTML = ` + ${field.fieldName} + ${field.jsonKey || "unmapped"} + `; + item.addEventListener("click", async () => { + if (field.page !== currentPage) { + currentPage = field.page; + await renderPage(currentPage); + } + selectField(field.id); + }); + fieldList.appendChild(item); + } + } +} + +// ─── Save ───────────────────────────────────────────────────────────────────── + +function setNested(obj: Record, path: string, value: unknown): void { + const parts = path.split("."); + let cur: Record = obj; + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i]!; + if (typeof cur[key] !== "object" || cur[key] === null) cur[key] = {}; + cur = cur[key] as Record; + } + cur[parts[parts.length - 1]!] = value; +} + +saveBtn.addEventListener("click", async () => { + const template: Record = {}; + for (const f of fields) { + if (!f.jsonKey) continue; + + const isAntwortBlock = f.jsonKey.startsWith("fragen.") || f.jsonKey.startsWith("kriterium."); + setNested( + template, + f.jsonKey, + isAntwortBlock + ? { + fieldName: f.fieldName, + label: f.label, + antwortFormat: f.antwortFormat ?? "einzelfrage", + fragen: f.fragen ?? [], + } + : f.fieldName, + ); + } + + try { + await fetch("/api/save", { + method: "POST", + headers: csrfHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(template), + }); + const orig = saveBtn.textContent; + saveBtn.textContent = "Saved ✓"; + setTimeout(() => { saveBtn.textContent = orig; }, 2000); + } catch (err) { + console.error("Save failed:", err); + } +}); + +function persist() { + if (!currentFilename) return; + localStorage.setItem(`tb:${currentFilename}`, JSON.stringify(fields)); +} diff --git a/packages/templatebuilder/index.html b/packages/templatebuilder/index.html new file mode 100644 index 0000000..2e0b0b1 --- /dev/null +++ b/packages/templatebuilder/index.html @@ -0,0 +1,94 @@ + + + + + + Template Builder + + + +
+
+ Template Builder + +
+
+ + + + +
+
+ +
+ + +
+ +
+
+ +

Upload a PDF to get started

+
+
+ + +
+
+
+
+ + + + + + + diff --git a/packages/templatebuilder/index.ts b/packages/templatebuilder/index.ts new file mode 100644 index 0000000..214d5ac --- /dev/null +++ b/packages/templatebuilder/index.ts @@ -0,0 +1,96 @@ +import index from "./index.html"; +import { dirname, join } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { dataPath } from "../../deployPaths"; +import { isSafeStem, jsonError, withAuth } from "../../security"; + +export const workerPath = new URL( + import.meta.resolve("pdfjs-dist/build/pdf.worker.mjs"), +).pathname; + +const TEMPLATE_PATH = + process.env.BMP_TEMPLATE_PATH ?? + dataPath(join(import.meta.dir, "template.json"), "templatebuilder", "template.json"); + +await mkdir(dirname(TEMPLATE_PATH), { recursive: true }); + +async function loadTemplate(): Promise> { + const file = Bun.file(TEMPLATE_PATH); + if (!(await file.exists())) return {}; + return file.json(); +} + +export const routes = { + "/builder": index, + + "/api/save": { + POST: withAuth(async (req: Request) => { + let data; + try { + data = await req.json(); + } catch { + return jsonError("Expected JSON body", 400); + } + if (!isSafeTemplate(data)) { + return jsonError("Template contains unsafe keys or too many fields", 400); + } + await Bun.write(TEMPLATE_PATH, JSON.stringify(data, null, 2)); + return Response.json({ ok: true }); + }, { + csrf: true, + limit: { key: "template-save", max: 20, windowMs: 60_000 }, + }), + }, + + "/api/load": { + GET: withAuth(async () => { + return Response.json(await loadTemplate()); + }), + }, +} as const; + +function isSafeTemplate(value: unknown): value is Record { + let fieldCount = 0; + + function visit(node: unknown, depth: number): boolean { + if (depth > 8 || typeof node !== "object" || node === null || Array.isArray(node)) { + return false; + } + for (const [key, child] of Object.entries(node as Record)) { + if (!/^[a-zA-Z0-9_-]{1,80}$/.test(key)) return false; + if (typeof child === "string") { + fieldCount += 1; + if (child.length > 200 || fieldCount > 250) return false; + continue; + } + if (typeof child === "object" && child !== null && "fieldName" in child) { + fieldCount += 1; + if (fieldCount > 250 || !isSafeFieldObject(child)) return false; + continue; + } + if (!visit(child, depth + 1)) return false; + } + return true; + } + + return visit(value, 0); +} + +function isSafeFieldObject(value: object): boolean { + const field = value as Record; + if (typeof field.fieldName !== "string" || field.fieldName.length > 200) return false; + if (field.label != null && (typeof field.label !== "string" || field.label.length > 300)) return false; + if (field.antwortFormat != null && field.antwortFormat !== "einzelfrage" && field.antwortFormat !== "mehrfachfrage_ein_antwortfeld") { + return false; + } + if (field.fragen != null) { + if (!Array.isArray(field.fragen) || field.fragen.length > 25) return false; + for (const frage of field.fragen) { + if (typeof frage !== "object" || frage === null) return false; + const item = frage as Record; + if (typeof item.text !== "string" || item.text.length > 1000) return false; + if (item.id != null && (typeof item.id !== "string" || !isSafeStem(item.id))) return false; + } + } + return true; +} diff --git a/packages/templatebuilder/package.json b/packages/templatebuilder/package.json new file mode 100644 index 0000000..c57452d --- /dev/null +++ b/packages/templatebuilder/package.json @@ -0,0 +1,7 @@ +{ + "name": "templatebuilder", + "version": "0.1.0", + "main": "index.ts", + "type": "module", + "private": true +} diff --git a/packages/templatebuilder/styles.css b/packages/templatebuilder/styles.css new file mode 100644 index 0000000..979b8c3 --- /dev/null +++ b/packages/templatebuilder/styles.css @@ -0,0 +1,379 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0d0d0d; + --surface: #161616; + --surface2: #1e1e1e; + --border: #2a2a2a; + --text: #e0e0e0; + --muted: #777; + --accent: #2563eb; + --green: #16a34a; + --orange: #ea580c; + --danger: #dc2626; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 13px; + color-scheme: dark; +} + +body { + background: var(--bg); + color: var(--text); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + gap: 12px; +} + +.header-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.app-name { + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} + +.filename { + font-size: 11px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbar { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 11px; + border-radius: 5px; + border: 1px solid var(--border); + background: var(--surface2); + color: var(--text); + cursor: pointer; + font-size: 12px; + line-height: 1; + transition: background 0.12s, border-color 0.12s; + white-space: nowrap; +} + +.btn:hover:not(:disabled) { background: #282828; } +.btn:disabled { opacity: 0.38; cursor: not-allowed; } + +.btn-primary { + background: var(--accent); + border-color: #1d4ed8; +} +.btn-primary:hover:not(:disabled) { background: #1d4ed8; } + +.btn-danger { + background: var(--danger); + border-color: #b91c1c; +} +.btn-danger:hover:not(:disabled) { background: #b91c1c; } + +.btn.active { + background: var(--accent); + border-color: #1d4ed8; +} + +.btn-icon { + padding: 5px 10px; + font-size: 14px; +} + +/* ── Layout ───────────────────────────────────────────────────────────────── */ + +.app { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ── Sidebar ──────────────────────────────────────────────────────────────── */ + +.sidebar { + width: 240px; + flex-shrink: 0; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.sidebar-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); } + +.field-count { font-size: 11px; color: var(--muted); } + +.field-list { + flex: 1; + overflow-y: auto; + padding: 2px 0; +} + +.field-empty { + padding: 16px 12px; + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} + +.field-page-label { + padding: 8px 12px 3px; + font-size: 10px; + color: var(--muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.field-item { + padding: 6px 12px; + cursor: pointer; + border-left: 2px solid transparent; + transition: background 0.1s; +} + +.field-item:hover { background: #1a1a1a; } + +.field-item.selected { + background: rgba(37, 99, 235, 0.1); + border-left-color: var(--accent); +} + +.fi-name { + display: block; + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fi-key { + display: block; + font-size: 11px; + color: var(--orange); + font-family: "SF Mono", "Fira Mono", monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.field-item.mapped .fi-key { color: var(--green); } + +/* ── Viewer ───────────────────────────────────────────────────────────────── */ + +.viewer { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.pagination { + display: flex; + align-items: center; + gap: 10px; + justify-content: center; + padding: 6px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.page-info { + font-size: 12px; + color: var(--muted); + min-width: 90px; + text-align: center; +} + +.canvas-container { + flex: 1; + overflow: auto; + background: #0a0a0a; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 20px; + position: relative; +} + +.upload-prompt { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: var(--muted); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + pointer-events: none; +} + +.upload-prompt p { font-size: 13px; } + +/* Two canvases stacked */ + +.canvases { + position: relative; + display: none; /* shown after PDF loads */ + flex-shrink: 0; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 8px 32px rgba(0, 0, 0, 0.6); +} + +#pdf-canvas { display: block; } +#overlay-canvas { position: absolute; top: 0; left: 0; cursor: default; } + +/* ── Key panel ────────────────────────────────────────────────────────────── */ + +.key-panel { + position: fixed; + bottom: 16px; + right: 16px; + width: 300px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 10px 36px rgba(0, 0, 0, 0.55); + z-index: 200; +} + +.key-panel.hidden { display: none; } + +.key-panel-inner { + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.key-panel-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.kp-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.kp-field-name { + font-size: 12px; + font-family: "SF Mono", "Fira Mono", monospace; + color: var(--text); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.kp-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + font-weight: 500; + flex-shrink: 0; +} + +.kp-badge-acroform { background: rgba(37,99,235,0.2); color: #60a5fa; } +.kp-badge-manual { background: rgba(234,88,12,0.2); color: #fb923c; } + +.kp-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.kp-row.hidden { display: none; } + +.kp-row label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.kp-input { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + padding: 6px 8px; + border-radius: 4px; + font-family: "SF Mono", "Fira Mono", monospace; + font-size: 12px; + width: 100%; + transition: border-color 0.12s; +} + +.kp-select { + font-family: inherit; +} + +.kp-textarea { + min-height: 110px; + resize: vertical; + line-height: 1.45; +} + +.kp-input:focus { + outline: none; + border-color: var(--accent); +} + +.kp-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.kp-actions .btn-danger { margin-left: auto; } + +/* ── Scrollbar ────────────────────────────────────────────────────────────── */ + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #444; } diff --git a/packages/templatebuilder/template.json b/packages/templatebuilder/template.json new file mode 100644 index 0000000..c2f7f21 --- /dev/null +++ b/packages/templatebuilder/template.json @@ -0,0 +1,169 @@ +{ + "kontakt": { + "unternehmen": { + "name": "Textfeld 1", + "branche": "Textfeld 1_2", + "rechtsform": "Textfeld 1_3", + "plz": "Textfeld 1_4", + "regierungsbezirk": "Textfeld 1_5", + "adresse": "Textfeld 1_6", + "telefon": "Textfeld 1_7", + "email": "Textfeld 1_8", + "web": "Textfeld 1_9" + }, + "ansprechpartner": { + "name": "Textfeld 1_10", + "funktion": "Textfeld 1_11", + "telefon": "Textfeld 1_12", + "email": "Textfeld 1_13" + } + }, + "unternehmen": { + "gruendungsjahr": "Textfeld 1_14", + "anzahl_mitarbeiter": "Textfeld 1_15", + "anzahl_azubi": "Textfeld 1_16", + "standorte_deutschland": "Textfeld 1_17", + "standorte_ausland": "Textfeld 1_18", + "umsatzvolumen1": "Textfeld 1_19", + "umsatzvolumen2": "Textfeld 1_20", + "umsatzvolumen3": "Textfeld 1_21", + "referenzen": "Textfeld 1_22" + }, + "fragen": { + "frage1": { + "fieldName": "Textfeld 2", + "label": "Frage 1", + "antwortFormat": "einzelfrage", + "fragen": [ + { + "id": "frage1_1", + "text": "Was zeichnet Ihr Unternehmen Ihrer Meinung nach aus?" + } + ] + }, + "frage2": { + "fieldName": "Textfeld 2_2", + "label": "Frage 2", + "antwortFormat": "einzelfrage", + "fragen": [ + { + "id": "frage2_1", + "text": "Wie definieren Sie für Ihr Unternehmen den Begriff „erfolgreich“?" + } + ] + }, + "frage3": { + "fieldName": "Textfeld 2_3", + "label": "Frage 3", + "antwortFormat": "einzelfrage", + "fragen": [ + { + "id": "frage3_1", + "text": "Beschreiben Sie kurz, wie sich Ihr Unternehmen in den letzten 5 Jahren entwickelt hat?" + } + ] + } + }, + "kriterium": { + "robustheit_resilienz": { + "fieldName": "Textfeld 3", + "label": "Robustheit/Resilienz", + "antwortFormat": "mehrfachfrage_ein_antwortfeld", + "fragen": [ + { + "id": "robustheit_resilienz_1", + "text": "Wie hat Ihr Unternehmen auf Veränderungen in der Vergangenheit reagiert?" + }, + { + "id": "robustheit_resilienz_2", + "text": "Wie können in Ihrem Unternehmen frühzeitig neue Herausforderungen erkannt und gemanagt werden? Haben Sie hierzu beispielsweise Strukturen, Prozesse oder Instrumente (z.B. Risikomanagementsystem) im Einsatz?" + }, + { + "id": "robustheit_resilienz_3", + "text": "Wie sichern Sie in Ihrem Unternehmen die Verbundenheit mit Ihren Stakeholdern, Kunden, Lieferanten und Mitarbeitenden?" + }, + { + "id": "robustheit_resilienz_4", + "text": "Wie ist Ihr Unternehmen finanziell aufgestellt, um Liquiditätsengpässe zu überbrücken und den langfristigen Erfolg zu sichern?" + }, + { + "id": "robustheit_resilienz_5", + "text": "Wie ist Ihr Unternehmen am Standort Bayern, aber auch über die Region hinaus vernetzt? Besteht beispielsweise ein Austausch im Rahmen von Netzwerken, die Zusammenarbeit mit öffentlichen Stellen oder gibt es sonstige Formen der Vernetzung, die helfen, den Standort Bayern zu sichern?" + } + ] + }, + "zukunftsfaehigkeit_innovation": { + "fieldName": "Textfeld 6", + "label": "Zukunftsfähigkeit/Innovation", + "antwortFormat": "mehrfachfrage_ein_antwortfeld", + "fragen": [ + { + "id": "zukunftsfaehigkeit_innovation_1", + "text": "Welche Ziele hinsichtlich Innovationen haben Sie in Ihrem Unternehmen formuliert, um Kundenbedarfe zu erkennen?" + }, + { + "id": "zukunftsfaehigkeit_innovation_2", + "text": "Wie stellen Sie sicher, dass Sie neue Trends erkennen und in Ihrem Unternehmen umsetzen, um Ihre Wettbewerbsfähigkeit zu sichern?" + }, + { + "id": "zukunftsfaehigkeit_innovation_3", + "text": "Wie passiert bei Ihnen Produkt- und/oder Prozess-Innovation, d.h. wie kommen Sie z.B. zu neuen Produkten und Prozessen?" + }, + { + "id": "zukunftsfaehigkeit_innovation_4", + "text": "Welche konkreten Maßnahmen bestehen zum Aufbau von innovativen Netzwerken, zur Integration von neuen Partnern und Geschäftsbeziehungen und zur Erweiterung der eigenen Fähigkeiten?" + }, + { + "id": "zukunftsfaehigkeit_innovation_5", + "text": "Welche Form von Wissensmanagement und Wissensarbeit besteht bei Ihnen im Unternehmen? Wie wird beispielsweise Wissen unabhängig von Personen gespeichert, wie wird es verarbeitet und priorisiert?" + } + ] + }, + "nachhaltigkeit_verantwortung": { + "fieldName": "Textfeld 6_2", + "label": "Nachhaltigkeit/Verantwortung", + "antwortFormat": "mehrfachfrage_ein_antwortfeld", + "fragen": [ + { + "id": "nachhaltigkeit_verantwortung_1", + "text": "Welche Aktivitäten zeigen Sie in Bezug auf den nachhaltigen Einsatz von Ressourcen, Rohstoffen und Materialien?" + }, + { + "id": "nachhaltigkeit_verantwortung_2", + "text": "Welche Rolle spielen regionale Lieferanten und regionale Wertschöpfungsketten in Ihrem Unternehmen? Die sog. „Corporate Social Responsibility“ kann sich durch unterschiedliche Aktivitäten im Unternehmen äußern. Mit welchen Aktivitäten kommen Sie der „sozialen und regionalen“ Verantwortung in Ihrem Unternehmen nach?" + }, + { + "id": "nachhaltigkeit_verantwortung_3", + "text": "Welche Aspekte sprechen aus Ihrer Sicht für die Bindung an Ihren Standort? Welche Rollen sehen Sie für sich dabei, die Region zu entwickeln?" + } + ] + }, + "attraktivitaet": { + "fieldName": "Textfeld 4", + "label": "Attraktivität", + "antwortFormat": "mehrfachfrage_ein_antwortfeld", + "fragen": [ + { + "id": "attraktivitaet_1", + "text": "Wie sorgen Sie dafür, dass Ihre Mitarbeiterinnen und Mitarbeiter gerne einen Beitrag für den Erfolg Ihres Unternehmens leisten? Wie sorgen Sie für „Freude an Resultaten“ und Durchhaltevermögen in „schwierigen Zeiten“?" + }, + { + "id": "attraktivitaet_2", + "text": "Auf welche Weise wird für Ihre Mitarbeiterinnen und Mitarbeiter der Sinn und Nutzen ihrer täglichen Arbeit erlebbar? Woran erkennen Sie, dass das gelingt?" + }, + { + "id": "attraktivitaet_3", + "text": "Welche Möglichkeiten bestehen in Ihrem Unternehmen für die persönliche und fachliche Entwicklung der Mitarbeiterinnen und Mitarbeiter? Inwiefern können diese Einfluss darauf nehmen?" + }, + { + "id": "attraktivitaet_4", + "text": "Woran können potentielle Bewerber/-innen Ihr Unternehmen als exzellenten Arbeitgeber erkennen? Wie stellen Sie sicher, dass dies für die richtigen Bewerber ersichtlich ist?" + }, + { + "id": "attraktivitaet_5", + "text": "Woran erkennen Sie, dass Ihre Unternehmenskultur „funktioniert“? Wie stellen Sie sicher, dass Verantwortung, Vertrauen, Leistung, Lernen und Innovation in die Unternehmenskultur einfließen?" + } + ] + } + } +} \ No newline at end of file diff --git a/packages/templatebuilder/tsconfig.json b/packages/templatebuilder/tsconfig.json new file mode 100644 index 0000000..4082f16 --- /dev/null +++ b/packages/templatebuilder/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/security.ts b/security.ts new file mode 100644 index 0000000..1f2cdfd --- /dev/null +++ b/security.ts @@ -0,0 +1,327 @@ +const AUTH_COOKIE = "bmp_demo_auth"; +const CSRF_COOKIE = "bmp_demo_csrf"; +const AUTH_TTL_SECONDS = 8 * 60 * 60; +const DEFAULT_LLM_MODEL = "openai/gpt-5.4-nano"; + +const configuredPassword = + process.env.BMP_DEMO_PASSWORD ?? + process.env.APP_ACCESS_TOKEN ?? + process.env.DEMO_ACCESS_TOKEN; + +export const demoPassword = configuredPassword || crypto.randomUUID(); + +if (!configuredPassword) { + console.warn("BMP_DEMO_PASSWORD is not set. Using this one-time demo password:"); + console.warn(` ${demoPassword}`); +} + +const rateLimitBuckets = new Map(); + +export interface SecurityOptions { + csrf?: boolean; + limit?: { + key: string; + max: number; + windowMs: number; + }; +} + +export type RouteHandler = (req: T) => Response | Promise; + +export const securityHeaders: Record = { + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", + "X-Frame-Options": "DENY", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()", +}; + +export function isSafeStem(value: string): boolean { + return /^[a-z0-9][a-z0-9_-]{0,80}$/i.test(value); +} + +export function normalizeStem(value: string): string { + const ascii = value + .normalize("NFKD") + .replace(/\p{Diacritic}/gu, "") + .replace(/ß/g, "ss") + .replace(/ẞ/g, "SS"); + return ascii + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .slice(0, 80); +} + +export function allowedModelFromEnv(): string { + return process.env.OPENROUTER_MODEL || DEFAULT_LLM_MODEL; +} + +export function validateRequestedModel(value: unknown): string | undefined { + const allowed = allowedModelFromEnv(); + if (value == null || value === "") return allowed; + return value === allowed ? allowed : undefined; +} + +export function getCsrfCookieName(): string { + return CSRF_COOKIE; +} + +export function csrfClientScript(): string { + return ` + function csrfHeaders(extra = {}) { + const csrf = document.cookie + .split("; ") + .find((part) => part.startsWith("${CSRF_COOKIE}=")) + ?.slice("${CSRF_COOKIE}=".length); + return csrf ? { ...extra, "X-BMP-CSRF": decodeURIComponent(csrf) } : extra; + } + `; +} + +export function addSecurityHeaders(response: Response): Response { + for (const [key, value] of Object.entries(securityHeaders)) { + response.headers.set(key, value); + } + return response; +} + +export async function requireAuth(req: Request, options: SecurityOptions = {}): Promise { + if (!rateLimit(req, "auth-check", 600, 60_000)) { + return jsonError("Too many requests", 429); + } + + if (options.limit && !rateLimit(req, options.limit.key, options.limit.max, options.limit.windowMs)) { + return jsonError("Too many requests", 429); + } + + if (!(await isAuthenticated(req))) { + return unauthorized(req); + } + + if (options.csrf && !hasValidCsrf(req)) { + return jsonError("Invalid CSRF token", 403); + } + + return undefined; +} + +export function withAuth(handler: (req: T) => unknown | Promise, options: SecurityOptions = {}): RouteHandler { + return async (req: T) => { + const blocked = await requireAuth(req, options); + if (blocked) return blocked; + const response = await handler(req); + return (response instanceof Response ? addSecurityHeaders(response) : response) as Response; + }; +} + +export function loginPage(req: Request): Response { + const next = safeNextUrl(new URL(req.url).searchParams.get("next") || "/"); + return html(` + + + + + BMP Demo Login + + + +
+

BMP Demo

+

Bitte melden Sie sich an, bevor Bewerbungen, Berichte oder LLM-Jobs geöffnet werden.

+ + + +
+ +`); +} + +export async function login(req: Request): Promise { + if (!rateLimit(req, "login", 8, 5 * 60_000)) { + return html("Zu viele Login-Versuche. Bitte kurz warten.", 429); + } + + let form: FormData; + try { + form = await req.formData(); + } catch { + return html("Ungültige Login-Anfrage.", 400); + } + + const password = String(form.get("password") ?? ""); + const next = safeNextUrl(String(form.get("next") ?? "/")); + if (!timingSafeEqual(password, demoPassword)) { + return html("Falsches Demo-Passwort.", 401); + } + + const expiresAt = Math.floor(Date.now() / 1000) + AUTH_TTL_SECONDS; + const authCookie = await createAuthCookie(expiresAt); + const csrf = crypto.randomUUID(); + const headers = new Headers({ + ...securityHeaders, + Location: next, + }); + headers.append("Set-Cookie", `${AUTH_COOKIE}=${authCookie}; Max-Age=${AUTH_TTL_SECONDS}; Path=/; HttpOnly; SameSite=Strict`); + headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Max-Age=${AUTH_TTL_SECONDS}; Path=/; SameSite=Strict`); + return new Response(null, { + status: 303, + headers, + }); +} + +export function logout(): Response { + const headers = new Headers({ + ...securityHeaders, + Location: "/login", + }); + headers.append("Set-Cookie", `${AUTH_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`); + headers.append("Set-Cookie", `${CSRF_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`); + return new Response(null, { + status: 303, + headers, + }); +} + +export function jsonError(error: string, status: number): Response { + return addSecurityHeaders(Response.json({ error }, { status })); +} + +function unauthorized(req: Request): Response { + const url = new URL(req.url); + const acceptsHtml = req.headers.get("accept")?.includes("text/html"); + if (req.method === "GET" && acceptsHtml) { + return new Response(null, { + status: 303, + headers: { + ...securityHeaders, + Location: `/login?next=${encodeURIComponent(url.pathname + url.search)}`, + }, + }); + } + return jsonError("Authentication required", 401); +} + +function html(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { + ...securityHeaders, + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "default-src 'self'; style-src 'unsafe-inline' 'self'; script-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", + }, + }); +} + +function rateLimit(req: Request, key: string, max: number, windowMs: number): boolean { + const bucketKey = `${key}:${clientIp(req)}`; + const now = Date.now(); + const bucket = rateLimitBuckets.get(bucketKey); + if (!bucket || bucket.resetAt <= now) { + rateLimitBuckets.set(bucketKey, { count: 1, resetAt: now + windowMs }); + return true; + } + bucket.count += 1; + return bucket.count <= max; +} + +function clientIp(req: Request): string { + return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "local"; +} + +function hasValidCsrf(req: Request): boolean { + const header = req.headers.get("x-bmp-csrf"); + const cookie = parseCookies(req.headers.get("cookie")).get(CSRF_COOKIE); + return Boolean(header && cookie && timingSafeEqual(header, cookie)); +} + +async function isAuthenticated(req: Request): Promise { + const auth = parseCookies(req.headers.get("cookie")).get(AUTH_COOKIE); + if (auth && await verifyAuthCookie(auth)) return true; + + const authorization = req.headers.get("authorization") ?? ""; + if (authorization.startsWith("Bearer ")) { + return timingSafeEqual(authorization.slice("Bearer ".length), demoPassword); + } + if (authorization.startsWith("Basic ")) { + try { + const decoded = atob(authorization.slice("Basic ".length)); + const password = decoded.includes(":") ? decoded.slice(decoded.indexOf(":") + 1) : decoded; + return timingSafeEqual(password, demoPassword); + } catch { + return false; + } + } + return false; +} + +async function createAuthCookie(expiresAt: number): Promise { + const payload = String(expiresAt); + return `${payload}.${await sign(payload)}`; +} + +async function verifyAuthCookie(value: string): Promise { + const [payload, signature] = value.split("."); + const expiresAt = Number(payload); + if (!payload || !signature || !Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) { + return false; + } + return timingSafeEqual(signature, await sign(payload)); +} + +async function sign(payload: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(demoPassword), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); + return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function parseCookies(header: string | null): Map { + const cookies = new Map(); + for (const part of (header ?? "").split(";")) { + const index = part.indexOf("="); + if (index < 0) continue; + cookies.set(part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())); + } + return cookies; +} + +function timingSafeEqual(a: string, b: string): boolean { + const left = new TextEncoder().encode(a); + const right = new TextEncoder().encode(b); + const length = Math.max(left.length, right.length); + let diff = left.length ^ right.length; + for (let index = 0; index < length; index += 1) { + diff |= (left[index] ?? 0) ^ (right[index] ?? 0); + } + return diff === 0; +} + +function safeNextUrl(value: string): string { + return value.startsWith("/") && !value.startsWith("//") ? value : "/"; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[char]!)); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..be3d138 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}