initial commit
This commit is contained in:
1
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Symbolic link
1
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Symbolic link
@@ -0,0 +1 @@
|
||||
../../CLAUDE.md
|
||||
20
.dockerignore
Normal file
20
.dockerignore
Normal file
@@ -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
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -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
|
||||
|
After Width: | Height: | Size: 401 B |
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -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
|
||||
111
AGENTS.md
Normal file
111
AGENTS.md
Normal file
@@ -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 <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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 <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
111
CLAUDE.md
Normal file
111
CLAUDE.md
Normal file
@@ -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 <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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 <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
9
Caddyfile
Normal file
9
Caddyfile
Normal file
@@ -0,0 +1,9 @@
|
||||
{$APP_DOMAIN} {
|
||||
encode zstd gzip
|
||||
|
||||
request_body {
|
||||
max_size {$MAX_UPLOAD_SIZE}
|
||||
}
|
||||
|
||||
reverse_proxy bmp:3000
|
||||
}
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -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"]
|
||||
69
README.md
Normal file
69
README.md
Normal file
@@ -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.
|
||||
BIN
assets/bmp-logo.png
Normal file
BIN
assets/bmp-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
333
bun.lock
Normal file
333
bun.lock
Normal file
@@ -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=="],
|
||||
}
|
||||
}
|
||||
7
deployPaths.ts
Normal file
7
deployPaths.ts
Normal file
@@ -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;
|
||||
}
|
||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -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:
|
||||
916
index.html
Normal file
916
index.html
Normal file
@@ -0,0 +1,916 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BMP Bewerbungscheck</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #071225;
|
||||
--bg-deep: #000a2d;
|
||||
--surface: rgba(10, 24, 50, 0.9);
|
||||
--surface-soft: rgba(16, 34, 68, 0.9);
|
||||
--surface-blue: rgba(18, 49, 96, 0.82);
|
||||
--border: rgba(223, 232, 242, 0.16);
|
||||
--border-strong: rgba(223, 232, 242, 0.34);
|
||||
--text: #ffffff;
|
||||
--muted: #b9c7d6;
|
||||
--soft: #dfe8f2;
|
||||
--accent: #ffd21f;
|
||||
--accent-2: #f5b90b;
|
||||
--accent-soft: rgba(255, 210, 31, 0.14);
|
||||
--green: #22c55e;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
html { background: var(--bg); }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgba(255,255,255,0.12), transparent 32%),
|
||||
linear-gradient(120deg, rgba(0, 10, 45, .96), rgba(7, 35, 77, .92)),
|
||||
linear-gradient(30deg, transparent 0 44%, rgba(255,255,255,.08) 45% 47%, transparent 48% 100%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
padding: 34px;
|
||||
}
|
||||
.app {
|
||||
width: min(1120px, 100%);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 26px;
|
||||
animation: pageIn 0.38s ease-out both;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
gap: 28px;
|
||||
align-items: end;
|
||||
}
|
||||
.brand-mark {
|
||||
width: min(330px, 100%);
|
||||
display: block;
|
||||
margin: 8px auto 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
max-width: 760px;
|
||||
font-size: clamp(30px, 5vw, 54px);
|
||||
font-weight: 760;
|
||||
line-height: 1.02;
|
||||
letter-spacing: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.sub {
|
||||
color: var(--soft);
|
||||
font-size: clamp(16px, 2vw, 18px);
|
||||
line-height: 1.6;
|
||||
max-width: 720px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.promise {
|
||||
background: rgba(6, 18, 43, 0.74);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.promise h2, .panel h2, .progress-card h2 {
|
||||
color: var(--soft);
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.promise-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 10px;
|
||||
color: var(--soft);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 760;
|
||||
flex: none;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 430px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.panel, .progress-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px;
|
||||
min-width: 0;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
.panel-intro {
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-size: 14px;
|
||||
}
|
||||
input[type="file"] { display: none; }
|
||||
.drop-zone {
|
||||
min-height: 220px;
|
||||
border: 2px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
padding: 28px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.over {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-blue);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 14px 36px rgba(255, 210, 31, 0.12);
|
||||
}
|
||||
.drop-zone.has-file {
|
||||
border-color: rgba(34,197,94,0.72);
|
||||
background: rgba(20, 83, 45, 0.34);
|
||||
}
|
||||
.drop-copy {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 9px;
|
||||
color: var(--soft);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.drop-title {
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.drop-help {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.file-name {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
button {
|
||||
min-height: 46px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent);
|
||||
color: #08111f;
|
||||
font-size: 15px;
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease, border-color 0.14s ease;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 24px rgba(255, 210, 31, 0.2);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.48;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.status {
|
||||
min-height: 22px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.progress-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 650px;
|
||||
}
|
||||
.steps {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.step {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.step.active {
|
||||
border-color: rgba(255,210,31,0.46);
|
||||
background: var(--surface-blue);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.step.done { background: rgba(20, 83, 45, 0.34); border-color: rgba(34,197,94,0.32); }
|
||||
.step.error { background: rgba(127, 29, 29, 0.34); border-color: rgba(239,68,68,0.38); }
|
||||
.dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #172033;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.step.active .dot {
|
||||
background: var(--accent);
|
||||
color: #08111f;
|
||||
box-shadow: 0 0 0 6px rgba(255,210,31,0.14);
|
||||
animation: pulse 1.25s ease-in-out infinite;
|
||||
}
|
||||
.step.done .dot { background: var(--green); color: #06121f; }
|
||||
.step.error .dot { background: var(--red); color: white; }
|
||||
.step-title {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.step strong {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 720;
|
||||
}
|
||||
.step small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.step-state {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.step-detail {
|
||||
grid-column: 2 / -1;
|
||||
display: none;
|
||||
gap: 8px;
|
||||
border-top: 1px solid rgba(255,210,31,0.16);
|
||||
padding-top: 10px;
|
||||
color: var(--soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.step-detail.visible {
|
||||
display: grid;
|
||||
}
|
||||
.llm-progress-track {
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(223,232,242,0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
.llm-progress-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
transition: width 0.22s ease;
|
||||
}
|
||||
.llm-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.llm-progress-current {
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
padding: 22px;
|
||||
color: var(--soft);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.empty h3 {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.empty ul {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
}
|
||||
.empty li {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.empty li::before {
|
||||
content: "✓";
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: rgba(34,197,94,0.16);
|
||||
color: var(--green);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
}
|
||||
.result {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.result-head {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(18, 49, 96, 0.92), rgba(10, 24, 50, 0.92));
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.result-head-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
.result-head h3 {
|
||||
font-size: 24px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
.traffic-signal {
|
||||
display: inline-grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-self: end;
|
||||
color: var(--soft);
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
.traffic-signal__body {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
width: 28px;
|
||||
padding: 5px;
|
||||
border-radius: 999px;
|
||||
background: #020617;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12), 0 12px 28px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
.traffic-signal__lamp {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: #64748b;
|
||||
opacity: 0.32;
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.32);
|
||||
}
|
||||
.traffic-signal--gruen .traffic-signal__lamp--gruen {
|
||||
background: #16a34a;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 3px rgba(22,163,74,0.18), 0 0 16px rgba(22,163,74,0.45);
|
||||
}
|
||||
.traffic-signal--gelb .traffic-signal__lamp--gelb {
|
||||
background: #f59e0b;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 3px rgba(245,158,11,0.2), 0 0 16px rgba(245,158,11,0.5);
|
||||
}
|
||||
.traffic-signal--rot .traffic-signal__lamp--rot {
|
||||
background: #dc2626;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 3px rgba(220,38,38,0.18), 0 0 16px rgba(220,38,38,0.45);
|
||||
}
|
||||
.summary {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
color: var(--soft);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.downloads {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-soft);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.download-title {
|
||||
color: var(--text);
|
||||
font-weight: 760;
|
||||
}
|
||||
.download-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.download-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
color: #08111f;
|
||||
text-decoration: none;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
background: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transition: background 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease;
|
||||
}
|
||||
.download-links a:hover {
|
||||
background: var(--accent-2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(37,99,235,0.18);
|
||||
}
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.24s ease, transform 0.24s ease, max-height 0.28s ease;
|
||||
}
|
||||
.reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
max-height: 1800px;
|
||||
}
|
||||
.reveal.is-exiting {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
max-height: 0;
|
||||
}
|
||||
@keyframes pageIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.12); }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 20px; }
|
||||
.hero, .layout { grid-template-columns: 1fr; }
|
||||
.panel { position: static; }
|
||||
.progress-card { min-height: auto; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
body { padding: 14px; }
|
||||
.panel, .progress-card, .promise { padding: 16px; }
|
||||
.actions { grid-template-columns: 1fr; }
|
||||
.step {
|
||||
grid-template-columns: 30px 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
.step-state {
|
||||
grid-column: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
.result-head-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.traffic-signal {
|
||||
justify-self: start;
|
||||
}
|
||||
.drop-zone { min-height: 190px; padding: 22px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Bewerbungscheck</p>
|
||||
<h1>PDF hochladen. Einschätzung und Bericht erhalten.</h1>
|
||||
<p class="sub">Dieses Werkzeug liest einen ausgefüllten Fragebogen aus, fasst die wichtigsten Angaben zusammen und erstellt eine klare Einschätzung mit passenden Exporten.</p>
|
||||
</div>
|
||||
<aside class="promise" aria-label="Was Sie erhalten">
|
||||
<h2>Was Sie erhalten</h2>
|
||||
<div class="promise-item"><span class="icon">1</span><span>Eine verständliche Zusammenfassung des Unternehmens.</span></div>
|
||||
<div class="promise-item"><span class="icon">2</span><span>Eine Ampel-Einschätzung als Entscheidungshilfe.</span></div>
|
||||
<div class="promise-item"><span class="icon">3</span><span>Einen Bericht und Rohdaten zum Speichern oder Weitergeben.</span></div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<section class="panel" aria-labelledby="upload-title">
|
||||
<div>
|
||||
<h2 id="upload-title">Start</h2>
|
||||
<p class="panel-intro">Wählen Sie den ausgefüllten PDF-Fragebogen aus. Danach starten Sie die Auswertung mit einem Klick.</p>
|
||||
</div>
|
||||
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<input type="file" id="file-input" accept=".pdf,application/pdf" />
|
||||
<div class="drop-copy">
|
||||
<span class="icon">↑</span>
|
||||
<span class="drop-title" id="drop-label">PDF auswählen oder hier ablegen</span>
|
||||
<span class="drop-help">Es wird nur eine PDF-Datei benötigt.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="run-btn" disabled>Auswertung starten</button>
|
||||
</div>
|
||||
<p class="status" id="status">Bereit, sobald eine PDF ausgewählt wurde.</p>
|
||||
<img class="brand-mark" src="/assets/bmp-logo.png" alt="Der Bayerische Mittelstandspreis 2026" />
|
||||
</section>
|
||||
|
||||
<section class="progress-card" aria-labelledby="progress-title">
|
||||
<h2 id="progress-title">Aktueller Stand</h2>
|
||||
<div class="steps">
|
||||
<div class="step" id="step-upload">
|
||||
<div class="dot">1</div>
|
||||
<div class="step-title"><strong>Datei erhalten</strong><small>Die PDF wird übernommen.</small></div>
|
||||
<span class="step-state">wartet</span>
|
||||
</div>
|
||||
<div class="step" id="step-extract">
|
||||
<div class="dot">2</div>
|
||||
<div class="step-title"><strong>Angaben lesen</strong><small>Die Antworten werden aus dem Fragebogen ausgelesen.</small></div>
|
||||
<span class="step-state">wartet</span>
|
||||
</div>
|
||||
<div class="step" id="step-mask">
|
||||
<div class="dot">3</div>
|
||||
<div class="step-title"><strong>Datenschutz anwenden</strong><small>Personenbezogene Angaben werden reduziert.</small></div>
|
||||
<span class="step-state">wartet</span>
|
||||
</div>
|
||||
<div class="step" id="step-summarize">
|
||||
<div class="dot">4</div>
|
||||
<div class="step-title"><strong>Einschätzung erstellen</strong><small>Die wichtigsten Punkte werden zusammengefasst und eingeordnet.</small></div>
|
||||
<span class="step-state">wartet</span>
|
||||
<div class="step-detail" id="llm-progress" aria-live="polite">
|
||||
<div class="llm-progress-track"><div class="llm-progress-fill" id="llm-progress-fill"></div></div>
|
||||
<div class="llm-progress-meta">
|
||||
<span id="llm-progress-count">LLM-Aufrufe werden vorbereitet.</span>
|
||||
<span id="llm-progress-time"></span>
|
||||
</div>
|
||||
<div class="llm-progress-current" id="llm-progress-current"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step" id="step-export">
|
||||
<div class="dot">5</div>
|
||||
<div class="step-title"><strong>Downloads vorbereiten</strong><small>Bericht und Rohdaten werden bereitgestellt.</small></div>
|
||||
<span class="step-state">wartet</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty reveal is-visible">
|
||||
<h3>Nach der Auswertung sehen Sie hier das Ergebnis.</h3>
|
||||
<ul>
|
||||
<li>Eine kurze Zusammenfassung, damit Sie schnell verstehen, worum es geht.</li>
|
||||
<li>Eine Ampel-Einschätzung für den schnellen Überblick.</li>
|
||||
<li>Downloads für den Bericht, die Tabelle und die Rohdaten.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="result reveal" id="result-section" hidden>
|
||||
<div class="result-head">
|
||||
<div class="result-head-main">
|
||||
<div>
|
||||
<h3>Die Auswertung ist fertig.</h3>
|
||||
<p class="panel-intro">Prüfen Sie die Zusammenfassung und laden Sie den Bericht oder die Rohdaten herunter.</p>
|
||||
</div>
|
||||
<div class="traffic-signal" id="result-traffic" aria-label="Ampelbewertung">
|
||||
<div class="traffic-signal__body">
|
||||
<span class="traffic-signal__lamp traffic-signal__lamp--rot"></span>
|
||||
<span class="traffic-signal__lamp traffic-signal__lamp--gelb"></span>
|
||||
<span class="traffic-signal__lamp traffic-signal__lamp--gruen"></span>
|
||||
</div>
|
||||
<span id="result-traffic-label">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Zusammenfassung</h2>
|
||||
<div class="summary" id="summary-text"></div>
|
||||
</div>
|
||||
|
||||
<div class="downloads">
|
||||
<div class="download-title">Downloads</div>
|
||||
<div class="download-links" id="downloads"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
const POLL_DELAY_MS = 900;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requestedModel = params.get("model")?.trim();
|
||||
|
||||
if (requestedModel) {
|
||||
console.info(`BMP Bewerbungscheck verwendet Modell: ${requestedModel}`);
|
||||
}
|
||||
|
||||
const dropZone = document.getElementById("drop-zone");
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const dropLabel = document.getElementById("drop-label");
|
||||
const runBtn = document.getElementById("run-btn");
|
||||
const statusEl = document.getElementById("status");
|
||||
const emptyState = document.getElementById("empty-state");
|
||||
const resultSection = document.getElementById("result-section");
|
||||
const downloadsEl = document.getElementById("downloads");
|
||||
const summaryText = document.getElementById("summary-text");
|
||||
const resultTraffic = document.getElementById("result-traffic");
|
||||
const resultTrafficLabel = document.getElementById("result-traffic-label");
|
||||
const llmProgress = document.getElementById("llm-progress");
|
||||
const llmProgressFill = document.getElementById("llm-progress-fill");
|
||||
const llmProgressCount = document.getElementById("llm-progress-count");
|
||||
const llmProgressTime = document.getElementById("llm-progress-time");
|
||||
const llmProgressCurrent = document.getElementById("llm-progress-current");
|
||||
const steps = {
|
||||
upload: document.getElementById("step-upload"),
|
||||
extract: document.getElementById("step-extract"),
|
||||
mask: document.getElementById("step-mask"),
|
||||
summarize: document.getElementById("step-summarize"),
|
||||
export: document.getElementById("step-export"),
|
||||
};
|
||||
|
||||
let selectedFile = null;
|
||||
function csrfHeaders(extra = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", () => setFile(fileInput.files[0]));
|
||||
dropZone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("over");
|
||||
});
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("over"));
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("over");
|
||||
setFile(event.dataTransfer.files[0]);
|
||||
});
|
||||
runBtn.addEventListener("click", runPipeline);
|
||||
|
||||
function setFile(file) {
|
||||
if (!file || file.type !== "application/pdf") {
|
||||
statusEl.textContent = "Bitte wählen Sie eine PDF-Datei aus.";
|
||||
return;
|
||||
}
|
||||
selectedFile = file;
|
||||
dropLabel.textContent = file.name;
|
||||
dropLabel.classList.add("file-name");
|
||||
dropZone.classList.add("has-file");
|
||||
runBtn.disabled = false;
|
||||
statusEl.textContent = "Die Datei ist bereit. Sie können die Auswertung starten.";
|
||||
updateStep("upload", "done", "bereit");
|
||||
}
|
||||
|
||||
function updateStep(name, state, text) {
|
||||
const el = steps[name];
|
||||
el.className = `step ${state || ""}`.trim();
|
||||
el.querySelector(".step-state").textContent = text;
|
||||
}
|
||||
|
||||
function resetLlmProgress() {
|
||||
llmProgress.classList.remove("visible");
|
||||
llmProgressFill.style.width = "0%";
|
||||
llmProgressCount.textContent = "LLM-Aufrufe werden vorbereitet.";
|
||||
llmProgressTime.textContent = "";
|
||||
llmProgressCurrent.textContent = "";
|
||||
}
|
||||
|
||||
function formatElapsed(ms) {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return minutes ? `${minutes}m ${String(rest).padStart(2, "0")}s` : `${rest}s`;
|
||||
}
|
||||
|
||||
function operationLabel(operation) {
|
||||
return {
|
||||
segmentierung: "Antworten zuordnen",
|
||||
zusammenfassung: "Antwort zusammenfassen",
|
||||
scoring: "Ampel bewerten",
|
||||
swot: "SWOT erstellen",
|
||||
}[operation] || "LLM-Aufruf";
|
||||
}
|
||||
|
||||
function renderLlmProgress(job) {
|
||||
const progress = job.llmProgress || {};
|
||||
const total = Number(progress.total || 0);
|
||||
const done = Number(progress.done || 0);
|
||||
const running = Number(progress.running || 0);
|
||||
const failed = Number(progress.error || 0);
|
||||
const known = done + failed;
|
||||
const displayKnown = total ? Math.min(known, total) : known;
|
||||
const percent = total ? Math.round((displayKnown / total) * 100) : 12;
|
||||
|
||||
llmProgress.classList.add("visible");
|
||||
llmProgressFill.style.width = `${Math.max(8, Math.min(100, percent))}%`;
|
||||
llmProgressCount.textContent = total
|
||||
? `${displayKnown} von ${total} LLM-Aufrufen abgeschlossen${running ? `, ${running} läuft` : ""}`
|
||||
: "LLM-Aufruf wird gestartet.";
|
||||
|
||||
const createdAt = Date.parse(job.createdAt || "");
|
||||
llmProgressTime.textContent = Number.isFinite(createdAt)
|
||||
? `läuft seit ${formatElapsed(Date.now() - createdAt)}`
|
||||
: "";
|
||||
|
||||
const latest = progress.latest;
|
||||
if (latest) {
|
||||
const label = `${operationLabel(latest.operation)}: ${latest.label}`;
|
||||
const status = latest.status === "running"
|
||||
? "läuft gerade"
|
||||
: latest.status === "done"
|
||||
? `fertig${latest.durationMs ? ` nach ${formatElapsed(latest.durationMs)}` : ""}`
|
||||
: "fehlgeschlagen";
|
||||
llmProgressCurrent.textContent = `${label} · ${status}`;
|
||||
} else {
|
||||
llmProgressCurrent.textContent = job.statusMessage || "Die Anfrage wurde angenommen und wird verarbeitet.";
|
||||
}
|
||||
}
|
||||
|
||||
function showPanel(el) {
|
||||
el.hidden = false;
|
||||
el.classList.remove("is-exiting");
|
||||
requestAnimationFrame(() => el.classList.add("is-visible"));
|
||||
}
|
||||
|
||||
function hidePanel(el) {
|
||||
if (el.hidden) return;
|
||||
el.classList.remove("is-visible");
|
||||
el.classList.add("is-exiting");
|
||||
window.setTimeout(() => {
|
||||
if (!el.classList.contains("is-visible")) {
|
||||
el.hidden = true;
|
||||
el.classList.remove("is-exiting");
|
||||
}
|
||||
}, 260);
|
||||
}
|
||||
|
||||
async function runPipeline() {
|
||||
if (!selectedFile) return;
|
||||
runBtn.disabled = true;
|
||||
hidePanel(emptyState);
|
||||
hidePanel(resultSection);
|
||||
downloadsEl.innerHTML = "";
|
||||
updateStep("upload", "active", "läuft");
|
||||
updateStep("extract", "", "wartet");
|
||||
updateStep("mask", "", "wartet");
|
||||
updateStep("summarize", "", "wartet");
|
||||
updateStep("export", "", "wartet");
|
||||
resetLlmProgress();
|
||||
statusEl.textContent = "Die PDF wird hochgeladen.";
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("pdf", selectedFile);
|
||||
const extractRes = await fetch("/extract", { method: "POST", headers: csrfHeaders(), body: form });
|
||||
const extractJson = await extractRes.json();
|
||||
if (!extractRes.ok || !extractJson.ok) throw new Error(extractJson.error || "Die Angaben konnten nicht gelesen werden.");
|
||||
|
||||
updateStep("upload", "done", "fertig");
|
||||
updateStep("extract", "done", "fertig");
|
||||
updateStep("mask", "active", "läuft");
|
||||
statusEl.textContent = "Die Angaben wurden gelesen. Jetzt werden die Inhalte für die Einschätzung vorbereitet.";
|
||||
|
||||
const summaryRes = await fetch("/api/summarizer/summarize", {
|
||||
method: "POST",
|
||||
headers: csrfHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({
|
||||
stem: extractJson.stem,
|
||||
...(requestedModel ? { model: requestedModel } : {}),
|
||||
}),
|
||||
});
|
||||
const summaryStart = await summaryRes.json();
|
||||
if (!summaryRes.ok || !summaryStart.ok) throw new Error(summaryStart.error || "Die Einschätzung konnte nicht gestartet werden.");
|
||||
|
||||
updateStep("mask", "done", "fertig");
|
||||
updateStep("summarize", "active", "läuft");
|
||||
statusEl.textContent = "Die Einschätzung wird erstellt. Das kann einen Moment dauern.";
|
||||
|
||||
const job = await waitForJob(summaryStart.jobId);
|
||||
updateStep("summarize", "done", "fertig");
|
||||
updateStep("export", "active", "läuft");
|
||||
statusEl.textContent = "Der Bericht wird vorbereitet.";
|
||||
|
||||
const resultRes = await fetch(`/api/pipeline/result/${encodeURIComponent(extractJson.stem)}`);
|
||||
const result = await resultRes.json();
|
||||
if (!resultRes.ok || !result.ok) throw new Error(result.error || "Das Ergebnis konnte nicht geladen werden.");
|
||||
|
||||
renderResult(result);
|
||||
updateStep("export", "done", "fertig");
|
||||
statusEl.textContent = "Fertig. Bericht und Rohdaten stehen bereit.";
|
||||
} catch (error) {
|
||||
const active = Object.entries(steps).find(([, el]) => el.classList.contains("active"));
|
||||
if (active) updateStep(active[0], "error", "Fehler");
|
||||
statusEl.textContent = String(error).replace(/^Error:\s*/, "");
|
||||
} finally {
|
||||
runBtn.disabled = !selectedFile;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
for (;;) {
|
||||
const jobResult = await fetch(`/api/summarizer/jobs/${jobId}`);
|
||||
const job = await jobResult.json();
|
||||
if (!jobResult.ok) throw new Error(job.error || "Die Auswertung konnte nicht geladen werden.");
|
||||
const progress = job.total ? `${job.completed} von ${job.total}` : "läuft";
|
||||
updateStep("summarize", "active", progress);
|
||||
renderLlmProgress(job);
|
||||
statusEl.textContent = job.statusMessage || "Die Einschätzung wird erstellt. Das kann einen Moment dauern.";
|
||||
if (job.status === "done") return job;
|
||||
if (job.status === "error") throw new Error(job.errors?.join("\\n") || "Die Auswertung ist fehlgeschlagen.");
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(result) {
|
||||
const data = result.summary;
|
||||
const ampel = data.ampelbewertung || {};
|
||||
renderTrafficSignal(ampel.farbe);
|
||||
summaryText.textContent = ampel.begruendung || "Für diese Auswertung liegt keine Zusammenfassung vor.";
|
||||
downloadsEl.innerHTML = "";
|
||||
for (const file of result.downloads || []) {
|
||||
const link = document.createElement("a");
|
||||
link.href = file.url;
|
||||
link.textContent = friendlyDownloadLabel(file.label);
|
||||
downloadsEl.appendChild(link);
|
||||
}
|
||||
showPanel(resultSection);
|
||||
}
|
||||
|
||||
function renderTrafficSignal(color) {
|
||||
const normalized = ["gruen", "gelb", "rot"].includes(color) ? color : "";
|
||||
resultTraffic.className = `traffic-signal${normalized ? ` traffic-signal--${normalized}` : ""}`;
|
||||
resultTrafficLabel.textContent = normalized
|
||||
? { gruen: "Grün", gelb: "Gelb", rot: "Rot" }[normalized]
|
||||
: "-";
|
||||
}
|
||||
|
||||
function friendlyDownloadLabel(label) {
|
||||
if (label.includes("Fragen-PDF")) return "Fragen als PDF";
|
||||
if (label.includes("Fragen-HTML")) return "Fragen als Web-Bericht";
|
||||
if (label.includes("PDF")) return "Bericht als PDF";
|
||||
if (label.includes("XLSX")) return "Tabelle";
|
||||
if (label.includes("Report-HTML")) return "Web-Bericht";
|
||||
if (label.includes("JSON")) return "Rohdaten";
|
||||
return label.replace(" herunterladen", "");
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
224
index.ts
Normal file
224
index.ts
Normal file
@@ -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<Response> {
|
||||
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<string, string | undefined> }).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<string, string | undefined> }).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`);
|
||||
27
package.json
Normal file
27
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
176
packages/extractor/extractor.ts
Normal file
176
packages/extractor/extractor.ts
Normal file
@@ -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<string, string>;
|
||||
|
||||
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<string, FieldIndexEntry>;
|
||||
|
||||
// ─── Template helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function isTemplateLeafObject(value: unknown): value is TemplateLeafObject {
|
||||
return typeof value === "object" && value !== null && typeof (value as Record<string, unknown>).fieldName === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively flattens the nested template produced by templatebuilder.
|
||||
*/
|
||||
export function flattenTemplate(
|
||||
node: Record<string, unknown>,
|
||||
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<string, unknown>, path));
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
// ─── PDF extraction ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function extractFields(
|
||||
data: ArrayBuffer,
|
||||
options: { maxPages?: number } = {},
|
||||
): Promise<FieldValues> {
|
||||
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<string, unknown> {
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>, 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<string, unknown>)[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function setNested(obj: Record<string, unknown>, 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<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
154
packages/extractor/index.ts
Normal file
154
packages/extractor/index.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||
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<string, unknown>).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<string, unknown>;
|
||||
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-";
|
||||
}
|
||||
7
packages/extractor/package.json
Normal file
7
packages/extractor/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "extractor",
|
||||
"version": "0.1.0",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
3
packages/extractor/tsconfig.json
Normal file
3
packages/extractor/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
131
packages/extractor/upload.html
Normal file
131
packages/extractor/upload.html
Normal file
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Extractor — Test Upload</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0d0d0d; --surface: #161616; --border: #2a2a2a;
|
||||
--text: #e0e0e0; --muted: #666; --accent: #2563eb; --green: #16a34a; --red: #dc2626;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 28px; width: 100%; max-width: 520px; display: flex; flex-direction: column; gap: 18px; }
|
||||
h1 { font-size: 15px; font-weight: 600; }
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border); border-radius: 7px; padding: 36px 20px;
|
||||
text-align: center; cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||||
color: var(--muted); font-size: 13px;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.over { border-color: var(--accent); background: rgba(37,99,235,0.05); color: var(--text); }
|
||||
.drop-zone.has-file { border-color: var(--green); color: var(--green); }
|
||||
input[type=file] { display: none; }
|
||||
button {
|
||||
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent);
|
||||
color: #fff; font-size: 13px; cursor: pointer; transition: background 0.12s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #1d4ed8; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.result {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 14px; font-family: "SF Mono", "Fira Mono", monospace; font-size: 11px;
|
||||
white-space: pre-wrap; word-break: break-all; max-height: 320px; overflow-y: auto;
|
||||
color: var(--text); display: none;
|
||||
}
|
||||
.result.error { border-color: var(--red); color: #f87171; }
|
||||
.result.success { border-color: var(--green); }
|
||||
.status { font-size: 12px; color: var(--muted); min-height: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Extractor — Test Upload</h1>
|
||||
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<input type="file" id="file-input" accept=".pdf">
|
||||
<span id="drop-label">Drop a PDF here, or click to select</span>
|
||||
</div>
|
||||
|
||||
<button id="submit-btn" disabled>Extract</button>
|
||||
|
||||
<p class="status" id="status"></p>
|
||||
<pre class="result" id="result"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dropZone = document.getElementById("drop-zone");
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const dropLabel = document.getElementById("drop-label");
|
||||
const submitBtn = document.getElementById("submit-btn");
|
||||
const statusEl = document.getElementById("status");
|
||||
const resultEl = document.getElementById("result");
|
||||
|
||||
let selectedFile = null;
|
||||
|
||||
function csrfHeaders(extra = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener("change", () => setFile(fileInput.files[0]));
|
||||
|
||||
dropZone.addEventListener("dragover", e => { e.preventDefault(); dropZone.classList.add("over"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("over"));
|
||||
dropZone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("over");
|
||||
setFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
function setFile(file) {
|
||||
if (!file || file.type !== "application/pdf") {
|
||||
statusEl.textContent = "Please select a PDF file.";
|
||||
return;
|
||||
}
|
||||
selectedFile = file;
|
||||
dropLabel.textContent = file.name;
|
||||
dropZone.classList.add("has-file");
|
||||
submitBtn.disabled = false;
|
||||
statusEl.textContent = "";
|
||||
resultEl.style.display = "none";
|
||||
}
|
||||
|
||||
submitBtn.addEventListener("click", async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
statusEl.textContent = "Extracting…";
|
||||
resultEl.style.display = "none";
|
||||
|
||||
const form = new FormData();
|
||||
form.append("pdf", selectedFile);
|
||||
|
||||
try {
|
||||
const res = await fetch("/extract", { method: "POST", headers: csrfHeaders(), body: form });
|
||||
const json = await res.json();
|
||||
|
||||
resultEl.textContent = JSON.stringify(json, null, 2);
|
||||
resultEl.className = "result " + (res.ok ? "success" : "error");
|
||||
resultEl.style.display = "block";
|
||||
statusEl.textContent = res.ok
|
||||
? `Written → ${json.files?.json || json.stem}`
|
||||
: `Error ${res.status}`;
|
||||
} catch (err) {
|
||||
resultEl.textContent = String(err);
|
||||
resultEl.className = "result error";
|
||||
resultEl.style.display = "block";
|
||||
statusEl.textContent = "Request failed.";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
294
packages/summarizer/index.html
Normal file
294
packages/summarizer/index.html
Normal file
@@ -0,0 +1,294 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Summarizer</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0d0d0d; --surface: #161616; --border: #2a2a2a;
|
||||
--text: #e0e0e0; --muted: #666; --accent: #2563eb; --green: #16a34a; --red: #dc2626;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 28px; width: 100%; max-width: 520px; display: flex; flex-direction: column; gap: 18px; }
|
||||
h1 { font-size: 15px; font-weight: 600; }
|
||||
.sub { font-size: 12px; color: var(--muted); }
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
|
||||
select, input[type="text"] {
|
||||
width: 100%; padding: 8px 10px; border-radius: 6px;
|
||||
border: 1px solid var(--border); background: var(--bg);
|
||||
color: var(--text); font-size: 13px; outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
select:focus, input[type="text"]:focus { border-color: var(--accent); }
|
||||
.field { display: flex; flex-direction: column; }
|
||||
.notice {
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px;
|
||||
background: rgba(37,99,235,0.08); color: var(--text); font-size: 12px; line-height: 1.45;
|
||||
}
|
||||
.notice strong { display: block; font-size: 12px; margin-bottom: 3px; }
|
||||
.notice span { color: var(--muted); }
|
||||
button {
|
||||
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent);
|
||||
color: #fff; font-size: 13px; cursor: pointer; transition: background 0.12s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #1d4ed8; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.log {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 14px; font-family: "SF Mono", "Fira Mono", monospace; font-size: 11px;
|
||||
white-space: pre-wrap; word-break: break-all; max-height: 280px; overflow-y: auto;
|
||||
color: var(--text); display: none;
|
||||
}
|
||||
.log.visible { display: block; }
|
||||
.log.error { border-color: var(--red); color: #f87171; }
|
||||
.log.success { border-color: var(--green); }
|
||||
.status { font-size: 12px; color: var(--muted); min-height: 16px; }
|
||||
.progress { display: none; flex-direction: column; gap: 8px; }
|
||||
.progress.visible { display: flex; }
|
||||
.progress-bar {
|
||||
width: 100%; height: 10px; border-radius: 999px; overflow: hidden;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%; width: 0%; background: var(--accent); transition: width 0.2s ease;
|
||||
}
|
||||
.progress-meta { font-size: 12px; color: var(--muted); display: flex; justify-content: space-between; gap: 10px; }
|
||||
.cost {
|
||||
display: none;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.cost.visible { display: grid; }
|
||||
.cost div { min-width: 0; }
|
||||
.cost .label { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
|
||||
.cost .value { color: var(--text); font-size: 13px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.downloads { display: none; gap: 8px; flex-wrap: wrap; }
|
||||
.downloads.visible { display: flex; }
|
||||
.downloads a {
|
||||
color: #fff; background: var(--accent); border-radius: 6px;
|
||||
padding: 7px 10px; font-size: 12px; text-decoration: none;
|
||||
}
|
||||
.downloads a:hover { background: #1d4ed8; }
|
||||
.downloads .group {
|
||||
width: 100%; color: var(--muted); font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div>
|
||||
<h1>Summarizer</h1>
|
||||
<p class="sub">Fasst Bewerbungsantworten aus extrahierten JSON-Dateien via LLM zusammen.</p>
|
||||
</div>
|
||||
|
||||
<div class="notice">
|
||||
<strong>Datenschutz</strong>
|
||||
<span>LLM-Eingaben werden vor dem Versand minimiert und pseudonymisiert. OpenRouter-Anfragen fordern ZDR-Routing und data_collection: deny an.</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="company-select">Unternehmen</label>
|
||||
<select id="company-select">
|
||||
<option value="">Lade Unternehmen …</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="run-btn" disabled>Zusammenfassung erstellen</button>
|
||||
|
||||
<div class="progress" id="progress">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
|
||||
<div class="progress-meta">
|
||||
<span id="progress-text">0 / 0</span>
|
||||
<span id="progress-current"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status" id="status"></p>
|
||||
<div class="cost" id="cost">
|
||||
<div><div class="label">OpenRouter-Kosten</div><div class="value" id="cost-value">-</div></div>
|
||||
<div><div class="label">Tokens</div><div class="value" id="tokens-value">-</div></div>
|
||||
<div><div class="label">LLM-Aufrufe</div><div class="value" id="calls-value">-</div></div>
|
||||
</div>
|
||||
<div class="downloads" id="downloads"></div>
|
||||
<pre class="log" id="log"></pre>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
const select = document.getElementById("company-select");
|
||||
const runBtn = document.getElementById("run-btn");
|
||||
const statusEl = document.getElementById("status");
|
||||
const logEl = document.getElementById("log");
|
||||
const progressEl = document.getElementById("progress");
|
||||
const progressFill = document.getElementById("progress-fill");
|
||||
const progressText = document.getElementById("progress-text");
|
||||
const progressCurrent = document.getElementById("progress-current");
|
||||
const downloadsEl = document.getElementById("downloads");
|
||||
const costEl = document.getElementById("cost");
|
||||
const costValue = document.getElementById("cost-value");
|
||||
const tokensValue = document.getElementById("tokens-value");
|
||||
const callsValue = document.getElementById("calls-value");
|
||||
|
||||
function csrfHeaders(extra = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
function renderProgress(job) {
|
||||
const total = job.total || 0;
|
||||
const completed = job.completed || 0;
|
||||
const percent = total ? Math.round((completed / total) * 100) : 0;
|
||||
progressEl.classList.add("visible");
|
||||
progressFill.style.width = `${percent}%`;
|
||||
progressText.textContent = `${completed} / ${total}`;
|
||||
progressCurrent.textContent = job.current ? `Aktuell: ${job.current}` : "";
|
||||
renderUsage(job.usage);
|
||||
}
|
||||
|
||||
function renderUsage(usage) {
|
||||
if (!usage || !usage.calls) {
|
||||
costEl.classList.remove("visible");
|
||||
return;
|
||||
}
|
||||
costValue.textContent = `${formatCost(usage.cost)} credits`;
|
||||
tokensValue.textContent = `${formatInteger(usage.totalTokens)} gesamt`;
|
||||
callsValue.textContent = `${formatInteger(usage.calls)}`;
|
||||
costEl.classList.add("visible");
|
||||
}
|
||||
|
||||
function formatCost(value) {
|
||||
const number = Number(value || 0);
|
||||
if (!number) return "0";
|
||||
return number < 0.0001 ? number.toExponential(2) : number.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function formatInteger(value) {
|
||||
return new Intl.NumberFormat("de-DE").format(Number(value || 0));
|
||||
}
|
||||
|
||||
async function waitForJob(jobId) {
|
||||
for (;;) {
|
||||
const res = await fetch(`/api/summarizer/jobs/${jobId}`);
|
||||
const job = await res.json();
|
||||
if (!res.ok) throw new Error(job.error || "Job konnte nicht geladen werden");
|
||||
renderProgress(job);
|
||||
|
||||
if (job.status === "done") return job;
|
||||
if (job.status === "error") throw new Error(job.errors?.join("\n") || "Job fehlgeschlagen");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCompanies() {
|
||||
try {
|
||||
const res = await fetch("/api/summarizer/companies");
|
||||
const { companies } = await res.json();
|
||||
select.innerHTML = "";
|
||||
if (!companies.length) {
|
||||
select.innerHTML = '<option value="">Keine Unternehmen gefunden</option>';
|
||||
return;
|
||||
}
|
||||
const all = document.createElement("option");
|
||||
all.value = "__all__";
|
||||
all.textContent = "Alle Unternehmen";
|
||||
select.appendChild(all);
|
||||
for (const c of companies) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = c;
|
||||
opt.textContent = c;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
runBtn.disabled = false;
|
||||
} catch (e) {
|
||||
statusEl.textContent = "Fehler beim Laden der Unternehmen: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDownloads(downloads) {
|
||||
downloadsEl.innerHTML = "";
|
||||
if (!downloads?.length) {
|
||||
downloadsEl.className = "downloads";
|
||||
return;
|
||||
}
|
||||
|
||||
let previousCompany = "";
|
||||
for (const file of downloads) {
|
||||
if (file.company && file.company !== previousCompany) {
|
||||
previousCompany = file.company;
|
||||
const group = document.createElement("div");
|
||||
group.className = "group";
|
||||
group.textContent = file.company;
|
||||
downloadsEl.appendChild(group);
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = file.url;
|
||||
link.textContent = file.label;
|
||||
downloadsEl.appendChild(link);
|
||||
}
|
||||
downloadsEl.className = "downloads visible";
|
||||
}
|
||||
|
||||
runBtn.addEventListener("click", async () => {
|
||||
const stem = select.value;
|
||||
if (!stem) return;
|
||||
|
||||
runBtn.disabled = true;
|
||||
logEl.textContent = "";
|
||||
logEl.className = "log visible";
|
||||
downloadsEl.innerHTML = "";
|
||||
downloadsEl.className = "downloads";
|
||||
costEl.className = "cost";
|
||||
progressEl.className = "progress visible";
|
||||
progressFill.style.width = "0%";
|
||||
progressText.textContent = "0 / 0";
|
||||
progressCurrent.textContent = "";
|
||||
statusEl.textContent = "Starte Zusammenfassung …";
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/summarizer/summarize", {
|
||||
method: "POST",
|
||||
headers: csrfHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ stem }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.ok) {
|
||||
logEl.textContent = data.error ?? "Unbekannter Fehler";
|
||||
logEl.className = "log visible error";
|
||||
statusEl.textContent = "Fehler beim Starten.";
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = "Job läuft …";
|
||||
const job = await waitForJob(data.jobId);
|
||||
renderUsage(job.usage);
|
||||
renderDownloads(job.downloads);
|
||||
logEl.textContent = "Ausgabe bereit.";
|
||||
logEl.className = "log visible success";
|
||||
statusEl.textContent = "Fertig.";
|
||||
} catch (e) {
|
||||
logEl.textContent = String(e);
|
||||
logEl.className = "log visible error";
|
||||
statusEl.textContent = "Fehler beim Zusammenfassen.";
|
||||
} finally {
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadCompanies();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
406
packages/summarizer/index.ts
Normal file
406
packages/summarizer/index.ts
Normal file
@@ -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<string, unknown>;
|
||||
const promptDetails = usage.prompt_tokens_details as Record<string, unknown> | undefined;
|
||||
const completionDetails = usage.completion_tokens_details as Record<string, unknown> | undefined;
|
||||
const costDetails = usage.cost_details as Record<string, unknown> | 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<string, SummaryJob>();
|
||||
|
||||
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<T>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
worker: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<string, string> }).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<string, string> }).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;
|
||||
7
packages/summarizer/package.json
Normal file
7
packages/summarizer/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "summarizer",
|
||||
"version": "0.1.0",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
164
packages/summarizer/privacy.test.ts
Normal file
164
packages/summarizer/privacy.test.ts
Normal file
@@ -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.");
|
||||
});
|
||||
360
packages/summarizer/privacy.ts
Normal file
360
packages/summarizer/privacy.ts
Normal file
@@ -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<string, unknown>;
|
||||
fragen?: Record<string, LlmAnswer>;
|
||||
kriterium?: Record<string, LlmAnswer>;
|
||||
}
|
||||
|
||||
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<T extends AntwortEintrag>(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<string, unknown> {
|
||||
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<string, PseudonymReplacement>,
|
||||
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, PseudonymReplacement>,
|
||||
): 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<string, unknown>
|
||||
: 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<string, PseudonymReplacement>();
|
||||
|
||||
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<T extends AntwortEintrag>(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 };
|
||||
}
|
||||
1466
packages/summarizer/report.ts
Normal file
1466
packages/summarizer/report.ts
Normal file
File diff suppressed because it is too large
Load Diff
318
packages/summarizer/scoring-model.ts
Normal file
318
packages/summarizer/scoring-model.ts
Normal file
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
79
packages/summarizer/scoring.test.ts
Normal file
79
packages/summarizer/scoring.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
172
packages/summarizer/scoring.ts
Normal file
172
packages/summarizer/scoring.ts
Normal file
@@ -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<ScoringFarbe, number | null> = {
|
||||
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;
|
||||
}
|
||||
1331
packages/summarizer/summarizer.ts
Normal file
1331
packages/summarizer/summarizer.ts
Normal file
File diff suppressed because it is too large
Load Diff
BIN
packages/summarizer/template.xlsx
Normal file
BIN
packages/summarizer/template.xlsx
Normal file
Binary file not shown.
3
packages/summarizer/tsconfig.json
Normal file
3
packages/summarizer/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
2
packages/templatebuilder/CLAUDE.md
Normal file
2
packages/templatebuilder/CLAUDE.md
Normal file
@@ -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".
|
||||
610
packages/templatebuilder/frontend.ts
Normal file
610
packages/templatebuilder/frontend.ts
Normal file
@@ -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<HTMLInputElement>("#file-input");
|
||||
const uploadBtn = q<HTMLButtonElement>("#upload-btn");
|
||||
const drawBtn = q<HTMLButtonElement>("#draw-btn");
|
||||
const saveBtn = q<HTMLButtonElement>("#save-btn");
|
||||
const prevPageBtn = q<HTMLButtonElement>("#prev-page");
|
||||
const nextPageBtn = q<HTMLButtonElement>("#next-page");
|
||||
const pageInfo = q<HTMLSpanElement>("#page-info");
|
||||
const fieldList = q<HTMLDivElement>("#field-list");
|
||||
const fieldCount = q<HTMLSpanElement>("#field-count");
|
||||
const filenameDisp = q<HTMLSpanElement>("#filename-display");
|
||||
const uploadPrompt = q<HTMLDivElement>("#upload-prompt");
|
||||
const canvases = q<HTMLDivElement>("#canvases");
|
||||
const pdfCanvas = q<HTMLCanvasElement>("#pdf-canvas");
|
||||
const overlayCanvas = q<HTMLCanvasElement>("#overlay-canvas");
|
||||
|
||||
// Key panel
|
||||
const keyPanel = q<HTMLDivElement>("#key-panel");
|
||||
const kpFieldName = q<HTMLSpanElement>("#kp-field-name");
|
||||
const kpTypeBadge = q<HTMLSpanElement>("#kp-type-badge");
|
||||
const kpNameRow = q<HTMLDivElement>("#kp-name-row");
|
||||
const kpFieldId = q<HTMLInputElement>("#kp-field-id");
|
||||
const kpJsonKey = q<HTMLInputElement>("#kp-json-key");
|
||||
const kpLabel = q<HTMLInputElement>("#kp-label");
|
||||
const kpAntwortFormat = q<HTMLSelectElement>("#kp-antwort-format");
|
||||
const kpFragen = q<HTMLTextAreaElement>("#kp-fragen");
|
||||
const kpAssign = q<HTMLButtonElement>("#kp-assign");
|
||||
const kpCancel = q<HTMLButtonElement>("#kp-cancel");
|
||||
const kpRemove = q<HTMLButtonElement>("#kp-remove");
|
||||
|
||||
function q<T extends Element>(sel: string): T {
|
||||
return document.querySelector(sel) as T;
|
||||
}
|
||||
|
||||
function csrfHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||
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<FieldEntry[]> {
|
||||
const result: FieldEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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 = '<p class="field-empty">No fields detected.<br>Use "Draw Field" to add manually.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const byPage = new Map<number, FieldEntry[]>();
|
||||
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 = `
|
||||
<span class="fi-name">${field.fieldName}</span>
|
||||
<span class="fi-key">${field.jsonKey || "unmapped"}</span>
|
||||
`;
|
||||
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<string, unknown>, path: string, value: unknown): void {
|
||||
const parts = path.split(".");
|
||||
let cur: Record<string, unknown> = 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<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const template: Record<string, unknown> = {};
|
||||
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));
|
||||
}
|
||||
94
packages/templatebuilder/index.html
Normal file
94
packages/templatebuilder/index.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Template Builder</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<span class="app-name">Template Builder</span>
|
||||
<span id="filename-display" class="filename"></span>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input type="file" id="file-input" accept=".pdf" hidden>
|
||||
<button id="upload-btn" class="btn">Upload PDF</button>
|
||||
<button id="draw-btn" class="btn" disabled>Draw Field</button>
|
||||
<button id="save-btn" class="btn btn-primary" disabled>Save Template</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Fields</span>
|
||||
<span id="field-count" class="field-count">—</span>
|
||||
</div>
|
||||
<div id="field-list" class="field-list">
|
||||
<p class="field-empty">Upload a PDF to begin.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="viewer">
|
||||
<div class="pagination">
|
||||
<button id="prev-page" class="btn btn-icon" disabled>←</button>
|
||||
<span id="page-info" class="page-info">—</span>
|
||||
<button id="next-page" class="btn btn-icon" disabled>→</button>
|
||||
</div>
|
||||
<div class="canvas-container" id="canvas-container">
|
||||
<div id="upload-prompt" class="upload-prompt">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<p>Upload a PDF to get started</p>
|
||||
</div>
|
||||
<div class="canvases" id="canvases">
|
||||
<canvas id="pdf-canvas"></canvas>
|
||||
<canvas id="overlay-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Key assignment panel -->
|
||||
<div id="key-panel" class="key-panel hidden">
|
||||
<div class="key-panel-inner">
|
||||
<div class="key-panel-meta">
|
||||
<span class="kp-label">Field</span>
|
||||
<span id="kp-field-name" class="kp-field-name"></span>
|
||||
<span id="kp-type-badge" class="kp-badge"></span>
|
||||
</div>
|
||||
<div id="kp-name-row" class="kp-row hidden">
|
||||
<label for="kp-field-id">Field Identifier</label>
|
||||
<input type="text" id="kp-field-id" class="kp-input" placeholder="e.g. company_name" autocomplete="off">
|
||||
</div>
|
||||
<div class="kp-row">
|
||||
<label for="kp-json-key">JSON Key Path</label>
|
||||
<input type="text" id="kp-json-key" class="kp-input" placeholder="e.g. information.company.name" autocomplete="off">
|
||||
</div>
|
||||
<div class="kp-row">
|
||||
<label for="kp-label">Label / Titel</label>
|
||||
<input type="text" id="kp-label" class="kp-input" placeholder="z.B. Robustheit & Resilienz" autocomplete="off">
|
||||
</div>
|
||||
<div class="kp-row">
|
||||
<label for="kp-antwort-format">Antwort-Zuordnung</label>
|
||||
<select id="kp-antwort-format" class="kp-input kp-select">
|
||||
<option value="einzelfrage">1 Feld = 1 Frage</option>
|
||||
<option value="mehrfachfrage_ein_antwortfeld">1 Feld = mehrere Fragen / eine gemeinsame Antwort</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kp-row">
|
||||
<label for="kp-fragen">Fragen (eine pro Zeile)</label>
|
||||
<textarea id="kp-fragen" class="kp-input kp-textarea" placeholder="Frage 1 Frage 2 Frage 3"></textarea>
|
||||
</div>
|
||||
<div class="kp-actions">
|
||||
<button id="kp-assign" class="btn btn-primary">Assign</button>
|
||||
<button id="kp-cancel" class="btn">Cancel</button>
|
||||
<button id="kp-remove" class="btn btn-danger" style="display:none">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./frontend.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
96
packages/templatebuilder/index.ts
Normal file
96
packages/templatebuilder/index.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>)) {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
7
packages/templatebuilder/package.json
Normal file
7
packages/templatebuilder/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "templatebuilder",
|
||||
"version": "0.1.0",
|
||||
"main": "index.ts",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
379
packages/templatebuilder/styles.css
Normal file
379
packages/templatebuilder/styles.css
Normal file
@@ -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; }
|
||||
169
packages/templatebuilder/template.json
Normal file
169
packages/templatebuilder/template.json
Normal file
@@ -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?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/templatebuilder/tsconfig.json
Normal file
3
packages/templatebuilder/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
327
security.ts
Normal file
327
security.ts
Normal file
@@ -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<string, { count: number; resetAt: number }>();
|
||||
|
||||
export interface SecurityOptions {
|
||||
csrf?: boolean;
|
||||
limit?: {
|
||||
key: string;
|
||||
max: number;
|
||||
windowMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type RouteHandler<T extends Request = Request> = (req: T) => Response | Promise<Response>;
|
||||
|
||||
export const securityHeaders: Record<string, string> = {
|
||||
"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<Response | undefined> {
|
||||
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<T extends Request>(handler: (req: T) => unknown | Promise<unknown>, options: SecurityOptions = {}): RouteHandler<T> {
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BMP Demo Login</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: #f4f7fb; color: #182230; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
form { width: min(420px, 100%); display: grid; gap: 16px; padding: 24px; border: 1px solid #d7e0ea; border-radius: 8px; background: white; box-shadow: 0 20px 60px rgba(15, 23, 42, 0.1); }
|
||||
h1 { margin: 0; font-size: 24px; }
|
||||
p { margin: 0; color: #627084; line-height: 1.5; }
|
||||
label { display: grid; gap: 6px; font-size: 13px; color: #405166; font-weight: 700; }
|
||||
input { min-height: 44px; border: 1px solid #b9c7d6; border-radius: 7px; padding: 10px 12px; font: inherit; }
|
||||
button { min-height: 44px; border: 0; border-radius: 7px; background: #2563eb; color: white; font: inherit; font-weight: 760; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form method="post" action="/login">
|
||||
<h1>BMP Demo</h1>
|
||||
<p>Bitte melden Sie sich an, bevor Bewerbungen, Berichte oder LLM-Jobs geöffnet werden.</p>
|
||||
<input type="hidden" name="next" value="${escapeHtml(next)}" />
|
||||
<label>Demo-Passwort <input name="password" type="password" autocomplete="current-password" autofocus required /></label>
|
||||
<button type="submit">Einloggen</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`);
|
||||
}
|
||||
|
||||
export async function login(req: Request): Promise<Response> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
const payload = String(expiresAt);
|
||||
return `${payload}.${await sign(payload)}`;
|
||||
}
|
||||
|
||||
async function verifyAuthCookie(value: string): Promise<boolean> {
|
||||
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<string> {
|
||||
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<string, string> {
|
||||
const cookies = new Map<string, string>();
|
||||
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]!));
|
||||
}
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user