Beteiligte in die Projekteinstellungen, TODOs aus Protokoll, Bewegung
Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und nur noch in den Projekteinstellungen editierbar (planCycleAssign()). Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen. Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst unabhaengig von den gespeicherten Protokollen - nichts davon wird abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter Import leicht 20 Vorschlaege liefert. Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt (Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog. Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe. plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit Zeilenzahl und Umsortierung nicht auseinanderlaufen. KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt (Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem alten Limit und brach bei jeder Schwankung ab. Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root, angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die Akzentleiste ist ein inset-Schatten statt eines positionierten Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens. Plan Checker: optionaler Projektabgleich des Plankopfs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6ba93b2a92
commit
93505e0e48
17 files changed
+1883
-334
No files matched your search
@@ -343,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', '/plan-checker-pruefen']);
|
||||
const RAW_BODY_ROUTES = new Set(['/import', '/project-files', '/project-protokolle/import', '/plan-checker-pruefen', '/plan-todos-aus-protokoll/einlesen']);
|
||||
const jsonBodyParser = express.json({ limit: '8mb' });
|
||||
app.use((req, res, next) => {
|
||||
if(req.method === 'POST' && RAW_BODY_ROUTES.has(req.path)) return next();
|
||||
@@ -556,6 +556,7 @@ db.exec(`CREATE TABLE IF NOT EXISTS plan_todos (
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
show_in_gantt INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (week_label, project_id, todo_id)
|
||||
)`);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS plan_todo_assignees (
|
||||
@@ -1050,8 +1051,8 @@ function serializeWeek(week){
|
||||
VALUES (?, ?, ?, ?, ?)`);
|
||||
const insPlanPerson = db.prepare(`INSERT OR IGNORE INTO plan_people (week_label, project_id, person_id, array_order)
|
||||
VALUES (?, ?, ?, ?)`);
|
||||
const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
const insTodo = db.prepare(`INSERT INTO plan_todos (week_label, project_id, todo_id, title, start, end, done, progress, sort_order, show_in_gantt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
const insTodoAssignee = db.prepare(`INSERT OR IGNORE INTO plan_todo_assignees (week_label, project_id, todo_id, person_id)
|
||||
VALUES (?, ?, ?, ?)`);
|
||||
const insTodoDep = db.prepare(`INSERT OR IGNORE INTO plan_todo_deps (week_label, project_id, todo_id, depends_on_id)
|
||||
@@ -1112,7 +1113,8 @@ function serializeWeek(week){
|
||||
(Array.isArray(plan.people) ? plan.people : []).forEach((pid, pidx) => insPlanPerson.run(weekLabel, projId, String(pid || ''), pidx));
|
||||
(Array.isArray(plan.todos) ? plan.todos : []).forEach((t, tidx) => {
|
||||
insTodo.run(weekLabel, projId, String(t.id || ''), String(t.title || ''),
|
||||
String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx);
|
||||
String(t.start || ''), String(t.end || ''), t.done ? 1 : 0, Number(t.progress) || 0, tidx,
|
||||
t.showInGantt === false ? 0 : 1);
|
||||
(Array.isArray(t.assignees) ? t.assignees : []).forEach(pid => insTodoAssignee.run(weekLabel, projId, String(t.id || ''), String(pid || '')));
|
||||
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(did => insTodoDep.run(weekLabel, projId, String(t.id || ''), String(did || '')));
|
||||
});
|
||||
@@ -1297,7 +1299,7 @@ function loadWeeks(){
|
||||
const todoMap = {};
|
||||
todos.forEach(r => {
|
||||
const k = todoKey(r.week_label, r.project_id, r.todo_id);
|
||||
todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress };
|
||||
todoMap[k] = { id: r.todo_id, title: r.title, start: r.start, end: r.end, done: !!r.done, dependsOn: [], assignees: [], progress: r.progress, showInGantt: !!r.show_in_gantt };
|
||||
});
|
||||
todoAssignees.forEach(r => {
|
||||
const t = todoMap[todoKey(r.week_label, r.project_id, r.todo_id)];
|
||||
@@ -2238,6 +2240,24 @@ function migrateAddProjectGroesse(){
|
||||
}
|
||||
}
|
||||
|
||||
// migrateAddPlanTodoShowInGantt: rüstet die Spalte `show_in_gantt` nach
|
||||
// (idempotent, gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()).
|
||||
// DEFAULT 1, damit jede bestehende Aufgabe ihr bisheriges Verhalten behält
|
||||
// (sichtbar im GANTT, sobald Start UND Ende gesetzt sind) — die neue
|
||||
// Checkbox im Aufgaben-Dialog ("Im Terminplan anzeigen", plan-modals.js)
|
||||
// blendet gezielt EINZELNE Aufgaben aus, ändert an diesem Normalfall nichts.
|
||||
// Erik auf eigenen Wunsch, nachdem "TODOs aus Protokoll" (plan-protokoll-
|
||||
// todos.js) gezeigt hatte, dass ein einzelner Import leicht 15-25 Aufgaben
|
||||
// auf einmal anlegt — alle MIT Datum (siehe dortiger Kopfkommentar), also
|
||||
// bisher alle zwingend auch als GANTT-Balken.
|
||||
function migrateAddPlanTodoShowInGantt(){
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('plan_todos')").all().map(r => r.name);
|
||||
if(!cols.includes('show_in_gantt')){
|
||||
db.exec('ALTER TABLE plan_todos ADD COLUMN show_in_gantt INTEGER NOT NULL DEFAULT 1');
|
||||
console.log('plan_todos um show_in_gantt ergänzt.');
|
||||
}
|
||||
}
|
||||
|
||||
// migrateAddProjectUpdatedAt: rüstet die Spalte `updated_at` nach (idempotent,
|
||||
// gleiches ALTER-TABLE-Muster wie migrateAddProjectGroesse()). Gebraucht von
|
||||
// der Planung-Sandbox, siehe Spaltenkommentar am CREATE TABLE oben.
|
||||
@@ -2665,6 +2685,7 @@ migrateAddProjectStatusAuthor();
|
||||
migrateAddProjectAuftragsstatus();
|
||||
migrateAddProjectGroesse();
|
||||
migrateAddProjectUpdatedAt();
|
||||
migrateAddPlanTodoShowInGantt();
|
||||
migrateAddUserAiKeyModel();
|
||||
migrateAddProtokollQuelle();
|
||||
migrateAddProtokollPunktErstelltAm();
|
||||
@@ -4264,6 +4285,29 @@ const KI_TIMEOUT_MS_CLOUD = 45000;
|
||||
// sichtbar, kein scheinbares Hängenbleiben.
|
||||
const KI_TIMEOUT_MS_LOKAL = 1800000;
|
||||
|
||||
// KI_TIMEOUT_MS_CLOUD_FREMD: ein abweichender, aber weiterhin REMOTE
|
||||
// liegender Endpunkt (z. B. Mammouth.ai, OpenRouter, ein Firmen-Proxy) ist
|
||||
// weder "die echte, bekanntermaßen schnelle OpenAI/Anthropic-API" (dort
|
||||
// gilt zurecht der scharfe KI_TIMEOUT_MS_CLOUD) noch "lokale CPU-Inferenz,
|
||||
// die Minuten dauern darf" (KI_TIMEOUT_MS_LOKAL) — ein eigener,
|
||||
// mittlerer Wert. Gefunden bei einem echten Fehlschlag: "TODOs aus
|
||||
// Protokoll" brach bei Erik zuverlässig mit "This operation was aborted"
|
||||
// ab, obwohl der Protokoll-Import grundsätzlich funktionierte — derselbe
|
||||
// Konto-Schlüssel zeigte auf https://api.mammouth.ai/v1 (Modell
|
||||
// "gpt-6-luna"), kein Loopback, also griff bislang der scharfe
|
||||
// KI_TIMEOUT_MS_CLOUD (45s). Direkt gegen den echten Schlüssel nachgestellt
|
||||
// (derselbe Aufruf, derselbe Prompt, dasselbe Dokument wie im Fehlerfall):
|
||||
// ein einzelnes triviales Prompt beantwortete der Anbieter in 1,3s, die
|
||||
// tatsächliche Punkte-Erkennung eines realen Protokolls (29.594 Zeichen)
|
||||
// brauchte dagegen 39,2s — schon im Erfolgsfall nur 6s unter dem alten
|
||||
// Limit, jede normale Schwankung (Tageszeit, Dokumentlänge, Anbieterlast)
|
||||
// reichte seither zuverlässig zum Abbruch. Ein Drittanbieter-Proxy ist
|
||||
// grundsätzlich langsamer und weniger vorhersagbar als die Original-API,
|
||||
// aber immer noch ein verwalteter Cloud-Dienst, keine CPU-Inferenz auf
|
||||
// fremder Hardware — 2 Minuten geben das Dreifache der gemessenen Dauer
|
||||
// als Spielraum, ohne in die Größenordnung von KI_TIMEOUT_MS_LOKAL zu gehen.
|
||||
const KI_TIMEOUT_MS_CLOUD_FREMD = 120000;
|
||||
|
||||
// LOKAL_DISPATCHER: Node's globales fetch() läuft intern über undici, dessen
|
||||
// eigener Standard-Agent Verbindungen nach headersTimeout/bodyTimeout = 5
|
||||
// Minuten von sich aus kappt — UNABHÄNGIG vom eigenen AbortController unten.
|
||||
@@ -4354,6 +4398,17 @@ function writeUserAiKey(userId, provider, patch){
|
||||
.run(userId, provider, apiKey, apiUrl, model);
|
||||
}
|
||||
|
||||
// KI_DEFAULT_URL: vor istLoopbackHost()/fetchMitTimeout() platziert (stand
|
||||
// ursprünglich weiter unten bei normalisiereAiUrl()), weil fetchMitTimeout()
|
||||
// unten die beiden Adressen jetzt selbst braucht, um einen ECHTEN
|
||||
// Anbieter-Endpunkt von einem abweichenden (lokal ODER ein fremder
|
||||
// Cloud-Proxy) zu unterscheiden — siehe istStandardCloudHost() und den
|
||||
// Fund bei KI_TIMEOUT_MS_CLOUD_FREMD weiter unten.
|
||||
const KI_DEFAULT_URL = {
|
||||
chatgpt: 'https://api.openai.com/v1/chat/completions',
|
||||
claude: 'https://api.anthropic.com/v1/messages'
|
||||
};
|
||||
|
||||
// istLoopbackHost: gemeinsame Grundlage für istGueltigeAiUrl() (URL-Prüfung
|
||||
// beim Speichern) und fetchMitTimeout() (Timeout-Wahl beim Aufruf) — beide
|
||||
// müssen dieselbe Vorstellung von "läuft auf der eigenen Maschine" teilen.
|
||||
@@ -4361,6 +4416,15 @@ function istLoopbackHost(hostname){
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
||||
}
|
||||
|
||||
// istStandardCloudHost: true nur für die beiden ECHTEN Anbieter-Hosts aus
|
||||
// KI_DEFAULT_URL (api.openai.com/api.anthropic.com) — die einzigen, bei
|
||||
// denen "eine Antwort dauert ungewöhnlich lange" zuverlässig "hier ist
|
||||
// etwas kaputt" bedeutet. Grundlage für fetchMitTimeout()s dritte Stufe
|
||||
// (KI_TIMEOUT_MS_CLOUD_FREMD) direkt darunter.
|
||||
function istStandardCloudHost(hostname){
|
||||
return hostname === new URL(KI_DEFAULT_URL.chatgpt).hostname || hostname === new URL(KI_DEFAULT_URL.claude).hostname;
|
||||
}
|
||||
|
||||
// istGueltigeAiUrl: https wird grundsätzlich verlangt (der Schlüssel reist
|
||||
// sonst im Klartext über ein Netz, das nicht die eigene Maschine ist) — mit
|
||||
// EINER Ausnahme: http auf dem Loopback. Ein lokal laufender Server wie
|
||||
@@ -4378,10 +4442,20 @@ function istGueltigeAiUrl(url){
|
||||
}
|
||||
|
||||
async function fetchMitTimeout(url, options){
|
||||
// Drei Stufen statt zwei: echter Anbieter-Host (knapp, 45s) < abweichender
|
||||
// Remote-Host wie ein Drittanbieter-Proxy (mittel, 2min, s.
|
||||
// KI_TIMEOUT_MS_CLOUD_FREMD) < Loopback (großzügig, 30min) — siehe dortigen
|
||||
// Kommentar für den konkreten Fehlschlag, der die mittlere Stufe nötig machte.
|
||||
let istLokal = false;
|
||||
try { istLokal = istLoopbackHost(new URL(url).hostname); } catch(e){ /* ungültige URL: Vorgabe bleibt Cloud-Timeout */ }
|
||||
let istStandard = true;
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
istLokal = istLoopbackHost(hostname);
|
||||
istStandard = istStandardCloudHost(hostname);
|
||||
} catch(e){ /* ungültige URL: Vorgabe bleibt der knappe Standard-Timeout */ }
|
||||
const timeoutMs = istLokal ? KI_TIMEOUT_MS_LOKAL : (istStandard ? KI_TIMEOUT_MS_CLOUD : KI_TIMEOUT_MS_CLOUD_FREMD);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), istLokal ? KI_TIMEOUT_MS_LOKAL : KI_TIMEOUT_MS_CLOUD);
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const fetchOptions = Object.assign({}, options, { signal: controller.signal });
|
||||
if(istLokal) fetchOptions.dispatcher = LOKAL_DISPATCHER;
|
||||
@@ -4458,10 +4532,8 @@ const KI_PUNKT_SCHEMA = {
|
||||
additionalProperties: false
|
||||
};
|
||||
|
||||
const KI_DEFAULT_URL = {
|
||||
chatgpt: 'https://api.openai.com/v1/chat/completions',
|
||||
claude: 'https://api.anthropic.com/v1/messages'
|
||||
};
|
||||
// KI_DEFAULT_URL ist jetzt weiter oben definiert, direkt vor
|
||||
// istLoopbackHost()/fetchMitTimeout() — siehe Kommentar dort.
|
||||
|
||||
// normalisiereAiUrl: wer eine eigene API-URL hinterlegt, tippt bei einem
|
||||
// lokalen Server wie LM Studio/Ollama erfahrungsgemäß nur die Basisadresse
|
||||
@@ -4623,6 +4695,232 @@ async function erkennePunkteMitKI(text, userId, provider){
|
||||
throw new Error('Unbekannter Anbieter: ' + provider);
|
||||
}
|
||||
|
||||
// ---------- "TODOs aus Protokoll" (public/js/plan/plan-protokoll-todos.js) ----------
|
||||
//
|
||||
// Eigenständige, zweite KI-Stufe: nimmt bereits erkannte Besprechungspunkte
|
||||
// (egal ob frisch aus einem Ad-hoc-PDF-Upload oder sonstwoher) und leitet
|
||||
// daraus Aufgaben-VORSCHLÄGE ab (Titel, Beschreibung, Fälligkeit, Zuständig-
|
||||
// Vorschlag) — anders als erkennePunkteMitKI() oben, das nur Punkte aus
|
||||
// Fließtext EXTRAHIERT, beurteilt dieser Schritt, was davon tatsächlich zu
|
||||
// tun ist: mehrere Punkte können zu einer Aufgabe zusammengehören, rein
|
||||
// informative Punkte liefern keine. Komplett unabhängig von
|
||||
// project_protokolle/protokoll_punkte — die Punkte kommen direkt im
|
||||
// Request-Body, nichts wird hier gelesen oder geschrieben.
|
||||
|
||||
// normalisiereAufgabeAusKi: Analoges Gegenstück zu normalisierePunktAusKi()
|
||||
// oben, für eine KI-Antwortzeile dieser zweiten Stufe.
|
||||
function normalisiereAufgabeAusKi(a){
|
||||
return {
|
||||
titel: String((a && a.titel) || ''),
|
||||
beschreibung: String((a && a.beschreibung) || ''),
|
||||
faelligkeit: String((a && a.faelligkeit) || ''),
|
||||
zustaendigVorschlag: String((a && a.zustaendigVorschlag) || ''),
|
||||
quellRefs: Array.isArray(a && a.quellRefs) ? a.quellRefs.filter(r => Number.isInteger(r)) : []
|
||||
};
|
||||
}
|
||||
|
||||
const KI_AUFGABEN_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
aufgaben: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
titel: { type: 'string' },
|
||||
beschreibung: { type: 'string' },
|
||||
faelligkeit: { type: 'string' },
|
||||
zustaendigVorschlag: { type: 'string' },
|
||||
quellRefs: { type: 'array', items: { type: 'integer' } }
|
||||
},
|
||||
required: ['titel', 'beschreibung', 'faelligkeit', 'zustaendigVorschlag', 'quellRefs'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['aufgaben'],
|
||||
additionalProperties: false
|
||||
};
|
||||
|
||||
// KI_AUFGABEN_SYSTEM_PROMPT: eigener, von KI_SYSTEM_PROMPT bewusst getrennter
|
||||
// Prompt — andere Aufgabe (Aufgaben aus bereits strukturierten Punkten
|
||||
// ABLEITEN, nicht Punkte aus Fließtext EXTRAHIEREN). Wie bei KI_SYSTEM_PROMPT/
|
||||
// PLAN_CHECK_SYSTEM_PROMPT gilt: der Wortlaut ist ein Startpunkt, kein
|
||||
// Festbetrag — vor dem produktiven Einsatz mit einem echten externen
|
||||
// Protokoll bei temperature:0 testen, Änderungen einzeln bewerten (siehe
|
||||
// CLAUDE.md "### Protokolle").
|
||||
const KI_AUFGABEN_SYSTEM_PROMPT = 'Du bekommst Besprechungspunkte eines Projektprotokolls (je Punkt: ref, thema, '
|
||||
+ 'text, zustaendig, termin). Leite daraus konkrete, umsetzbare Aufgaben ab. Mehrere Punkte, die dieselbe Handlung '
|
||||
+ 'betreffen, können in einer Aufgabe zusammengefasst werden; rein informative Punkte ohne notwendige Handlung '
|
||||
+ 'liefern KEINE Aufgabe. Je Aufgabe: ein kurzer Titel (Tätigkeit zuerst), eine kurze Beschreibung, ein '
|
||||
+ 'Fälligkeitsdatum NUR wenn im Text ein konkretes Datum genannt ist (sonst leer lassen, nichts erfinden), ein '
|
||||
+ 'Vorschlag für die zuständige Person (wörtlich aus dem Feld "zustaendig" übernommen, nichts erfinden), und die '
|
||||
+ 'refs der Punkte, aus denen sie entstand.';
|
||||
|
||||
// aufgabenTextFuerKi: baut den Nutzer-Content für beide Anbieter aus der
|
||||
// Punkteliste — jeder Punkt trägt seine Array-Position als "ref", damit die
|
||||
// Antwort referenzierbar ist, ohne dass ein id-Konzept nötig wäre (nichts
|
||||
// hier ist persistiert).
|
||||
function aufgabenTextFuerKi(punkte){
|
||||
return punkte.map((p, i) => `ref ${i}\nThema: ${p.thema || ''}\nText: ${p.text || ''}\n`
|
||||
+ `Zuständig: ${p.zustaendig || ''}\nTermin: ${p.termin || ''}`).join('\n\n');
|
||||
}
|
||||
|
||||
async function leiteAufgabenMitChatGPT(punkte, 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: model || 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: KI_AUFGABEN_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: aufgabenTextFuerKi(punkte) }
|
||||
],
|
||||
response_format: { type: 'json_schema', json_schema: { name: 'protokoll_aufgaben', strict: true, schema: KI_AUFGABEN_SCHEMA } },
|
||||
// Kleiner als bei der Punkte-Erkennung (erkennePunkteMitChatGPT oben):
|
||||
// die Ausgabe ist nur eine Handvoll kurzer Aufgaben, kein ganzes Protokoll.
|
||||
max_tokens: 4096,
|
||||
temperature: 0
|
||||
};
|
||||
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.');
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(content); } catch(e){ throw new Error('ChatGPT-Antwort war kein gültiges JSON.'); }
|
||||
if(!Array.isArray(parsed.aufgaben)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
|
||||
return parsed.aufgaben.map(normalisiereAufgabeAusKi);
|
||||
}
|
||||
|
||||
async function leiteAufgabenMitClaude(punkte, 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: model || 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 4096,
|
||||
temperature: 0,
|
||||
system: KI_AUFGABEN_SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: aufgabenTextFuerKi(punkte) }],
|
||||
tools: [{
|
||||
name: 'protokoll_aufgaben',
|
||||
description: 'Die aus den Besprechungspunkten abgeleiteten Aufgaben.',
|
||||
input_schema: KI_AUFGABEN_SCHEMA
|
||||
}],
|
||||
tool_choice: { type: 'tool', name: 'protokoll_aufgaben' }
|
||||
})
|
||||
});
|
||||
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.aufgaben)){
|
||||
throw new Error('Claude-Antwort hatte nicht die erwartete Form.');
|
||||
}
|
||||
return toolUse.input.aufgaben.map(normalisiereAufgabeAusKi);
|
||||
}
|
||||
|
||||
async function leiteAufgabenMitKI(punkte, userId, provider){
|
||||
const { apiKey, apiUrl, model } = readUserAiKey(userId, provider);
|
||||
if(!apiKey) throw new Error('Kein API-Schlüssel hinterlegt.');
|
||||
if(provider === 'chatgpt') return leiteAufgabenMitChatGPT(punkte, apiKey, apiUrl, model);
|
||||
if(provider === 'claude') return leiteAufgabenMitClaude(punkte, apiKey, apiUrl, model);
|
||||
throw new Error('Unbekannter Anbieter: ' + provider);
|
||||
}
|
||||
|
||||
// Route 1/2: liest ein hochgeladenes PDF (roh im Body, wie
|
||||
// /project-protokolle/import) und erkennt daraus Besprechungspunkte — nutzt
|
||||
// DIESELBE Erkennung wie der echte Protokoll-Import (erkennePunkteMitKI()/
|
||||
// erkennePunkteAusText()), legt aber NICHTS an: kein project_protokolle-
|
||||
// Eintrag, keine Datei auf der Festplatte (anders als der Import, der beides
|
||||
// immer persistiert — diese Funktion ist bewusst ein Ad-hoc-Werkzeug ohne
|
||||
// Archiv, siehe Kopfkommentar oben). Die erkannten Punkte gehen direkt an
|
||||
// den Client zurück und werden von der zweiten Route unten (ohne erneuten
|
||||
// Server-Lookup) weiterverarbeitet.
|
||||
app.post('/plan-todos-aus-protokoll/einlesen', requireAuth, express.raw({ type: '*/*', limit: '50mb' }), 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.' });
|
||||
}
|
||||
const text = await extrahierePdfText(buf);
|
||||
|
||||
let punkte, erkennungsart = 'textmuster', erkennungshinweis = '';
|
||||
const anbieter = req.query.anbieter;
|
||||
if(anbieter === 'chatgpt' || anbieter === 'claude'){
|
||||
try {
|
||||
const ergebnis = await erkennePunkteMitKI(text, req.user.id, anbieter);
|
||||
punkte = ergebnis.punkte;
|
||||
erkennungsart = anbieter;
|
||||
} catch(err){
|
||||
console.warn('KI-Erkennung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf Textmuster:', err.message);
|
||||
erkennungshinweis = err.message;
|
||||
punkte = erkennePunkteAusText(text);
|
||||
}
|
||||
} else {
|
||||
punkte = erkennePunkteAusText(text);
|
||||
}
|
||||
|
||||
res.json({ ok: true, punkte, erkennungsart, erkennungshinweis });
|
||||
} catch(err){
|
||||
console.error('TODOs aus Protokoll (Einlesen) fehlgeschlagen:', err);
|
||||
res.status(500).json({ ok: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Route 2/2: leitet aus den (vom Client ausgewählten) Punkten Aufgaben-
|
||||
// Vorschläge ab. Die Punkte kommen direkt im Body — kein Bezug zu
|
||||
// project_protokolle, siehe Kopfkommentar oben.
|
||||
app.post('/plan-todos-aus-protokoll/aufgaben-vorschlag', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { anbieter, punkte } = req.body || {};
|
||||
if(!Array.isArray(punkte) || punkte.length === 0){
|
||||
return res.status(400).json({ ok: false, error: 'Keine Punkte ausgewählt.' });
|
||||
}
|
||||
|
||||
let vorschlaege, erkennungsart = 'direkt', erkennungshinweis = '';
|
||||
if(anbieter === 'chatgpt' || anbieter === 'claude'){
|
||||
try {
|
||||
vorschlaege = await leiteAufgabenMitKI(punkte, req.user.id, anbieter);
|
||||
erkennungsart = anbieter;
|
||||
} catch(err){
|
||||
console.warn('KI-Ableitung (TODOs aus Protokoll) fehlgeschlagen, Rückfall auf 1:1-Übernahme:', err.message);
|
||||
erkennungshinweis = err.message;
|
||||
vorschlaege = null;
|
||||
}
|
||||
}
|
||||
if(!vorschlaege){
|
||||
// Direkter Rückfall: je ausgewähltem Punkt ein Vorschlag, derselbe
|
||||
// Rückfall-Geist wie erkennePunkteAusText() beim Import.
|
||||
vorschlaege = punkte.map((p, i) => ({
|
||||
titel: String(p.thema || '').trim() || String(p.text || '').slice(0, 80),
|
||||
beschreibung: String(p.text || ''),
|
||||
faelligkeit: String(p.termin || ''),
|
||||
zustaendigVorschlag: String(p.zustaendig || ''),
|
||||
quellRefs: [i]
|
||||
}));
|
||||
}
|
||||
|
||||
res.json({ ok: true, vorschlaege, erkennungsart, erkennungshinweis });
|
||||
} catch(err){
|
||||
console.error('TODOs aus Protokoll (Aufgaben-Vorschlag) fehlgeschlagen:', err);
|
||||
res.status(500).json({ ok: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Plan Checker (plan_checker_merkmale) ----------
|
||||
//
|
||||
// Eigenständiges Prüf-Werkzeug im Seitenleisten-Bereich "Studenten": lädt
|
||||
@@ -4679,18 +4977,25 @@ async function erkennePunkteMitKI(text, userId, provider){
|
||||
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.';
|
||||
+ 'der Zeichnung eindeutig zu erkennen ist - im Zweifel false. "begruendung" ist HÖCHSTENS 150 ZEICHEN LANG - eine feste '
|
||||
+ 'Obergrenze, kein Stilwunsch. Zähle NIEMALS einzelne Werte auf (keine Maßzahlen, keine Höhenangaben, keine '
|
||||
+ 'Koordinaten) - auch nicht als Beispiel ("z. B. ...") und auch nicht, wenn auf der Zeichnung sehr viele davon zu sehen '
|
||||
+ 'sind: beschreibe stattdessen in einem knappen Satz, DASS und WO solche Angaben vorkommen. 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.
|
||||
// Ergebnis stark verschieben (siehe dortige Projekthistorie). Der erste
|
||||
// Zusatz ("NIE eine Aufzählung einzelner Werte", ohne Zeichengrenze) 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"). Reichte
|
||||
// demselben Modell beim nächsten Fehlschlag aber IMMER NOCH nicht: Es zählte
|
||||
// diesmal sogar "1,00, 2,00, 3,00 ... 22,00" durch und sprengte damit auch
|
||||
// das inzwischen angehobene max_tokens erneut. Eine semantische Anweisung
|
||||
// ("halt dich kurz") lässt sich offenbar umgehen, eine harte Zahl
|
||||
// ("höchstens 150 Zeichen") ist für ein Modell mechanisch leichter zu prüfen
|
||||
// - ebenfalls noch nicht an diesem konkreten Fehlschlag gegengetestet, siehe
|
||||
// Hinweis bei max_tokens.
|
||||
|
||||
// PLAN_CHECK_BILD_ANZAHL: Gesamtzahl der an die KI gesendeten Bilder (1
|
||||
// Übersicht + die Kacheln, s. renderPdfAlsBildsatz() oben) - bestimmt den
|
||||
@@ -4798,14 +5103,15 @@ async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, mode
|
||||
] }
|
||||
],
|
||||
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,
|
||||
// 4096 war zu knapp, dann 8192 - ein lokal angebundenes Modell hielt sich
|
||||
// trotz zunehmend schärferer Anweisung (s. PLAN_CHECK_SYSTEM_PROMPT oben,
|
||||
// Projekthistorie dort) wiederholt nicht an die Kürze, und die Antwort
|
||||
// wurde mitten im JSON abgeschnitten. 16000 angeglichen an das Limit, das
|
||||
// sich für genau dasselbe Problem beim Protokoll-Import bereits bewährt
|
||||
// hat (erkennePunkteMitChatGPT() oben) - weiterhin nur ein Sicherheitsnetz
|
||||
// zusätzlich zur Prompt-Anweisung, keine Garantie: Ein Modell, das
|
||||
// entschlossen genug aufzählt, kann auch dieses Limit erreichen.
|
||||
max_tokens: 16000,
|
||||
temperature: 0
|
||||
};
|
||||
// Siehe erkennePunkteMitChatGPT() oben: manche lokale Server laden
|
||||
@@ -4832,12 +5138,21 @@ async function pruefePlanMitChatGPT(bilderBase64, merkmale, apiKey, apiUrl, mode
|
||||
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));
|
||||
// finish_reason:'length' heißt konkret "max_tokens erreicht, bevor das
|
||||
// Modell fertig war" - der häufigste real beobachtete Grund für
|
||||
// ungültiges JSON hier (ein Modell, das trotz PLAN_CHECK_SYSTEM_PROMPT
|
||||
// einzelne Werte aufzählt, s. dortige Projekthistorie). Eine konkrete
|
||||
// Fehlermeldung dafür erspart das erneute Einschicken des rohen
|
||||
// Antworttexts zur Diagnose. Rohtext zusätzlich im Fehler, wie bei den
|
||||
// HTTP-Fehlern oben (errBody.slice(0, 200)) - andere Scheitern-Gründe
|
||||
// (z. B. ein Modell, das nur EIN Bild pro Anfrage verarbeiten kann und
|
||||
// bei mehreren Bildern mit Fließtext statt JSON antwortet) bleiben so
|
||||
// trotzdem sichtbar.
|
||||
const abgeschnitten = data.choices[0].finish_reason === 'length';
|
||||
const hinweis = abgeschnitten
|
||||
? 'Die Antwort wurde abgeschnitten (Modell hat das Token-Limit erreicht, bevor es fertig war - vermutlich zu ausführliche Begründungen). '
|
||||
: '';
|
||||
throw new Error('ChatGPT-Antwort war kein gültiges JSON: ' + hinweis + content.slice(0, 300));
|
||||
}
|
||||
if(!Array.isArray(parsed.ergebnisse)) throw new Error('ChatGPT-Antwort hatte nicht die erwartete Form.');
|
||||
return parsed.ergebnisse;
|
||||
@@ -4875,10 +5190,11 @@ async function pruefePlanMitClaude(bilderBase64, merkmale, apiKey, apiUrl, model
|
||||
// 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,
|
||||
// 16000 - dasselbe Sicherheitsnetz, auf denselben Wert angehoben wie bei
|
||||
// pruefePlanMitChatGPT() oben (Projekthistorie dort), selbes Risiko gilt
|
||||
// grundsätzlich auch hier (eine zu ausführliche Begründung, die vor
|
||||
// Abschluss des JSON abgeschnitten wird).
|
||||
max_tokens: 16000,
|
||||
temperature: 0,
|
||||
system: PLAN_CHECK_SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: [
|
||||
@@ -5436,6 +5752,51 @@ app.post('/plan-checker-pruefen', requireAuth, express.raw({ type: '*/*', limit:
|
||||
}
|
||||
|
||||
const merkmale = db.prepare('SELECT titel, prompt FROM plan_checker_merkmale ORDER BY sort_order, titel').all();
|
||||
|
||||
// Projekt-Abgleich (Eriks Wunsch): optional, genau wie das Zusatz-Merkmal
|
||||
// weiter unten nur für DIESE eine Prüfung und nicht gespeichert - kein
|
||||
// neues Feld in plan_checker_merkmale, sondern derselbe Mechanismus, nur
|
||||
// mit einem serverseitig gebauten statt frei getipptem Text. Das Projekt
|
||||
// selbst kommt aus dem bereits beim Client geladenen db.weeks (die
|
||||
// Auswahl im Frontend liest currentWeek().projects, kein eigener
|
||||
// Endpunkt) - der Server bekommt nur Nummer/Bezeichnung als Query-Param,
|
||||
// prüft sie aber NICHT gegen die Datenbank (reine Texteingabe für den
|
||||
// Prompt, wie das Zusatz-Merkmal auch). Bewusst nur Projektnummer +
|
||||
// Bezeichnung: Das sind die einzigen Projekt-Stammdaten, die es im
|
||||
// Datenmodell überhaupt gibt - ein Plankopf kennt zusätzlich
|
||||
// Planersteller, Maßstab, Plannummer, Datum, aber dafür gibt es keine
|
||||
// gespeicherte Vorgabe, gegen die man abgleichen könnte.
|
||||
const projektNr = String(req.query.projektNr || '').trim();
|
||||
const projektName = String(req.query.projektName || '').trim();
|
||||
if(projektNr || projektName){
|
||||
const soll = [
|
||||
projektNr ? `Projektnummer "${projektNr}"` : null,
|
||||
projektName ? `Projektbezeichnung "${projektName}"` : null
|
||||
].filter(Boolean).join(' und ');
|
||||
// Erste Fassung fragte direkt "stimmt der Plankopf mit X überein?" -
|
||||
// ein echter Fehlschlag zeigte, dass das zu leicht als "bestätige X"
|
||||
// statt "lies ab und vergleiche dann" gelesen wird: Die KI gab exakt
|
||||
// die vorgegebenen Werte als angeblich abgelesene Werte zurück und
|
||||
// erklärte sie für übereinstimmend, obwohl im Plankopf tatsächlich eine
|
||||
// andere Nummer stand (Erik hat das am echten Ergebnis nachgewiesen).
|
||||
// Jetzt zweistufig erzwungen: ERST ablesen UND DAS GELESENE in der
|
||||
// Begründung wörtlich nennen, DANN erst vergleichen - macht die
|
||||
// Vorgabewerte nicht Teil der "Lese"-Aufgabe und macht einen
|
||||
// Bestätigungsfehler wie diesen für einen Menschen beim Lesen der
|
||||
// Begründung sofort erkennbar (genau wie Erik ihn gefunden hat).
|
||||
merkmale.push({
|
||||
titel: 'Plankopf stimmt mit Projekt überein',
|
||||
prompt: 'Lies zuerst die im Plankopf (Schriftfeld) TATSÄCHLICH abgedruckte Projektnummer und/oder '
|
||||
+ 'Projektbezeichnung ab - unabhängig davon, was du gleich vergleichen sollst. Nenne diese tatsächlich '
|
||||
+ 'abgelesenen Werte als ERSTES in der Begründung, wörtlich wie abgedruckt. Vergleiche ERST DANACH, ob '
|
||||
+ `diese abgelesenen Werte zu ${soll} passen. Sinngemäße Übereinstimmung genügt (Abkürzungen, leicht `
|
||||
+ 'abweichende Schreibweise, zusätzliche Wörter), eine erkennbar ANDERE Nummer oder ein erkennbar anderes '
|
||||
+ 'Projekt dagegen nicht. "erfuellt" ist nur dann true, wenn die TATSÄCHLICH abgelesenen Werte passen - '
|
||||
+ 'nicht schon deshalb, weil das erwartete Projekt plausibel klingt. Ist die Angabe im Plankopf nicht '
|
||||
+ 'lesbar, sage das, statt die erwarteten Werte zu wiederholen.'
|
||||
});
|
||||
}
|
||||
|
||||
const zusatz = String(req.query.zusatzMerkmal || '').trim();
|
||||
if(zusatz) merkmale.push({ titel: zusatz, prompt: zusatz });
|
||||
if(merkmale.length === 0){
|
||||
|
||||
Reference in new issue
Block a user