Plan Checker, Wochenbericht-Fokus-Modus und Wandtafel-Dashboard-Feinschliff

Plan Checker (neues Studenten-Werkzeug, Seitenleiste + Admin → Plan-Checker-
Merkmale): lädt einen Bauplan als PDF hoch und lässt ChatGPT oder Claude ihn
gegen eine gepflegte Merkmalsliste prüfen. PDF-Rendering (pdf.js +
@napi-rs/canvas) berücksichtigt jetzt Ebenen (Optional Content Groups) und
WASM-Decoder korrekt, sendet eine Übersicht plus mehrere hochaufgelöste
Kachel-Ausschnitte statt eines einzelnen, zu groben Bilds, und beide Anbieter
bekommen dasselbe Bildmaterial. Jedes Prüfergebnis kann auf eines der
gesendeten Bilder als Beleg verweisen (bildIndex) — als Screenshot im
Ergebnis anklickbar, öffnet vergrößert in einem Modal statt eines von Chrome
blockierten target=_blank-Links auf eine data:-URL. KI-Modellname ist jetzt
pro Konto überschreibbar (Einstellungen → Mein Konto) für Drittanbieter mit
abweichendem Modell-Katalog.

Wochenbericht: dichte Kartenansicht bekommt einen Fokus-Modus — nur der
Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
aufklappbaren Abschnitten statt dauerhaft nebeneinander zu stehen.

Projektbereich: Terminplan-Auslastung rechnet jetzt exakt wie der
Auslastungs-Tab/die Wandtafel (statt einer unabhängigen, tageweisen
Schätzung); die LPH-Fortschrittsliste im Dashboard zeigt nur noch die
beauftragten Leistungsphasen.

Wandtafel: Dashboard-Kopf mit Kennzahlen-Reihe und Panel-Reihe neu
strukturiert, in den Blautönen des Firmenlogos; dieselbe Palette wurde
anschließend auf den gesamten App-weiten Dark Mode übernommen.

Dateibereinigung: nicht mehr benötigte Test-Uploads/Backups entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-05 14:01:41 +02:00
1 parent db63a29365
commit 6ba93b2a92
308 files changed
+64441 -743

No files matched your search

+696 -24
View File
@@ -40,10 +40,18 @@ const { DatabaseSync } = require('node:sqlite');
// durch das bloße `require('http')` allein auslösbar, unabhängig vom
// tatsächlichen PDF-Inhalt) — in einem Express-Server also immer, da
// express selbst transitiv http lädt. Ein eigenständiges pdfjs-dist ist
// nicht betroffen. Das native @napi-rs/canvas (nur für Seiten-Rendering
// zu Bildern, hier nie gebraucht) ist bei pdfjs-dist nur eine optionale
// Abhängigkeit; die Projekt-.npmrc (`omit=optional`) verhindert, dass es
// überhaupt installiert wird — keine native Kompilierung, kein Canvas.
// nicht betroffen.
//
// @napi-rs/canvas: zunächst bewusst draußen gehalten (siehe Git-Historie
// der .npmrc) — das Seiten-Rendering zu Bildern war hier nie gebraucht,
// solange nur TEXT aus einem PDF gezogen wurde. Für den Plan Checker
// (siehe "Plan Checker" weiter unten) ändert sich das: Maßketten und
// Plankopf sind optische Merkmale einer Zeichnung, kein Fließtext, also
// muss die Planseite als BILD an die KI gehen. Jetzt eine echte, direkte
// Abhängigkeit (nicht mehr nur pdfjs-dists optionale) — die .npmrc mit
// `omit=optional` ist deshalb entfallen, sie hätte sonst auch das für
// @napi-rs/canvas selbst nötige, plattformspezifische Binärpaket (z. B.
// @napi-rs/canvas-darwin-arm64) ausgeschlossen.
const mammoth = require('mammoth');
// undici: NICHT für fetch() selbst (das liefert Node schon global) — nur für
// die Agent-Klasse, um der KI-Erkennung gegen einen eigenen Server (siehe
@@ -82,6 +90,176 @@ async function extrahierePdfText(buf){
loadingTask.destroy();
}
}
// ladeCanvas/renderPdfAlsBildsatz: fürs "Plan Checker"-Feature weiter unten
// (POST /plan-checker-pruefen, aufgerufen aus pruefePlanMitKI()) — rendert
// ausschließlich die ERSTE Seite eines PDFs zu einem Bildsatz (Übersicht +
// hochaufgelöste Kachel-Ausschnitte, s. renderPdfAlsBildsatz() dort), für
// BEIDE Anbieter gleich (siehe Projekthistorie bei pruefePlanMitClaude()
// unten — ein einzelnes, von uns oder vom Anbieter selbst erzeugtes
// Gesamtbild der Seite erwies sich für feine Maßzahlen/Plankopf-Text
// wiederholt als zu grob). Ein Planblatt pro Prüfung ist der Normalfall;
// mehrseitige PDFs mit mehreren Planblättern sind vorerst nicht vorgesehen.
let canvasLibPromise = null;
function ladeCanvas(){
if(!canvasLibPromise) canvasLibPromise = import('@napi-rs/canvas');
return canvasLibPromise;
}
// PDFJS_STANDARD_FONTS: Pfad zu den von pdfjs-dist mitgelieferten Standard-
// Schriftdaten (Foxit-Ersatzschriften für Helvetica/Times/Symbol/...), mit
// abschließendem Trenner wie von pdf.js intern per String-Konkatenation
// erwartet (`${standardFontDataUrl}${dateiname}`). OHNE diesen Parameter
// wirft pdf.js beim Rendern pro fehlender Schriftart die Warnung
// "UnknownErrorException: Ensure that the standardFontDataUrl API parameter
// is provided" und kann Zeichen nicht auf Glyphen abbilden — ein einfacher
// Testtext (reine Helvetica-Buchstaben) rendert trotzdem noch halbwegs über
// einen groben Ersatz, aber bei echten CAD-/Architektur-Export-PDFs
// (Revit/AutoCAD, oft mit eigens eingebetteten oder nicht eingebetteten
// Spezialschriften für Maßzahlen und Plankopf-Text) blieben genau diese
// Textstellen dadurch leer oder unleserlich — die Maßketten-LINIEN kamen an
// (reine Vektorgrafik, schriftunabhängig), die Zahlen und der Plankopf-Text
// nicht. Nur für renderPdfAlsBildsatz() relevant: extrahierePdfText()
// liest ausschließlich getTextContent() (die im PDF gespeicherten
// Zeichencodes), ohne je eine Glyphe zu rendern, und ist von fehlenden
// Schriftdaten nicht betroffen.
const PDFJS_STANDARD_FONTS = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep;
// PDFJS_WASM: Pfad zu den von pdfjs-dist mitgelieferten WASM-Modulen
// (u. a. jbig2.wasm, openjpeg.wasm), im selben Muster wie PDFJS_STANDARD_FONTS
// oben. OHNE diesen Parameter wirft pdf.js beim Laden eines WASM-Decoders
// "UnknownErrorException: Ensure that the wasmUrl API parameter is provided"
// und versucht dann, JBIG2-kodierte Bildinhalte (verbreitetes Kompressions-
// format für eingebettete Scan-/Rasterbilder, z. B. ein eingescanntes Logo im
// Plankopf) über einen Fallback-Pfad zu laden, der mangels `wasmUrl` zu einem
// kaputten Modulnamen zusammengesetzt wird ("nulljbig2_nowasm_fallback.js")
// und mit ERR_MODULE_NOT_FOUND scheitert — der betroffene Bildinhalt fehlt
// dadurch im gerenderten PNG, genau wie zuvor die Ebenen/Schriften.
const PDFJS_WASM = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'wasm') + path.sep;
// PLAN_CHECK_KACHEL_*: Rastergröße und Überlappung für renderPdfAlsBildsatz()
// unten. 2x2 (vier Kacheln) mit 12% Überlappung je Rand - genug
// Sicherheitsrand, falls ein Feld (z. B. der Plankopf) genau auf einer
// Kachelgrenze liegt, ohne die Zahl der pro Prüfung gesendeten Bilder
// (= Kosten/Dauer je Anbieter-Anfrage) unnötig in die Höhe zu treiben.
const PLAN_CHECK_KACHEL_SPALTEN = 2;
const PLAN_CHECK_KACHEL_ZEILEN = 2;
const PLAN_CHECK_KACHEL_UEBERLAPPUNG = 0.12;
const PLAN_CHECK_UEBERSICHT_BREITE = 2000;
const PLAN_CHECK_KACHEL_BREITE = 2200;
// renderPdfAlsBildsatz: löst die vorherige renderPdfErstePageAlsPng() ab, die
// die ganze Planseite als EIN einzelnes PNG rendert hat. Grund für die
// Umstellung: Nach den vorherigen Fixes (Ebenen/Schrift/WASM, s.o.) meldete
// Erik einen neuen, grundsätzlicheren Befund - der Plankopf wurde zwar jetzt
// gefunden und der Plantitel korrekt gelesen, aber Projektname,
// Planersteller, Maßstab, Datum und Plannummer blieben "in der vorliegenden
// Auflösung" nicht sämtlich lesbar. Das bestätigt in letzter Konsequenz den
// Verdacht, der schon zur vorherigen PDF-Direktübergabe an Claude geführt
// hatte (s. historischer Kommentar bei pruefePlanMitClaude() weiter unten):
// JEDER Anbieter skaliert ein übermitteltes Bild bzw. eine PDF-Seite intern
// auf eine eigene Obergrenze herunter, BEVOR das Modell draufschaut (bei
// Claude laut Dokumentation ca. 1568px lange Kante, bei OpenAI vergleichbar).
// Ein Plankopf ist nur ein kleiner Ausschnitt eines großen Planblatts - seine
// Feinschrift liegt nach dieser anbieterseitigen Herunterskalierung weit
// unter Lesbarkeit, UNABHÄNGIG davon, wie hoch WIR die Ausgangsauflösung
// wählen (mehr Pixel in der Quelle ändern nichts, wenn der Anbieter ohnehin
// wieder auf dieselbe Obergrenze herunterrechnet). Claudes PDF-Direktübergabe
// half laut Erik auch nicht durchgreifend genug - vermutlich, weil Anthropics
// eigene Dokumenten-Verarbeitung intern denselben Weg geht (PDF-Seite zu
// einem einzigen Bild, dann dieselbe Auflösungsgrenze).
//
// Der einzige verlässliche Ausweg: der KI ZUSÄTZLICH zur Übersicht mehrere,
// überlappende Ausschnitte der Seite in höherer Pixel-Dichte mitgeben, sodass
// Feinschrift gleich welcher Lage auf dem Blatt in mindestens einem Bild
// unterhalb der anbieterseitigen Herunterskalierungs-Schwelle bleibt - OHNE
// dafür die Lage des Plankopfs erkennen zu müssen (die variiert je
// Büro-Vorlage: beim getesteten Plan oben rechts, DIN-Konvention wäre unten
// rechts). Jede Kachel wird NICHT aus einem fertigen Raster-Bild
// zugeschnitten (das brächte keine zusätzliche Schärfe, nur eine vergrößerte
// Unschärfe), sondern pdf.js erneut mit einer höheren scale-Vorgabe gerendert
// und per Context-Transform (ctx.translate) auf den gewünschten Ausschnitt
// verschoben - der Ausschnitt entsteht also selbst aus dem Vektor-Original in
// höherer Auflösung, nicht durch Hochskalieren eines bereits gerenderten
// Bilds. Gilt jetzt für BEIDE Anbieter (Eriks ausdrückliche Entscheidung):
// Claude bekommt dieselben gerenderten Bilder wie ChatGPT statt wieder der
// rohen PDF, damit für beide dasselbe, selbst kontrollierte Verfahren gilt
// statt für Claude erneut auf unbekanntes anbieterinternes Verhalten zu
// vertrauen.
//
// Rückgabe: { uebersicht: Buffer, kacheln: Buffer[] } - je ein PNG-Buffer.
async function renderPdfAlsBildsatz(buf){
const pdfjsLib = await ladePdfjs();
const { createCanvas } = await ladeCanvas();
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buf), standardFontDataUrl: PDFJS_STANDARD_FONTS, wasmUrl: PDFJS_WASM });
try {
const doc = await loadingTask.promise;
const page = await doc.getPage(1);
const basisViewport = page.getViewport({ scale: 1 });
// Alle optionalen Ebenen (PDF Optional Content Groups) erzwungen
// sichtbar schalten, BEVOR gerendert wird: CAD-/BIM-Exporte (AutoCAD,
// Revit, ...) legen technische Pläne sehr häufig in Ebenen an (z. B.
// "Architektur-Grundriss", "ELT-Planung", "Legende", "Plankopf") und
// markieren einen Teil davon im PDF selbst als standardmäßig AUS - oft
// bewusst so exportiert, damit jedes Gewerk seine eigene Ebene in
// Acrobat selbst einschaltet. Ohne diese Ergänzung rendert pdf.js exakt
// diesen Vorgabe-Zustand nach: bei einem echten Plan blieben dadurch nur
// der Architektur-Grundriss übrig, ELT-Planung, Legende UND Plankopf
// fehlten im gerenderten Bild komplett (von Erik direkt bestätigt, nach
// Ansehen des gespeicherten Debug-PNGs) - die KI konnte diese Merkmale
// also gar nicht als "nicht vorhanden" FALSCH einstufen, sie bekam sie
// schlicht nie zu sehen.
const ebenen = await doc.getOptionalContentConfig();
for(const [ebenenId] of ebenen){
ebenen.setVisibility(ebenenId, true);
}
// ausschnitt (optional): {x, y, breite, hoehe} in Geräte-Pixeln DIESES
// scale-Werts. Ohne ausschnitt wird die ganze Seite gerendert.
async function renderAusschnitt(scale, ausschnitt){
const viewport = page.getViewport({ scale });
const breite = ausschnitt ? Math.round(ausschnitt.breite) : Math.round(viewport.width);
const hoehe = ausschnitt ? Math.round(ausschnitt.hoehe) : Math.round(viewport.height);
const canvas = createCanvas(breite, hoehe);
const ctx = canvas.getContext('2d');
if(ausschnitt) ctx.translate(-ausschnitt.x, -ausschnitt.y);
await page.render({ canvasContext: ctx, viewport, optionalContentConfigPromise: Promise.resolve(ebenen) }).promise;
return canvas.toBuffer('image/png');
}
// Übersicht: die ganze Seite, moderate Auflösung - liefert der KI den
// Gesamtkontext (Art/Lage der Zeichnung), aber keine verlässliche
// Feinschrift.
const uebersicht = await renderAusschnitt(PLAN_CHECK_UEBERSICHT_BREITE / basisViewport.width, null);
// Kacheln: Raster mit Überlappung, JEDE Kachel mit eigener, hoher
// Skalierung gerendert (nicht aus der Übersicht zugeschnitten, s. o.).
const anteilBreite = (1 / PLAN_CHECK_KACHEL_SPALTEN) + PLAN_CHECK_KACHEL_UEBERLAPPUNG;
const kachelScale = PLAN_CHECK_KACHEL_BREITE / (basisViewport.width * anteilBreite);
const kachelViewport = page.getViewport({ scale: kachelScale });
const kacheln = [];
for(let zeile = 0; zeile < PLAN_CHECK_KACHEL_ZEILEN; zeile++){
for(let spalte = 0; spalte < PLAN_CHECK_KACHEL_SPALTEN; spalte++){
const startAnteilX = Math.max(0, spalte / PLAN_CHECK_KACHEL_SPALTEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const startAnteilY = Math.max(0, zeile / PLAN_CHECK_KACHEL_ZEILEN - PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const endAnteilX = Math.min(1, (spalte + 1) / PLAN_CHECK_KACHEL_SPALTEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
const endAnteilY = Math.min(1, (zeile + 1) / PLAN_CHECK_KACHEL_ZEILEN + PLAN_CHECK_KACHEL_UEBERLAPPUNG / 2);
kacheln.push(await renderAusschnitt(kachelScale, {
x: startAnteilX * kachelViewport.width,
y: startAnteilY * kachelViewport.height,
breite: (endAnteilX - startAnteilX) * kachelViewport.width,
hoehe: (endAnteilY - startAnteilY) * kachelViewport.height
}));
}
}
return { uebersicht, kacheln };
} finally {
loadingTask.destroy();
}
}
const app = express();
// Wichtige Konstanten: Serverport (PORT-Env) und die gültigen Abteilungsordner
@@ -165,7 +343,7 @@ app.use(require('cors')());
// express.raw() ihn bekommt ("Keine Datei empfangen"). 8 MB statt 2 MB, weil
// eine Sitzung mit vielen Projekten und langen Kommentaren sonst irgendwann
// still am 413 scheitert.
const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import']);
const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen']);
const jsonBodyParser = express.json({ limit: '8mb' });
app.use((req, res, next) => {
if(req.method === 'POST' && RAW_BODY_ROUTES.has(req.path)) return next();
@@ -592,6 +770,22 @@ db.exec(`CREATE TABLE IF NOT EXISTS lph_templates (
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_lph_templates_lph ON lph_templates(lph)');
// plan_checker_merkmale: firmenweite Grundliste der Merkmale, gegen die der
// Plan Checker (Seitenleisten-Bereich "Studenten") einen hochgeladenen Plan
// prüft (z. B. "Maßketten vorhanden"). "prompt" ist die Prüfanweisung, die
// an die KI geht — "titel" ist zugleich die Anzeige-Bezeichnung UND der
// Schlüssel, unter dem die KI ihr Ergebnis zurückgibt. Anders als
// lph_templates gibt es hier kein Gruppierungsfeld: die Liste gilt
// unterschiedslos für jede Prüfung (plus ein optionales, NICHT
// gespeichertes Zusatz-Merkmal nur für die jeweils eine Prüfung, siehe
// POST /plan-checker-pruefen).
db.exec(`CREATE TABLE IF NOT EXISTS plan_checker_merkmale (
id TEXT PRIMARY KEY,
titel TEXT NOT NULL,
prompt TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
)`);
// lph_honorar_anteile: firmenweite Honorar-Anteile je Leistungsphase in
// Prozent (HOAI-Sätze). Bewusst NICHT als Konstante im Code, sondern
// pflegbar — die Sätze hängen vom Leistungsbild ab (Technische Ausrüstung
@@ -2055,6 +2249,26 @@ function migrateAddProjectUpdatedAt(){
}
}
// migrateAddUserAiKeyModel: rüstet die Spalte `model` nach (idempotent,
// gleiches Muster wie migrateAddProjectGroesse()). Leer = die in
// erkennePunkteMitChatGPT()/erkennePunkteMitClaude()/pruefePlanMitChatGPT()/
// pruefePlanMitClaude() hartcodierte Vorgabe gilt weiterhin — nötig, seit
// Erik mit Mammouth.ai einen eigenständigen Anbieter mit eigenem
// Modell-Katalog anbinden wollte: "gpt-4o-mini"/"gpt-4o" sind feste
// OpenAI-Modellnamen, die bei einem fremden Anbieter schlicht nicht
// existieren (HTTP 400 "Invalid model name ... Call /v1/models to view
// available models for your key"). Bisher ließ sich bei "Eigener Server"
// nur die URL überschreiben, nie der Modellname — dieselbe Lücke, die schon
// bei der API-URL bestand, bevor sie ergänzt wurde (siehe
// istGueltigeAiUrl()/normalisiereAiUrl() oben).
function migrateAddUserAiKeyModel(){
const cols = db.prepare("SELECT name FROM pragma_table_info('user_ai_keys')").all().map(r => r.name);
if(!cols.includes('model')){
db.exec("ALTER TABLE user_ai_keys ADD COLUMN model TEXT NOT NULL DEFAULT ''");
console.log('user_ai_keys-Tabelle um model ergänzt.');
}
}
// migrateAddProtokollQuelle: rüstet die beiden Spalten nach, die ein
// importiertes Protokoll von einem manuell angelegten unterscheiden
// (idempotent, gleiches Muster wie migrateAddProjectGroesse()). Beide
@@ -2451,6 +2665,7 @@ migrateAddProjectStatusAuthor();
migrateAddProjectAuftragsstatus();
migrateAddProjectGroesse();
migrateAddProjectUpdatedAt();
migrateAddUserAiKeyModel();
migrateAddProtokollQuelle();
migrateAddProtokollPunktErstelltAm();
migrateAddProtokollPunktStatus();
@@ -4121,17 +4336,22 @@ const KI_SYSTEM_PROMPT = 'Extrahiere ALLE Besprechungspunkte aus diesem Protokol
// (js/settings/settings.js), genau wie E-Mail und Passwort. Klartext in der
// SQLite-Datei, wie jeder andere Wert dort auch.
function readUserAiKey(userId, provider){
const row = db.prepare('SELECT api_key, api_url FROM user_ai_keys WHERE user_id = ? AND provider = ?').get(userId, provider);
return { apiKey: row ? row.api_key : '', apiUrl: row ? row.api_url : '' };
const row = db.prepare('SELECT api_key, api_url, model FROM user_ai_keys WHERE user_id = ? AND provider = ?').get(userId, provider);
return { apiKey: row ? row.api_key : '', apiUrl: row ? row.api_url : '', model: row ? row.model : '' };
}
function writeUserAiKey(userId, provider, patch){
const bestehend = readUserAiKey(userId, provider);
const apiKey = Object.prototype.hasOwnProperty.call(patch, 'apiKey') ? String(patch.apiKey || '') : bestehend.apiKey;
const apiUrl = Object.prototype.hasOwnProperty.call(patch, 'apiUrl') ? String(patch.apiUrl || '') : bestehend.apiUrl;
db.prepare(`INSERT INTO user_ai_keys (user_id, provider, api_key, api_url) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, api_url = excluded.api_url`)
.run(userId, provider, apiKey, apiUrl);
// model: leer = die im jeweiligen Aufrufer hartcodierte Vorgabe gilt
// (gpt-4o-mini/gpt-4o bzw. claude-haiku/claude-sonnet-5) - nur bei einem
// eigenständigen Anbieter mit abweichendem Modell-Katalog (z. B.
// Mammouth.ai) muss das hier überschrieben werden.
const model = Object.prototype.hasOwnProperty.call(patch, 'model') ? String(patch.model || '') : bestehend.model;
db.prepare(`INSERT INTO user_ai_keys (user_id, provider, api_key, api_url, model) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, api_url = excluded.api_url, model = excluded.model`)
.run(userId, provider, apiKey, apiUrl, model);
}
// istLoopbackHost: gemeinsame Grundlage für istGueltigeAiUrl() (URL-Prüfung
@@ -4258,8 +4478,24 @@ function normalisiereAiUrl(apiUrl, defaultUrl){
if(!apiUrl) return defaultUrl;
try {
const parsed = new URL(apiUrl);
if(!parsed.pathname || parsed.pathname === '/'){
parsed.pathname = new URL(defaultUrl).pathname;
const defaultPath = new URL(defaultUrl).pathname; // z. B. '/v1/chat/completions'
const leer = !parsed.pathname || parsed.pathname === '/';
// Viele OpenAI-kompatible Anbieter (Mammouth.ai, OpenRouter und weitere)
// dokumentieren ihre Basis-URL nach der offiziellen OpenAI-SDK-Konvention
// als ".../v1" - ohne den eigentlichen Completions-Pfad dahinter. Das
// Eingabefeld (Einstellungen → Mein Konto) schlägt mit seinem Platzhalter
// ("z. B. http://localhost:1234 für LM Studio") genau diese Art bloßer
// Basis-URL vor, und wer sie nach der für den jeweiligen Anbieter
// dokumentierten Form mit angehängtem "/v1" einträgt, bekam bisher einen
// 404: Die Anfrage landete exakt auf ".../v1" statt auf
// ".../v1/chat/completions" (reproduzierbar bei Mammouth.ai, dessen
// FastAPI-typische {"detail":"Not Found"}-Antwort das auch so ausweist).
// Nur ein Pfad, der GENAU auf "/v1" endet, wird so behandelt wie ein
// leerer - ein abweichender, bewusst eigener Pfad (z. B. ein
// Firmen-Proxy mit eigener Route) bleibt wie bisher unangetastet.
const nurBasisPfad = /\/v1\/?$/.test(parsed.pathname) && defaultPath.startsWith('/v1/');
if(leer || nurBasisPfad){
parsed.pathname = defaultPath;
return parsed.toString();
}
return apiUrl;
@@ -4268,13 +4504,17 @@ function normalisiereAiUrl(apiUrl, defaultUrl){
}
}
async function erkennePunkteMitChatGPT(text, apiKey, apiUrl){
async function erkennePunkteMitChatGPT(text, apiKey, apiUrl, model){
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
let istLokal = false;
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
const body = {
model: 'gpt-4o-mini',
// model (Konto-Einstellung, user_ai_keys.model) überschreibt die Vorgabe
// nur, wenn gesetzt — nötig für einen eigenständigen Anbieter mit
// abweichendem Modell-Katalog (z. B. Mammouth.ai), siehe
// migrateAddUserAiKeyModel() oben.
model: model || 'gpt-4o-mini',
messages: [
{ role: 'system', content: KI_SYSTEM_PROMPT },
{ role: 'user', content: text }
@@ -4336,12 +4576,12 @@ async function erkennePunkteMitChatGPT(text, apiKey, apiUrl){
};
}
async function erkennePunkteMitClaude(text, apiKey, apiUrl){
async function erkennePunkteMitClaude(text, apiKey, apiUrl, model){
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001',
model: model || 'claude-haiku-4-5-20251001',
// wie beim ChatGPT-Pfad: ein langes Protokoll mit vielen Punkten
// braucht mehr als das ursprüngliche Minimum, sonst bricht die
// Antwort vor dem letzten Punkt ab.
@@ -4375,14 +4615,350 @@ async function erkennePunkteMitClaude(text, apiKey, apiUrl){
}
async function erkennePunkteMitKI(text, userId, provider){
const { apiKey, apiUrl } = readUserAiKey(userId, provider);
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
const gekuerzterText = text.length > KI_MAX_TEXT_LAENGE ? text.slice(0, KI_MAX_TEXT_LAENGE) : text;
if(provider === 'chatgpt') return erkennePunkteMitChatGPT(gekuerzterText, apiKey, apiUrl);
if(provider === 'claude') return erkennePunkteMitClaude(gekuerzterText, apiKey, apiUrl);
if(provider === 'chatgpt') return erkennePunkteMitChatGPT(gekuerzterText, apiKey, apiUrl, model);
if(provider === 'claude') return erkennePunkteMitClaude(gekuerzterText, apiKey, apiUrl, model);
throw new Error('Unbekannter Anbieter: ' + provider);
}
// ---------- Plan Checker (plan_checker_merkmale) ----------
//
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
// einen Bauplan als PDF hoch und lässt eine KI ihn gegen eine Liste von
// Merkmalen prüfen (z. B. "Maßketten vorhanden", "Plankopf vollständig").
// Anders als der Protokoll-Import oben geht es hier nicht um Text, sondern
// um optische Merkmale einer Zeichnung.
//
// Beide Anbieter werden (wieder) auf demselben Weg zugeführt, nach einem
// Zwischenschritt, der sich als unzureichend erwies: Die allererste Fassung
// rendere die PDF-Seite IMMER zu einem einzigen PNG und schickte das an
// beide Anbieter — Erik meldete daraufhin "extrem unzuverlässige" Erkennung
// von Maßketten, auch ein deutlich stärkeres Modell besserte das nicht, was
// auf ein Auflösungsproblem statt eine Modellgrenze hindeutete. Als
// Zwischenschritt bekam Claude testweise das PDF DIREKT (document-
// Content-Block, in der Annahme, Anthropics eigene Dokumenten-Verarbeitung
// löse Auflösung/Aufteilung besser) — half laut Erik aber nicht
// durchgreifend genug: ein erneuter Test zeigte den Plankopf zwar jetzt
// gefunden, aber Projektname, Planersteller, Maßstab, Datum und Plannummer
// blieben "in der vorliegenden Auflösung" nicht sämtlich lesbar. Das
// bestätigte den eigentlichen Engpass: JEDER Anbieter skaliert ein
// übermitteltes Bild bzw. eine PDF-Seite intern auf eine eigene Obergrenze
// herunter, egal auf welchem Weg es ankommt — ein einzelnes Bild der ganzen
// Seite kann Feinschrift in einem kleinen Blattausschnitt (wie dem Plankopf)
// deshalb grundsätzlich nicht zuverlässig lesbar machen.
//
// Jetzt rendern WIR beide Zuführungen selbst (renderPdfAlsBildsatz() weiter
// oben, aufgerufen aus pruefePlanMitKI() unten): eine Übersicht der ganzen
// Seite PLUS mehrere überlappende, einzeln hochaufgelöste Ausschnitte -
// sowohl an ChatGPT (als mehrere image_url-Blöcke) als auch an Claude (als
// mehrere image-Blöcke) auf dieselbe Weise geschickt. ChatGPT (über die hier
// genutzte Chat-Completions-API) sowie jeder lokale, über dieselbe
// Schnittstelle angesprochene Server (LM Studio, Ollama) oder Drittanbieter
// (z. B. Mammouth.ai) kennen in dieser Schnittstelle ohnehin grundsätzlich
// kein PDF-Format - nur Bilder; OpenAIs neuere "Responses API" könnte PDFs
// zwar direkt lesen, wird aber von lokalen Servern und den meisten
// Drittanbietern nicht nachgebildet, eine Umstellung darauf würde also genau
// die lokale/Drittanbieter-Nutzung brechen, die ausdrücklich mit abgedeckt
// sein soll, und bleibt deshalb aus.
//
// Nutzt dieselben Konten-Schlüssel (user_ai_keys, readUserAiKey() oben) wie
// der Protokoll-Import — keine eigene Schlüsselverwaltung nötig.
//
// Nichts wird dauerhaft gespeichert: weder das hochgeladene PDF noch die
// daraus gerenderten Bilder landen auf der Festplatte (anders als
// /project-files oder /project-protokolle/import, die beide immer
// persistieren, siehe dort) — das Ergebnis geht direkt in der Antwort
// zurück. Es gibt bewusst (noch) keinen Prüf-Verlauf: Das ist ein
// Ad-hoc-Werkzeug, kein Dokumentenarchiv.
// Bildunabhängig formuliert ("Planzeichnung" statt "Bild"), da beide
// Anbieter mehrere Bilder (Übersicht + Kacheln) derselben Zeichnung
// bekommen, nicht nur eines.
const PLAN_CHECK_SYSTEM_PROMPT = 'Du prüfst eine technische Planzeichnung (Architektur/TGA) auf das Vorhandensein '
+ 'bestimmter Merkmale. Du bekommst eine nummerierte Liste von Merkmalen mit Prüfanweisung. Gib für JEDES Merkmal aus der '
+ 'Liste genau einen Eintrag zurück, mit dem Titel exakt wie angegeben. "erfuellt" ist nur dann true, wenn das Merkmal auf '
+ 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist KURZ - ein bis zwei Sätze, NIE eine '
+ 'Aufzählung einzelner Werte (z. B. nicht jede einzelne Maßzahl auflisten) - beschreibe stattdessen zusammenfassend, was '
+ 'zu sehen ist. Erfinde nichts.';
// Wie bei KI_SYSTEM_PROMPT oben gilt: Formulierungsänderungen können das
// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der Zusatz
// "NIE eine Aufzählung einzelner Werte" kam nach einem echten Fehlschlag
// dazu: Ein lokal über LM Studio angebundenes Modell zählte für "Maßketten
// vorhanden" jede einzelne gefundene Maßzahl einzeln auf ("2,50 m, 3,12 m,
// 1,20 m, 2,10 m, ..."), wodurch die Antwort noch vor Abschluss des JSON an
// max_tokens (s. u.) abgeschnitten wurde und als Ganzes nicht mehr parsbar
// war ("ChatGPT-Antwort war kein gültiges JSON"). Das ursprüngliche "ein bis
// zwei Sätze" allein reichte diesem Modell als Grenze nicht aus.
// PLAN_CHECK_BILD_ANZAHL: Gesamtzahl der an die KI gesendeten Bilder (1
// Übersicht + die Kacheln, s. renderPdfAlsBildsatz() oben) - bestimmt den
// gültigen Wertebereich für "bildIndex" im Schema direkt unten. Von Hand
// synchron zu PLAN_CHECK_KACHEL_SPALTEN/-ZEILEN gehalten statt daraus
// berechnet, damit die enum-Liste unten als lesbare Literalliste im Schema
// steht (für strict-JSON-Schema bei OpenAI ohnehin nötig).
const PLAN_CHECK_BILD_ANZAHL = 1 + PLAN_CHECK_KACHEL_SPALTEN * PLAN_CHECK_KACHEL_ZEILEN;
const PLAN_CHECK_SCHEMA = {
type: 'object',
properties: {
ergebnisse: {
type: 'array',
items: {
type: 'object',
properties: {
merkmal: { type: 'string' },
erfuellt: { type: 'boolean' },
begruendung: { type: 'string' },
// bildIndex: Eriks Wunsch nach einem Screenshot je Merkmal, NACH
// einem ersten Versuch mit pixelgenauen Bild-Ausschnitten
// (Beschriftungsraster + Zuschnitt einer benannten Zelle), der sich
// in der Praxis als nicht gut genug erwies ("das mit den
// Bildausschnitten hat nicht gut funktioniert") - deutlich
// einfacher und robuster: die KI wählt nur noch AUS den bereits
// gesendeten Bildern dasjenige, das den Nachweis für das Merkmal
// am besten zeigt (0 = Übersicht, 1..N = die Kacheln in der
// gesendeten Reihenfolge). Reine Auswahl aus einer festen, kleinen
// Liste statt einer selbst zu bestimmenden Koordinate oder
// Zellenbezeichnung - und der Server muss dafür NICHTS mehr
// nachträglich rendern, die Bilder liegen schon als Buffer vor
// (s. pruefePlanMitKI() weiter unten).
bildIndex: { type: 'integer', enum: Array.from({ length: PLAN_CHECK_BILD_ANZAHL }, (_, i) => i) }
},
required: ['merkmal', 'erfuellt', 'begruendung', 'bildIndex'],
additionalProperties: false
}
}
},
required: ['ergebnisse'],
additionalProperties: false
};
// baueMerkmalePrompt: formt die Merkmale-Liste (Grundliste + optionales
// Zusatz-Merkmal nur für diese eine Prüfung, siehe POST /plan-checker-pruefen)
// in den Anweisungstext für die KI. Parameter: merkmale (Array {titel, prompt}).
// Rückgabe: String. Erklärt zusätzlich die Bildreihenfolge (Übersicht zuerst,
// dann die hochaufgelösten Kachel-Ausschnitte, s. renderPdfAlsBildsatz()
// oben) - ohne diesen Hinweis hat das Modell keinen Grund, bei mehreren
// Bildern über dieselbe Zeichnung gezielt nach Feinschrift in den hinteren
// Bildern zu suchen, statt sich nur auf das erste zu verlassen. Erklärt
// zusätzlich bildIndex (s. PLAN_CHECK_SCHEMA oben).
function baueMerkmalePrompt(merkmale){
const zeilen = merkmale.map((m, i) => (i + 1) + '. ' + m.titel + ': ' + m.prompt);
return 'Prüfe die Planzeichnung auf folgende Merkmale:\n\n' + zeilen.join('\n')
+ '\n\nDu bekommst mehrere Bilder derselben Zeichnung, nummeriert von 0: Bild 0 ist eine Übersicht der ganzen '
+ 'Seite, die Bilder 1 bis ' + (PLAN_CHECK_BILD_ANZAHL - 1) + ' sind sich überlappende Ausschnitte in höherer '
+ 'Auflösung. Nutze die Ausschnitte gezielt, um kleine Details wie Maßzahlen oder Plankopf-Angaben zu lesen, die '
+ 'auf der Übersicht zu klein sind.'
+ '\n\nGib zu jedem Merkmal zusätzlich unter "bildIndex" die Nummer DESJENIGEN Bildes an, auf dem der '
+ 'Hauptnachweis für dieses Merkmal am besten zu erkennen ist. Lässt sich kein einzelnes Bild sinnvoll '
+ 'bevorzugen, gib 0 (die Übersicht) an.';
}
// pruefePlanMitChatGPT: bilderBase64 ist ein Array (Übersicht zuerst, dann
// die Kachel-Ausschnitte, s. renderPdfAlsBildsatz() weiter oben) - jedes
// Bild wird als eigener image_url-Block gesendet, in derselben Reihenfolge,
// die baueMerkmalePrompt() dem Modell textlich erklärt.
async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, model){
const zielUrl = normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.chatgpt);
let istLokal = false;
try { istLokal = istLoopbackHost(new URL(zielUrl).hostname); } catch(e){ /* ungültige URL: bleibt false */ }
const bildBloecke = bilderBase64.map(b64 => ({
// detail:'high' statt des Vorgabewerts 'auto' — erzwingt die
// feinteiligere, gekachelte Bildverarbeitung von OpenAI für JEDES der
// gesendeten Bilder. Ohne das kann das Modell bei einer dichten
// technischen Zeichnung von sich aus die gröbere Verarbeitung wählen,
// wodurch kleine Maßzahlen und Plankopf-Text unlesbar werden — genau das
// Merkmal, auf das hier geprüft wird.
type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64, detail: 'high' }
}));
const body = {
// gpt-4o statt gpt-4o-mini als VORGABE (anders als beim Protokoll-Import
// oben, der reinen Fließtext liest): Erik meldete, dass die Erkennung
// "extrem unzuverlässig" war - bei temperature:0 (s.u.) deutet
// schwankendes Ergebnis für dieselbe Zeichnung auf eine Fähigkeitsgrenze
// des Modells hin, nicht auf Prompt/Rendering (siehe auch die
// Protokoll-Import-Erfahrung weiter oben: "ein stärkeres Modell hätte
// mehr Spielraum - das bleibt eine Modell-Fähigkeits-Frage, keine
// Prompt-Frage"). Feine Maßzahlen auf einer dichten technischen
// Zeichnung zuverlässig zu lesen ist eine deutlich anspruchsvollere
// Bildaufgabe als Fließtext aus einem Besprechungsprotokoll zu ziehen.
// Konto-Einstellung (user_ai_keys.model) überschreibt auch diese Vorgabe,
// wenn gesetzt - nötig für einen eigenständigen Anbieter mit
// abweichendem Modell-Katalog (z. B. Mammouth.ai).
model: model || 'gpt-4o',
messages: [
{ role: 'system', content: PLAN_CHECK_SYSTEM_PROMPT },
{ role: 'user', content: [
{ type: 'text', text: baueMerkmalePrompt(merkmale) },
...bildBloecke
] }
],
response_format: { type: 'json_schema', json_schema: { name: 'plan_pruefung', strict: true, schema: PLAN_CHECK_SCHEMA } },
// 4096 war zu knapp bemessen: ein lokal angebundenes Modell hielt sich
// trotz "ein bis zwei Sätze" nicht an die Kürze (s. PLAN_CHECK_SYSTEM_PROMPT
// oben) und die Antwort wurde mitten im JSON abgeschnitten. 8192 als
// Sicherheitsnetz zusätzlich zur geschärften Prompt-Anweisung - beide
// Maßnahmen zusammen, da sich (wie schon bei KI_SYSTEM_PROMPT dokumentiert)
// nicht verlässlich vorhersagen lässt, ob ein Modell eine Kürze-Anweisung
// tatsächlich befolgt.
max_tokens: 8192,
temperature: 0
};
// Siehe erkennePunkteMitChatGPT() oben: manche lokale Server laden
// standardmäßig ein Reasoning-Modell, das vor jeder Antwort "denkt" und
// damit jeden Timeout sprengen kann — nur für lokale Ziele abgeschaltet.
if(istLokal) body.reasoning_effort = 'none';
const res = await fetchMitTimeout(zielUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify(body)
});
if(!res.ok){
const errBody = await res.text().catch(() => '');
throw new Error('ChatGPT antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
}
const data = await res.json();
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
if(!content) throw new Error('ChatGPT lieferte keine Antwort.');
// Manche lokalen Server (LM Studio, Ollama) oder Modelle halten sich nicht
// strikt an response_format:json_schema und umschließen die Antwort
// trotzdem mit einem Markdown-Codeblock ("```json\n...\n```"); das reine
// JSON innerhalb des Blocks wird dann aussortiert, bevor geparst wird.
const bereinigt = content.trim().replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
let parsed;
try { parsed = JSON.parse(bereinigt); } catch(e){
// Rohtext der Antwort im Fehler, wie bei den HTTP-Fehlern oben
// (errBody.slice(0, 200)) - ohne das war ein lokal/drittanbieter-
// bedingtes Scheitern (z. B. ein Modell, das nur EIN Bild pro Anfrage
// verarbeiten kann und bei mehreren Bildern mit Fließtext statt JSON
// antwortet) nur über die Server-Konsole nachvollziehbar.
throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + content.slice(0, 300));
}
if(!Array.isArray(parsed.ergebnisse)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
return parsed.ergebnisse;
}
// pruefePlanMitClaude: bekam zwischenzeitlich das PDF DIREKT
// (document-Content-Block) statt eines von uns gerenderten Bildes - Grund
// damals: Erik meldete, dass die Erkennung von Maßketten "extrem
// unzuverlässig" war, auch nach dem Wechsel von Haiku auf das deutlich
// stärkere Sonnet 5 KEINE Verbesserung, was gegen eine Modell-
// Fähigkeitsgrenze und FÜR ein Auflösungsproblem sprach (Claude skaliert laut
// Anthropic-Dokumentation auf ca. 1568px lange Kante bzw. ~1,15 Megapixel
// herunter). Die PDF-Direktübergabe half laut Erik aber NICHT durchgreifend
// genug: ein erneuter Test mit korrekt erkanntem Plankopf zeigte trotzdem
// "in der vorliegenden Auflösung" nicht sämtlich lesbare Feinschrift
// (Planersteller, Maßstab, Datum, Plannummer) - vermutlich, weil Anthropics
// eigene Dokumenten-Verarbeitung eine PDF-Seite intern ebenfalls erst zu
// einem einzigen Bild wandelt und DANN derselben Auflösungsgrenze
// unterliegt. bilderBase64 ist jetzt wie bei pruefePlanMitChatGPT() ein Array
// (Übersicht + hochaufgelöste Kachel-Ausschnitte, renderPdfAlsBildsatz()
// weiter oben) - dasselbe, selbst kontrollierte Verfahren für beide
// Anbieter, statt erneut auf unbekanntes anbieterinternes Verhalten zu
// vertrauen.
async function pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model){
const bildBloecke = bilderBase64.map(b64 => ({
type: 'image', source: { type: 'base64', media_type: 'image/png', data: b64 }
}));
const res = await fetchMitTimeout(normalisiereAiUrl(apiUrl, KI_DEFAULT_URL.claude), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({
// claude-sonnet-5 statt claude-haiku-4-5-20251001 als VORGABE (anders
// als beim Protokoll-Import oben), auch wenn das allein die
// Unzuverlässigkeit NICHT behoben hat (s.o.) - ein stärkeres Modell
// bleibt trotzdem die richtige Vorgabe für diese anspruchsvolle
// Bildaufgabe. Konto-Einstellung überschreibt auch diese Vorgabe.
model: model || 'claude-sonnet-5',
// 8192 statt 4096 - gleiches Sicherheitsnetz wie bei pruefePlanMitChatGPT()
// oben, selbes Risiko gilt grundsätzlich auch hier (eine zu ausführliche
// Begründung, die vor Abschluss des JSON abgeschnitten wird).
max_tokens: 8192,
temperature: 0,
system: PLAN_CHECK_SYSTEM_PROMPT,
messages: [{ role: 'user', content: [
...bildBloecke,
{ type: 'text', text: baueMerkmalePrompt(merkmale) }
] }],
tools: [{
name: 'plan_pruefung',
description: 'Das Prüfergebnis je Merkmal.',
input_schema: PLAN_CHECK_SCHEMA
}],
tool_choice: { type: 'tool', name: 'plan_pruefung' }
})
});
if(!res.ok){
const errBody = await res.text().catch(() => '');
throw new Error('Claude antwortete mit HTTP ' + res.status + (errBody ? ': ' + errBody.slice(0, 200) : ''));
}
const data = await res.json();
const toolUse = Array.isArray(data.content) ? data.content.find(b => b.type === 'tool_use') : null;
if(!toolUse || !toolUse.input || !Array.isArray(toolUse.input.ergebnisse)){
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
}
return toolUse.input.ergebnisse;
}
// pruefePlanMitKI: rendert den Bildsatz (Übersicht + Kachel-Ausschnitte,
// renderPdfAlsBildsatz() weiter oben) jetzt EINMAL, GEMEINSAM für beide
// Anbieter — anders als zuvor, als nur ChatGPT ein eigenes Rendering bekam
// und Claude das PDF direkt (siehe Projekthistorie bei pruefePlanMitClaude()
// oben). Reihenfolge des zurückgegebenen Arrays: Übersicht zuerst, danach
// die Kacheln in Zeilen-/Spaltenreihenfolge — dieselbe Reihenfolge, die
// baueMerkmalePrompt() dem Modell textlich erklärt und die bildIndex im
// Ergebnis referenziert.
//
// Ergänzt NACH der KI-Antwort pro Ergebnis ein "screenshot"-Feld (Data-URL
// oder null) — Eriks Wunsch, im Plan-Checker-Ergebnis zu jedem Merkmal ein
// Bild zu zeigen. Ein erster Versuch (Beschriftungsraster auf der Übersicht
// + nachträglicher Zuschnitt einer von der KI genannten Rasterzelle) erwies
// sich in der Praxis als nicht gut genug ("das mit den Bildausschnitten hat
// nicht gut funktioniert") und wurde durch diese einfachere Variante
// ersetzt: Der Screenshot ist exakt eines der bereits gerenderten Bilder
// (bildsatz.uebersicht oder eine der bildsatz.kacheln), ausgewählt allein
// über den von der KI gelieferten bildIndex — KEIN zweiter Render-Durchlauf
// mehr nötig, die Bilder liegen als Buffer schon vor.
async function pruefePlanMitKI(pdfBuf, merkmale, userId, provider){
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
if(provider !== 'claude' && provider !== 'chatgpt') throw new Error('Unbekannter Anbieter: ' + provider);
let bildsatz;
try {
bildsatz = await renderPdfAlsBildsatz(pdfBuf);
} catch(err){
console.error('PDF-Rendering fehlgeschlagen:', err);
throw new Error('Die PDF-Datei konnte nicht gelesen werden.');
}
const bilder = [bildsatz.uebersicht, ...bildsatz.kacheln];
const bilderBase64 = bilder.map(b => b.toString('base64'));
const ergebnisse = provider === 'claude'
? await pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model)
: await pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, model);
// Number.isInteger()-Prüfung statt blindem Vertrauen auf das Schema: ein
// Drittanbieter/lokaler Server hält sich nicht immer an response_format
// (s. Projekthistorie bei pruefePlanMitChatGPT() - derselbe Grund, aus dem
// dort schon Markdown-Codeblöcke toleriert werden). Ein fehlender/
// unbrauchbarer bildIndex liefert einfach keinen Screenshot statt die
// Prüfung scheitern zu lassen.
for(const e of ergebnisse){
const idx = e.bildIndex;
e.screenshot = Number.isInteger(idx) && idx >= 0 && idx < bilder.length
? 'data:image/png;base64,' + bilder[idx].toString('base64')
: null;
}
return ergebnisse;
}
// textZuSicheremHtml: wandelt reinen Klartext (PDF/Word-Extraktion oder
// KI-Erkennung, erkennePunkteAusText()/erkennePunkteMitKI() oben) in
// dasselbe sanitizeStatusHtml()-taugliche HTML, das ein von Hand getippter
@@ -4786,6 +5362,102 @@ app.delete('/lph-templates/:id', requireRole('admin'), (req, res) => {
}
});
// ---------- Plan-Checker-Merkmale (plan_checker_merkmale) ----------
function serializeMerkmalRow(row){
return { id: row.id, titel: row.titel, prompt: row.prompt, sortOrder: row.sort_order };
}
// Route: alle Merkmale lesen (jeder angemeldete Nutzer braucht die Liste, um
// zu sehen, was geprüft wird — nur das Pflegen ist Admin-Sache).
app.get('/plan-checker-merkmale', requireAuth, (req, res) => {
try {
const rows = db.prepare('SELECT * FROM plan_checker_merkmale ORDER BY sort_order, titel').all();
res.json({ ok: true, merkmale: rows.map(serializeMerkmalRow) });
} catch(err){
console.error('Merkmale laden fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: neues Merkmal anlegen. Admin-only (firmenweite Konfiguration).
app.post('/plan-checker-merkmale', requireRole('admin'), (req, res) => {
try {
const { titel, prompt } = req.body || {};
const cleanTitel = String(titel || '').trim();
const cleanPrompt = String(prompt || '').trim();
if(!cleanTitel) return res.status(400).json({ ok: false, error: 'Titel fehlt.' });
if(!cleanPrompt) return res.status(400).json({ ok: false, error: 'Prüfanweisung fehlt.' });
const maxRow = db.prepare('SELECT MAX(sort_order) AS m FROM plan_checker_merkmale').get();
const sortOrder = (maxRow && Number.isFinite(maxRow.m) ? maxRow.m : -1) + 1;
const id = crypto.randomUUID();
db.prepare('INSERT INTO plan_checker_merkmale (id, titel, prompt, sort_order) VALUES (?, ?, ?, ?)')
.run(id, cleanTitel, cleanPrompt, sortOrder);
res.json({ ok: true, merkmal: { id, titel: cleanTitel, prompt: cleanPrompt, sortOrder } });
} catch(err){
console.error('Merkmal anlegen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: Merkmal löschen. Admin-only. Idempotent.
app.delete('/plan-checker-merkmale/:id', requireRole('admin'), (req, res) => {
try {
db.prepare('DELETE FROM plan_checker_merkmale WHERE id = ?').run(String(req.params.id));
res.json({ ok: true });
} catch(err){
console.error('Merkmal löschen fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// Route: einen Plan prüfen. Roh-Binärkörper wie /project-protokolle/import
// (siehe RAW_BODY_ROUTES oben) — provider und ein optionales, nur für DIESE
// Prüfung geltendes Zusatz-Merkmal kommen als Query-Parameter, da der Body
// vollständig die Datei ist. Weder die PDF- noch die daraus gerenderten
// Bilddateien werden gespeichert (siehe Erläuterung bei pruefePlanMitKI()
// oben). Das Rendering zum Bildsatz (Übersicht + Kacheln) passiert NICHT
// hier, sondern innerhalb von pruefePlanMitKI() — für beide Anbieter gleich.
app.post('/plan-checker-pruefen', requireAuth, express.raw({ type: '*/*', limit: '40mb' }), async (req, res) => {
try {
const buf = req.body;
if(!Buffer.isBuffer(buf) || buf.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Datei empfangen.' });
}
// Einfache Format-Prüfung über die PDF-Magic-Bytes statt einer
// Dateiendung (die Endung kommt hier aus keinem Formularfeld, der Body
// IST die Datei) — ein PDF beginnt immer mit "%PDF".
if(buf.slice(0, 4).toString('latin1') !== '%PDF'){
return res.status(400).json({ ok: false, error: 'Nur PDF-Dateien werden unterstützt.' });
}
const provider = String(req.query.provider || '');
if(provider !== 'chatgpt' && provider !== 'claude'){
return res.status(400).json({ ok: false, error: 'Unbekannter KI-Anbieter.' });
}
const merkmale = db.prepare('SELECT titel, prompt FROM plan_checker_merkmale ORDER BY sort_order, titel').all();
const zusatz = String(req.query.zusatzMerkmal || '').trim();
if(zusatz) merkmale.push({ titel: zusatz, prompt: zusatz });
if(merkmale.length === 0){
return res.status(400).json({ ok: false, error: 'Keine Merkmale zum Prüfen hinterlegt.' });
}
try {
const ergebnisse = await pruefePlanMitKI(buf, merkmale, req.user.id, provider);
res.json({ ok: true, ergebnisse });
} catch(err){
// Ein fehlgeschlagener KI-Aufruf (kein Schlüssel, Netzwerk, Zeitüber-
// schreitung, unerwartete Antwort) ist ein gültiges, anzuzeigendes
// Ergebnis — kein Serverfehler (gleiches Muster wie POST
// /my-ai-settings/test).
res.json({ ok: false, error: String(err && err.message || err) });
}
} catch(err){
console.error('Plan-Prüfung fehlgeschlagen:', err);
res.status(500).json({ ok: false, error: String(err) });
}
});
// ---------- Honorar-Konfiguration (Anteile je LPH + Stundensatz) ----------
// ---------- Firmenweite Einstellungen (app_settings) ----------
@@ -4974,8 +5646,8 @@ app.get('/my-ai-settings', requireAuth, (req, res) => {
const claude = readUserAiKey(req.user.id, 'claude');
res.json({
ok: true,
chatgpt: { configured: !!chatgpt.apiKey, apiUrl: chatgpt.apiUrl },
claude: { configured: !!claude.apiKey, apiUrl: claude.apiUrl }
chatgpt: { configured: !!chatgpt.apiKey, apiUrl: chatgpt.apiUrl, model: chatgpt.model },
claude: { configured: !!claude.apiKey, apiUrl: claude.apiUrl, model: claude.model }
});
} catch(err){
console.error('KI-Einstellungen laden fehlgeschlagen:', err);
@@ -4998,8 +5670,8 @@ app.put('/my-ai-settings', requireAuth, (req, res) => {
const claude = readUserAiKey(req.user.id, 'claude');
res.json({
ok: true,
chatgpt: { configured: !!chatgpt.apiKey, apiUrl: chatgpt.apiUrl },
claude: { configured: !!claude.apiKey, apiUrl: claude.apiUrl }
chatgpt: { configured: !!chatgpt.apiKey, apiUrl: chatgpt.apiUrl, model: chatgpt.model },
claude: { configured: !!claude.apiKey, apiUrl: claude.apiUrl, model: claude.model }
});
} catch(err){
console.error('KI-Einstellungen speichern fehlgeschlagen:', err);
@@ -5432,7 +6104,7 @@ app.get('/', (req,res)=>{
// Projektliste aufgegangen ist: Weitergegebene Links sollen nicht mit 404
// enden. Die Oberfläche leitet den Abschnitt auf die Projektliste um
// (TAB_BY_SLUG in public/js/globals.js) und erzeugt ihn selbst nicht mehr.
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Projekte','Projektbeteiligte','Planung','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
const VALID_TAB_SLUGS = new Set(['Projektübersicht','Wochenbericht','Auslastung','Projekte','Projektbeteiligte','Planung','PlanChecker','MeineAufgaben','Wandtafel','Einstellungen','Admin']);
// Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen)
const PLAN_SLUG = 'Projektplan';