initial commit
This commit is contained in:
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"
|
||||
}
|
||||
Reference in New Issue
Block a user