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