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:
1 parent
db63a29365
commit
6ba93b2a92
308 files changed
+64441
-743
No files matched your search
@@ -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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user