initial commit

This commit is contained in:
syntaxbullet
2026-05-13 17:26:13 +02:00
commit 99569d2cf3
43 changed files with 8725 additions and 0 deletions

View 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>

View 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;

View File

@@ -0,0 +1,7 @@
{
"name": "summarizer",
"version": "0.1.0",
"main": "index.ts",
"type": "module",
"private": true
}

View 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.");
});

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

File diff suppressed because it is too large Load Diff

View 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,
},
],
},
];

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

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}