From e908db27888c33b05cb4a22049446b7ee4bebd7d Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Mon, 21 Sep 2026 17:31:51 +0200 Subject: [PATCH] feat(assistant): Fragen an die Daten im Kopf der Anwendung Management und Administration koennen oben rechts eine Frage in Worten stellen - "wie viele Stunden im August", "welches Projekt hat noch Budget", "wie ist die Auslastung". Aufbau: das Modell bekommt eine Uebersicht (Kunden, laufende Projekte, Eckdaten) und vier Werkzeuge - auswertung, projekte, buchungen, personen. Damit fragt es gezielt nach, statt dass ihm der ganze Bestand vorgelegt wird; 19.000 Buchungen passen in keinen Kontext. Die Werkzeuge rechnen mit loadEntries und loadProjects, also mit denselben Funktionen wie die Reports - der Assistent kann so keine zweite Wahrheit erzeugen. Was einfliesst, begrenzt visibleUserIds wie ueberall sonst. Benutzt wird die Responses-API von OpenAI: die neueren Modelle nehmen Werkzeuge in /v1/chat/completions nur ohne Nachdenken an. Mit "store: false" bleibt nichts bei OpenAI liegen, der Verlauf geht deshalb je Runde komplett mit. Der Schluessel liegt verschluesselt in den Einstellungen (AES-256-GCM wie das SSO-Secret) und wird nie ausgeliefert; das Modell ist einstellbar, Vorgabe gpt-5.6-luna. Co-Authored-By: Claude Opus 5 (1M context) --- server/src/lib/ai.js | 89 +++++ server/src/lib/assistant.js | 445 +++++++++++++++++++++++++ server/src/lib/settings.js | 12 + server/src/routes/assistant.js | 40 +++ server/src/routes/settings.js | 29 ++ server/src/server.js | 3 + src/components/assistant/Assistant.tsx | 172 ++++++++++ src/components/layout/Header.tsx | 3 + src/components/settings/Settings.tsx | 94 +++++- src/components/ui/Icon.tsx | 1 + src/lib/api.ts | 23 +- 11 files changed, 909 insertions(+), 2 deletions(-) create mode 100644 server/src/lib/ai.js create mode 100644 server/src/lib/assistant.js create mode 100644 server/src/routes/assistant.js create mode 100644 src/components/assistant/Assistant.tsx diff --git a/server/src/lib/ai.js b/server/src/lib/ai.js new file mode 100644 index 0000000..327407a --- /dev/null +++ b/server/src/lib/ai.js @@ -0,0 +1,89 @@ +/* + * Anbindung an OpenAI fuer den Assistenten im Kopf der Anwendung. + * + * Bewusst ohne SDK: gebraucht wird ein einziger Endpunkt, und `fetch` ist in + * Node eingebaut. Der Schluessel liegt verschluesselt in den Einstellungen und + * bleibt auf dem Server - der Browser spricht nie mit OpenAI. + * + * Benutzt wird /v1/responses, nicht /v1/chat/completions: die neueren Modelle + * (gpt-5.6-luna und Geschwister) nehmen Werkzeuge dort nur an, wenn man das + * Nachdenken abschaltet. Ueber die Responses-API geht beides zusammen. + * + * `store: false` - die Frage und die Daten aus Pulse bleiben nicht bei OpenAI + * liegen. Der Verlauf eines Gespraechs wird deshalb bei jeder Runde komplett + * mitgeschickt. + */ + +const ENDPOINT = 'https://api.openai.com/v1/responses'; +const TIMEOUT_MS = 120_000; + +export const AI_DEFAULT_MODEL = 'gpt-5.6-luna'; + +export class AiError extends Error { + constructor(message, status = 502) { + super(message); + this.status = status; + } +} + +export function aiConfigured(ai) { + return Boolean(ai?.api_key); +} + +/** Ein Aufruf. `input` ist der ganze bisherige Verlauf inklusive Werkzeugergebnisse. */ +export async function askModel(ai, { instructions, input, tools, maxOutputTokens = 2000, effort = 'low' }) { + if (!aiConfigured(ai)) throw new AiError('Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI-Assistent).', 503); + + const body = { + model: ai.model || AI_DEFAULT_MODEL, + instructions, + input, + max_output_tokens: maxOutputTokens, + store: false, + }; + if (tools?.length) body.tools = tools; + // Nicht jedes Modell kennt die Stufe; ohne Angabe waehlt OpenAI selbst. + if (effort) body.reasoning = { effort }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + let response; + try { + response = await fetch(ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ai.api_key}` }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + if (err.name === 'AbortError') throw new AiError('Das Modell hat nicht rechtzeitig geantwortet.', 504); + throw new AiError(`OpenAI ist nicht erreichbar: ${err.message}`); + } + clearTimeout(timer); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const message = data?.error?.message || `HTTP ${response.status}`; + if (response.status === 401) throw new AiError('OpenAI hat den Schlüssel abgelehnt.', 502); + if (response.status === 429) throw new AiError('OpenAI drosselt gerade oder das Guthaben ist aufgebraucht.', 502); + if (response.status === 404) throw new AiError(`Das Modell „${body.model}“ steht für diesen Schlüssel nicht bereit.`, 502); + throw new AiError(`OpenAI: ${message}`); + } + return data; +} + +/** Text aller Nachrichten einer Antwort. */ +export function answerText(response) { + return (response.output || []) + .filter((item) => item.type === 'message') + .flatMap((item) => item.content || []) + .map((part) => part.text || '') + .join('') + .trim(); +} + +/** Werkzeugaufrufe einer Antwort. */ +export function toolCalls(response) { + return (response.output || []).filter((item) => item.type === 'function_call'); +} diff --git a/server/src/lib/assistant.js b/server/src/lib/assistant.js new file mode 100644 index 0000000..0368fb4 --- /dev/null +++ b/server/src/lib/assistant.js @@ -0,0 +1,445 @@ +import { pool } from '../db.js'; +import { getSettings } from './settings.js'; +import { loadEntries } from './entries.js'; +import { loadProjects } from './projects.js'; +import { visibleUserIds } from './access.js'; +import { today, isoWeek } from './dates.js'; +import { AiError, askModel, answerText, toolCalls } from './ai.js'; + +/* + * Der Assistent: eine Frage in Worten, eine Antwort aus den Daten dieser + * Anwendung. + * + * Anders als die KI-Suche im CRM bekommt das Modell hier NICHT den ganzen + * Bestand - 19.000 Buchungen passen in keinen Kontext. Es bekommt eine + * Uebersicht (Kunden, laufende Projekte, Personen) und vier Werkzeuge, mit + * denen es gezielt nachfragen kann. Die Werkzeuge rechnen mit denselben + * Funktionen wie die Reports, damit der Assistent keine zweite Wahrheit + * erzeugt. + * + * Was das Modell sehen darf, richtet sich nach der Rolle des Fragenden: die + * Werkzeuge schraenken auf `visibleUserIds` ein, genau wie die Auswertungen. + */ + +const MAX_RUNDEN = 6; +const MAX_ZEILEN = 200; + +export const TOOLS = [ + { + type: 'function', + name: 'auswertung', + description: + 'Gebuchte Zeiten in einem Zeitraum, gruppiert. Liefert Stunden, abrechenbare Stunden und Betrag je Gruppe. ' + + 'Das ist das Werkzeug für alle Fragen nach "wie viele Stunden", "wie viel Umsatz", "wer hat wie viel gebucht".', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + von: { type: 'string', description: 'Erster Tag, YYYY-MM-DD.' }, + bis: { type: 'string', description: 'Letzter Tag, YYYY-MM-DD.' }, + gruppieren: { + type: 'string', + enum: ['kunde', 'projekt', 'task', 'person', 'monat', 'woche', 'gesamt'], + description: 'Wonach gruppiert wird. "gesamt" liefert nur die Summe.', + }, + kunde: { type: ['string', 'null'], description: 'Auf einen Kunden einschränken (Name oder Kürzel).' }, + projekt: { type: ['string', 'null'], description: 'Auf ein Projekt einschränken (Code oder Name).' }, + person: { type: ['string', 'null'], description: 'Auf eine Person einschränken (Name).' }, + nur_abrechenbar: { type: ['boolean', 'null'], description: 'Nur abrechenbare Buchungen zählen.' }, + ohne_interne_projekte: { type: ['boolean', 'null'], description: 'Interne Projekte und Abwesenheiten weglassen.' }, + }, + required: ['von', 'bis', 'gruppieren'], + }, + }, + { + type: 'function', + name: 'projekte', + description: + 'Projekte mit Budget, Verbrauch, Restbudget, Laufzeit und Budgettöpfen. Für Fragen nach Budgets, Restbudget, ' + + 'Auftragslage, Projektstatus und danach, welche Projekte es zu einem Kunden gibt.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + suche: { type: ['string', 'null'], description: 'Text in Projektname oder Code.' }, + kunde: { type: ['string', 'null'], description: 'Nur Projekte dieses Kunden.' }, + status: { type: ['string', 'null'], enum: ['aktiv', 'archiviert', 'alle', null], description: 'Standard: aktiv.' }, + nur_mit_budget: { type: ['boolean', 'null'], description: 'Nur Projekte mit Budgettopf.' }, + limit: { type: ['integer', 'null'], description: 'Höchstens so viele Projekte (Standard 40).' }, + }, + required: [], + }, + }, + { + type: 'function', + name: 'buchungen', + description: + 'Einzelne Zeitbuchungen mit Datum, Person, Projekt, Task und Notiz. Für Fragen nach konkreten Tätigkeiten ' + + '("woran hat X am Montag gearbeitet"). Höchstens 100 Zeilen - für Summen die auswertung nehmen.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + von: { type: 'string', description: 'Erster Tag, YYYY-MM-DD.' }, + bis: { type: 'string', description: 'Letzter Tag, YYYY-MM-DD.' }, + person: { type: ['string', 'null'] }, + projekt: { type: ['string', 'null'] }, + kunde: { type: ['string', 'null'] }, + suche: { type: ['string', 'null'], description: 'Text in der Notiz oder im Tasknamen.' }, + limit: { type: ['integer', 'null'], description: 'Standard 50, höchstens 100.' }, + }, + required: ['von', 'bis'], + }, + }, + { + type: 'function', + name: 'personen', + description: + 'Mitarbeiter mit Rolle, Wochenstunden und - wenn ein Zeitraum angegeben ist - gebuchten Stunden, ' + + 'abrechenbarem Anteil und Auslastung. Für Fragen nach Auslastung und danach, wer woran arbeitet.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + von: { type: ['string', 'null'], description: 'Zeitraum für Stunden und Auslastung, YYYY-MM-DD.' }, + bis: { type: ['string', 'null'] }, + nur_aktive: { type: ['boolean', 'null'], description: 'Standard: nur aktive Mitarbeiter.' }, + }, + required: [], + }, + }, +]; + +const ANWEISUNG = `Du bist der Assistent von Pulse, der Zeiterfassung der perinco GmbH. Du beantwortest Fragen zu Kunden, Projekten, Budgets, Buchungen und Auslastung. + +Arbeitsweise: +- Hol dir die Zahlen IMMER über die Werkzeuge. Rechne nichts aus dem Gedächtnis und schätze nie. +- Die Übersicht am Anfang nennt nur laufende Projekte und Kunden. Alles Weitere steht in den Werkzeugen, auch archivierte Projekte. +- Stimmt ein Name nicht genau, melden die Werkzeuge Vorschläge zurück - nimm den passenden und frag erst nach, wenn es wirklich mehrdeutig ist. +- Mehrere Werkzeugaufrufe sind in Ordnung. Für Summen nimm "auswertung", nicht "buchungen". + +Antwort: +- Deutsch, knapp, sachlich. Keine Anrede, keine Einleitung, kein Markdown und keine Sternchen. +- Nenne die Zahlen mit Einheit und deutschem Format (1.234,50 h / 12.345,67 €) und dazu den Zeitraum, auf den sie sich beziehen. +- Bei Listen höchstens zehn Zeilen, die größten zuerst, und sag dazu, wenn mehr existieren. +- Steht etwas nicht in den Daten, sag das klar. Erfinde nichts. + +Fachliches: +- "Billable"/abrechenbar heißt: Projekt und Task sind abrechenbar. Interne Projekte und Abwesenheiten (Urlaub, Krankheit, Feiertag) sind nicht abrechenbar. +- Der Betrag ist Stunden mal Satz. Der Satz kommt vom Task, sonst vom Budgettopf, bei Abruf vom Projekt. +- Projekttypen: "billable" rechnet nach Aufwand gegen einen Budgettopf, "abruf" nach Aufwand ohne Deckel, "fixed" ist ein Festpreis, "non_billable" ist intern. +- Auslastung = gebuchte Stunden bis heute geteilt durch die Kapazität bis heute.`; + +/* ----------------------------------------------------------- Werkzeuge -- */ + +const zahl = (minuten) => Math.round((minuten / 60) * 100) / 100; +const geld = (betrag) => Math.round(betrag * 100) / 100; + +function passt(text, suche) { + return String(text || '').toLowerCase().includes(String(suche || '').toLowerCase()); +} + +/** Findet Kunde, Projekt oder Person zu einer Eingabe des Modells. */ +async function finde(art, eingabe) { + const suche = String(eingabe).trim(); + if (art === 'kunde') { + const [rows] = await pool.query('SELECT id, name, short_name FROM pulse_customers ORDER BY name'); + const treffer = rows.filter((r) => passt(r.name, suche) || passt(r.short_name, suche)); + return { treffer, vorschlaege: rows.map((r) => r.name) }; + } + if (art === 'projekt') { + const [rows] = await pool.query('SELECT id, code, name FROM pulse_projects ORDER BY code'); + const treffer = rows.filter((r) => passt(r.code, suche) || passt(r.name, suche)); + return { treffer, vorschlaege: rows.slice(0, 40).map((r) => `${r.code} ${r.name}`) }; + } + const [rows] = await pool.query("SELECT id, name FROM pulse_users WHERE role <> 'admin' ORDER BY name"); + const treffer = rows.filter((r) => passt(r.name, suche)); + return { treffer, vorschlaege: rows.map((r) => r.name) }; +} + +async function werkzeugAuswertung(args, ctx) { + const filter = {}; + if (args.kunde) { + const { treffer, vorschlaege } = await finde('kunde', args.kunde); + if (treffer.length !== 1) return { fehler: `Kunde „${args.kunde}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => t.name) : vorschlaege }; + filter.customerId = treffer[0].id; + } + if (args.projekt) { + const { treffer, vorschlaege } = await finde('projekt', args.projekt); + if (treffer.length !== 1) return { fehler: `Projekt „${args.projekt}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => `${t.code} ${t.name}`) : vorschlaege }; + filter.projectId = treffer[0].id; + } + let userIds = ctx.userIds; + if (args.person) { + const { treffer, vorschlaege } = await finde('person', args.person); + if (treffer.length !== 1) return { fehler: `Person „${args.person}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => t.name) : vorschlaege }; + const id = treffer[0].id; + if (ctx.userIds !== null && !ctx.userIds.includes(id)) return { fehler: 'Diese Person liegt außerhalb deiner Sicht.' }; + userIds = [id]; + } + + let entries = await loadEntries(ctx.settings, { + from: args.von, + to: args.bis, + userIds, + ...filter, + skipNonBillableProjects: Boolean(args.ohne_interne_projekte), + }); + if (args.nur_abrechenbar) entries = entries.filter((e) => e.billable); + + const schluessel = { + kunde: (e) => e.customer_name, + projekt: (e) => `${e.project_code} ${e.project_display}`, + task: (e) => e.task_name || '(ohne Task)', + person: (e) => e.user_name, + monat: (e) => String(e.entry_date).slice(0, 7), + woche: (e) => `KW ${isoWeek(String(e.entry_date)).week}/${String(e.entry_date).slice(0, 4)}`, + gesamt: () => 'Gesamt', + }[args.gruppieren]; + + const gruppen = new Map(); + for (const e of entries) { + const k = schluessel(e); + const g = gruppen.get(k) || { gruppe: k, stunden: 0, abrechenbare_stunden: 0, betrag: 0, buchungen: 0 }; + g.stunden += e.minutes; + if (e.billable) g.abrechenbare_stunden += e.minutes; + g.betrag += e.amount; + g.buchungen += 1; + gruppen.set(k, g); + } + const zeilen = [...gruppen.values()] + .map((g) => ({ ...g, stunden: zahl(g.stunden), abrechenbare_stunden: zahl(g.abrechenbare_stunden), betrag: geld(g.betrag) })) + .sort((a, b) => (args.gruppieren === 'monat' || args.gruppieren === 'woche' ? String(a.gruppe).localeCompare(String(b.gruppe)) : b.stunden - a.stunden)); + + return { + zeitraum: { von: args.von, bis: args.bis }, + gruppiert_nach: args.gruppieren, + summe: { + stunden: zahl(entries.reduce((s, e) => s + e.minutes, 0)), + abrechenbare_stunden: zahl(entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0)), + betrag: geld(entries.reduce((s, e) => s + e.amount, 0)), + buchungen: entries.length, + }, + zeilen: zeilen.slice(0, MAX_ZEILEN), + gekuerzt: zeilen.length > MAX_ZEILEN ? zeilen.length - MAX_ZEILEN : 0, + }; +} + +async function werkzeugProjekte(args, ctx) { + const where = []; + const params = []; + const status = args.status || 'aktiv'; + if (status === 'aktiv') where.push('p.is_active = 1'); + if (status === 'archiviert') where.push('p.is_active = 0'); + if (args.kunde) { + const { treffer, vorschlaege } = await finde('kunde', args.kunde); + if (treffer.length !== 1) return { fehler: `Kunde „${args.kunde}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => t.name) : vorschlaege }; + where.push('p.customer_id = ?'); + params.push(treffer[0].id); + } + if (args.suche) { + where.push('(p.name LIKE ? OR p.code LIKE ?)'); + params.push(`%${args.suche}%`, `%${args.suche}%`); + } + const projekte = await loadProjects(ctx.settings, { where: where.join(' AND ') || '1=1', params }); + const gefiltert = args.nur_mit_budget ? projekte.filter((p) => p.pots.length) : projekte; + const limit = Math.min(Number(args.limit) || 40, 80); + return { + anzahl: gefiltert.length, + projekte: gefiltert.slice(0, limit).map((p) => ({ + code: p.code, + name: p.display_name || p.name, + kunde: p.customer_name, + typ: p.type, + aktiv: p.is_active, + laufzeit: { von: String(p.start_date), bis: p.end_date ? String(p.end_date) : null }, + satz: p.rate == null ? null : Number(p.rate), + gebuchte_stunden: zahl(p.used_minutes || 0), + budget_stunden: p.budget_hours == null ? null : Number(p.budget_hours), + rest_stunden: p.remaining_hours == null ? null : Number(p.remaining_hours), + toepfe: p.pots.map((b) => ({ + bezeichnung: b.label, + von: String(b.valid_from), + bis: String(b.valid_to), + budget_stunden: b.budget_hours == null ? null : Number(b.budget_hours), + budget_euro: b.budget_amount == null ? null : Number(b.budget_amount), + satz: b.rate == null ? null : Number(b.rate), + bestellnummer: b.order_number || null, + genutzte_stunden: zahl(b.used_minutes || 0), + erloest_euro: b.earned == null ? null : Number(b.earned), + status: b.status, + hinweis: b.warning || null, + })), + tasks: p.tasks.map((t) => ({ name: t.name, abrechenbar: Boolean(t.billable), satz: t.rate == null ? null : Number(t.rate) })), + team: p.members.map((m) => m.name), + })), + gekuerzt: Math.max(0, gefiltert.length - limit), + }; +} + +async function werkzeugBuchungen(args, ctx) { + const filter = {}; + let userIds = ctx.userIds; + for (const [art, wert] of [['kunde', args.kunde], ['projekt', args.projekt], ['person', args.person]]) { + if (!wert) continue; + const { treffer, vorschlaege } = await finde(art, wert); + if (treffer.length !== 1) { + return { fehler: `${art} „${wert}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => t.name || `${t.code} ${t.name}`) : vorschlaege }; + } + if (art === 'kunde') filter.customerId = treffer[0].id; + if (art === 'projekt') filter.projectId = treffer[0].id; + if (art === 'person') { + if (ctx.userIds !== null && !ctx.userIds.includes(treffer[0].id)) return { fehler: 'Diese Person liegt außerhalb deiner Sicht.' }; + userIds = [treffer[0].id]; + } + } + let entries = await loadEntries(ctx.settings, { from: args.von, to: args.bis, userIds, ...filter }); + if (args.suche) entries = entries.filter((e) => passt(e.note, args.suche) || passt(e.task_name, args.suche)); + const limit = Math.min(Number(args.limit) || 50, 100); + return { + zeitraum: { von: args.von, bis: args.bis }, + anzahl: entries.length, + summe_stunden: zahl(entries.reduce((s, e) => s + e.minutes, 0)), + buchungen: entries.slice(0, limit).map((e) => ({ + datum: String(e.entry_date), + person: e.user_name, + projekt: `${e.project_code} ${e.project_display}`, + task: e.task_name || null, + stunden: zahl(e.minutes), + abrechenbar: Boolean(e.billable), + betrag: geld(e.amount), + notiz: e.note || null, + })), + gekuerzt: Math.max(0, entries.length - limit), + }; +} + +async function werkzeugPersonen(args, ctx) { + const nurAktive = args.nur_aktive !== false; + const [users] = await pool.query( + `SELECT id, name, role, weekly_hours, is_external, company, is_active, job_title + FROM pulse_users WHERE role <> 'admin' ${nurAktive ? 'AND is_active = 1' : ''} ORDER BY name` + ); + const sichtbar = users.filter((u) => ctx.userIds === null || ctx.userIds.includes(u.id)); + const zeilen = sichtbar.map((u) => ({ + name: u.name, + rolle: u.role, + wochenstunden: u.weekly_hours == null ? Number(ctx.settings.weekly_hours) : Number(u.weekly_hours), + extern: Boolean(u.is_external), + firma: u.company || null, + aktiv: Boolean(u.is_active), + })); + + if (args.von && args.bis) { + const { buildTimeReport } = await import('../routes/reports.js'); + const report = await buildTimeReport(ctx.viewer, { from: args.von, to: args.bis }); + const nachName = new Map(report.users.map((r) => [r.name, r])); + for (const zeile of zeilen) { + const r = nachName.get(zeile.name); + zeile.stunden = r ? zahl(r.minutes) : 0; + zeile.abrechenbare_stunden = r ? zahl(r.billable_minutes) : 0; + zeile.betrag = r ? geld(r.amount) : 0; + zeile.kapazitaet_stunden = r ? zahl(r.capacity_minutes || 0) : 0; + zeile.auslastung_prozent = r ? r.utilization : null; + } + } + return { zeitraum: args.von && args.bis ? { von: args.von, bis: args.bis } : null, anzahl: zeilen.length, personen: zeilen }; +} + +const WERKZEUGE = { + auswertung: werkzeugAuswertung, + projekte: werkzeugProjekte, + buchungen: werkzeugBuchungen, + personen: werkzeugPersonen, +}; + +/* ----------------------------------------------------------- Uebersicht -- */ + +/** Kleiner Lagebericht, damit das Modell Namen kennt und nicht blind sucht. */ +async function uebersicht(settings, ctx) { + const [[zahlen]] = await pool.query( + `SELECT (SELECT COUNT(*) FROM pulse_customers) AS kunden, + (SELECT COUNT(*) FROM pulse_projects) AS projekte, + (SELECT COUNT(*) FROM pulse_projects WHERE is_active = 1) AS projekte_aktiv, + (SELECT COUNT(*) FROM pulse_users WHERE is_active = 1 AND role <> 'admin') AS mitarbeiter, + (SELECT COUNT(*) FROM pulse_time_entries) AS buchungen, + (SELECT MIN(entry_date) FROM pulse_time_entries) AS erste_buchung, + (SELECT MAX(entry_date) FROM pulse_time_entries) AS letzte_buchung` + ); + const [kunden] = await pool.query('SELECT name FROM pulse_customers ORDER BY name'); + const aktive = await loadProjects(settings, { where: 'p.is_active = 1' }); + return { + heute: today(), + firma: settings.company_name, + stunden_pro_tag: Number(settings.hours_per_day), + wochenstunden_standard: Number(settings.weekly_hours), + bestand: { + kunden: zahlen.kunden, + projekte_gesamt: zahlen.projekte, + projekte_aktiv: zahlen.projekte_aktiv, + mitarbeiter_aktiv: zahlen.mitarbeiter, + buchungen: zahlen.buchungen, + zeitraum: { von: String(zahlen.erste_buchung || ''), bis: String(zahlen.letzte_buchung || '') }, + }, + kunden: kunden.map((k) => k.name), + laufende_projekte: aktive.map((p) => ({ + code: p.code, + name: p.display_name || p.name, + kunde: p.customer_name, + typ: p.type, + rest_stunden: p.remaining_hours == null ? null : Number(p.remaining_hours), + })), + }; +} + +/* -------------------------------------------------------------- Ablauf -- */ + +/** + * Eine Frage beantworten. `verlauf` sind frühere Frage/Antwort-Paare dieses + * Gesprächs - Werkzeugergebnisse gehen nicht mit, die holt das Modell neu. + */ +export async function ask({ viewer, question, verlauf = [] }) { + const settings = await getSettings(); + if (!settings.ai.enabled) throw new AiError('Der KI-Assistent ist ausgeschaltet (Einstellungen → KI-Assistent).', 503); + + const ctx = { settings, viewer, userIds: await visibleUserIds(viewer) }; + const input = []; + for (const runde of verlauf.slice(-6)) { + if (runde?.frage) input.push({ role: 'user', content: String(runde.frage).slice(0, 2000) }); + if (runde?.antwort) input.push({ role: 'assistant', content: String(runde.antwort).slice(0, 4000) }); + } + input.push({ + role: 'user', + content: `Übersicht:\n${JSON.stringify(await uebersicht(settings, ctx))}\n\nFrage: ${question}`, + }); + + const benutzt = []; + for (let runde = 0; runde < MAX_RUNDEN; runde += 1) { + const response = await askModel(settings.ai, { + instructions: ANWEISUNG, + input, + tools: TOOLS, + maxOutputTokens: 3000, + }); + const aufrufe = toolCalls(response); + if (aufrufe.length === 0) { + const text = answerText(response); + if (!text) throw new AiError('Das Modell hat keine Antwort geliefert.', 502); + return { answer: text, model: response.model, tools: benutzt }; + } + for (const item of response.output) input.push(item); + for (const aufruf of aufrufe) { + let ergebnis; + try { + const args = JSON.parse(aufruf.arguments || '{}'); + benutzt.push({ name: aufruf.name, args }); + const fn = WERKZEUGE[aufruf.name]; + ergebnis = fn ? await fn(args, ctx) : { fehler: `Unbekanntes Werkzeug ${aufruf.name}.` }; + } catch (err) { + ergebnis = { fehler: `Werkzeug fehlgeschlagen: ${err.message}` }; + } + input.push({ type: 'function_call_output', call_id: aufruf.call_id, output: JSON.stringify(ergebnis) }); + } + } + throw new AiError('Die Frage brauchte zu viele Zwischenschritte. Bitte enger stellen.', 502); +} diff --git a/server/src/lib/settings.js b/server/src/lib/settings.js index 45e7982..20d64d1 100644 --- a/server/src/lib/settings.js +++ b/server/src/lib/settings.js @@ -71,6 +71,16 @@ export const DEFAULTS = { last_run: null, last_result: null, }, + /* + * KI-Assistent im Kopf der Anwendung. Der Schluessel liegt verschluesselt, + * genau wie das SSO-Secret; das Modell ist einstellbar, weil OpenAI die + * Namen regelmaessig wechselt. + */ + ai: { + enabled: false, + api_key: '', + model: 'gpt-5.6-luna', + }, sso: { enabled: false, button_label: 'Mit Microsoft anmelden', @@ -104,6 +114,7 @@ export async function getSettings() { } if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret); if (result.hrworks.secret_access_key) result.hrworks.secret_access_key = decrypt(result.hrworks.secret_access_key); + if (result.ai.api_key) result.ai.api_key = decrypt(result.ai.api_key); return result; } @@ -112,6 +123,7 @@ export async function saveSetting(key, value) { let stored = value; if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) }; if (key === 'hrworks' && value.secret_access_key) stored = { ...value, secret_access_key: encrypt(value.secret_access_key) }; + if (key === 'ai' && value.api_key) stored = { ...value, api_key: encrypt(value.api_key) }; await pool.query( 'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)', [key, JSON.stringify(stored)] diff --git a/server/src/routes/assistant.js b/server/src/routes/assistant.js new file mode 100644 index 0000000..6e39f77 --- /dev/null +++ b/server/src/routes/assistant.js @@ -0,0 +1,40 @@ +import express from 'express'; +import { requireRole } from '../middleware/auth.js'; +import { getSettings } from '../lib/settings.js'; +import { aiConfigured } from '../lib/ai.js'; +import { ask } from '../lib/assistant.js'; + +/* + * Der Assistent im Kopf der Anwendung. + * + * Nur Management und Administration: die Antworten fassen Zeiten, Budgets und + * Umsaetze ueber alle Mitarbeiter zusammen - das ist keine Sicht fuer jeden. + * Welche Daten einfliessen, entscheidet trotzdem `visibleUserIds`. + */ + +const router = express.Router(); +router.use(requireRole('management')); + +router.get('/status', async (req, res, next) => { + try { + const { ai } = await getSettings(); + res.json({ available: Boolean(ai.enabled && aiConfigured(ai)), enabled: ai.enabled, model: ai.model }); + } catch (err) { + next(err); + } +}); + +router.post('/ask', async (req, res, next) => { + const question = String(req.body?.question || '').trim().slice(0, 1000); + if (!question) return res.status(400).json({ error: 'Keine Frage übergeben.' }); + const verlauf = Array.isArray(req.body?.verlauf) ? req.body.verlauf.slice(-6) : []; + try { + const result = await ask({ viewer: req.user, question, verlauf }); + res.json(result); + } catch (err) { + if (err.status) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + +export default router; diff --git a/server/src/routes/settings.js b/server/src/routes/settings.js index cb56680..7c13906 100644 --- a/server/src/routes/settings.js +++ b/server/src/routes/settings.js @@ -2,6 +2,7 @@ import express from 'express'; import { requireRole, ROLES } from '../middleware/auth.js'; import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js'; import { ssoConfigured, testConnection } from '../lib/sso.js'; +import { answerText, askModel } from '../lib/ai.js'; import { sendDocument } from '../export/render.js'; const router = express.Router(); @@ -37,6 +38,7 @@ router.get('/', requireRole('admin'), async (req, res, next) => { secret_set: Boolean(s.hrworks.secret_access_key), configured: Boolean(s.hrworks.access_key && s.hrworks.secret_access_key), }, + ai: { ...s.ai, api_key: '', key_set: Boolean(s.ai.api_key), configured: Boolean(s.ai.api_key) }, }); } catch (err) { next(err); @@ -125,6 +127,17 @@ router.put('/', requireRole('admin'), async (req, res, next) => { if (!errors.length) await saveSetting('hrworks', next_); } + if (body.ai) { + const next_ = { ...current.ai }; + if (body.ai.model !== undefined) next_.model = text(body.ai.model, 80).trim() || DEFAULTS.ai.model; + if (body.ai.enabled !== undefined) next_.enabled = Boolean(body.ai.enabled); + // Leer = unveraendert; der Schluessel wird nie ausgeliefert. + if (body.ai.api_key) next_.api_key = text(body.ai.api_key, 300).trim(); + if (body.ai.clear_key) next_.api_key = ''; + if (next_.enabled && !next_.api_key) errors.push('Der KI-Assistent braucht einen OpenAI-Schlüssel.'); + if (!errors.length) await saveSetting('ai', next_); + } + if (body.sso) { const next_ = { ...current.sso }; for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) { @@ -149,6 +162,22 @@ router.put('/', requireRole('admin'), async (req, res, next) => { } }); +/** Schluessel und Modell einmal ausprobieren. */ +router.post('/ai/test', requireRole('admin'), async (req, res) => { + try { + const { ai } = await getSettings(); + const response = await askModel(ai, { + instructions: 'Antworte mit genau einem Wort: OK.', + input: [{ role: 'user', content: 'Test' }], + maxOutputTokens: 200, + effort: 'low', + }); + res.json({ ok: true, model: response.model, answer: answerText(response).slice(0, 200) }); + } catch (err) { + res.status(err.status || 502).json({ error: err.message }); + } +}); + router.post('/sso/test', requireRole('admin'), async (req, res) => { try { const { sso } = await getSettings(); diff --git a/server/src/server.js b/server/src/server.js index 0507f78..bf03c21 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -15,6 +15,7 @@ import settingsRoutes from './routes/settings.js'; import directoryRoutes from './routes/directory.js'; import importRoutes from './routes/imports.js'; import hrworksRoutes from './routes/hrworks.js'; +import assistantRoutes from './routes/assistant.js'; const app = express(); const PORT = Number(process.env.PORT || 5010); @@ -55,6 +56,8 @@ app.use('/api/directory', requireAuth, requireRole('management'), directoryRoute app.use('/api/imports', requireAuth, requireRole('admin'), importRoutes); // Abwesenheiten aus HRworks - nur Administratoren. app.use('/api/hrworks', requireAuth, requireRole('admin'), hrworksRoutes); +// Der Assistent pruft die Rolle selbst (Management und Administration). +app.use('/api/assistant', requireAuth, assistantRoutes); app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' })); diff --git a/src/components/assistant/Assistant.tsx b/src/components/assistant/Assistant.tsx new file mode 100644 index 0000000..7fd5394 --- /dev/null +++ b/src/components/assistant/Assistant.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Modal } from '../ui/Modal'; +import { Button, ErrorBox, inputClass } from '../ui/controls'; +import { Icon } from '../ui/Icon'; +import { assistantApi, type AssistantAnswer } from '../../lib/api'; +import { useAuth } from '../../lib/auth'; +import { atLeast } from '../../types'; + +/* + * Assistent im Kopf: eine Frage in Worten, eine Antwort aus den Daten von + * Pulse. Sichtbar nur fuer Management und Administration - die Antworten + * fassen Zeiten und Umsaetze ueber alle Mitarbeiter zusammen. + * + * Der Knopf erscheint erst, wenn in den Einstellungen ein Schluessel steht; + * sonst waere er eine Tuer, hinter der nichts ist. + */ + +const BEISPIELE = [ + 'Wie viele Stunden hat das Team diesen Monat gebucht?', + 'Welche Projekte haben weniger als 20 Prozent Restbudget?', + 'Wie ist die Auslastung im September?', + 'Wie viel Umsatz kam dieses Jahr von Hays?', +]; + +type Runde = { frage: string; antwort: string; werkzeuge: AssistantAnswer['tools'] }; + +export const Assistant: React.FC = () => { + const { user } = useAuth(); + const erlaubt = atLeast(user, 'management'); + const [status, setStatus] = useState<{ available: boolean; model: string } | null>(null); + const [offen, setOffen] = useState(false); + + useEffect(() => { + if (!erlaubt) return; + assistantApi + .status() + .then((s) => setStatus({ available: s.available, model: s.model })) + .catch(() => setStatus(null)); + }, [erlaubt]); + + if (!erlaubt || !status?.available) return null; + + return ( + <> + + {offen && setOffen(false)} />} + + ); +}; + +const AssistantDialog: React.FC<{ model: string; onClose: () => void }> = ({ model, onClose }) => { + const [frage, setFrage] = useState(''); + const [verlauf, setVerlauf] = useState([]); + const [laeuft, setLaeuft] = useState(false); + const [fehler, setFehler] = useState(null); + const ende = useRef(null); + + useEffect(() => { + ende.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, [verlauf, laeuft]); + + const fragen = async (text: string) => { + const gestellt = text.trim(); + if (!gestellt || laeuft) return; + setFrage(''); + setFehler(null); + setLaeuft(true); + try { + const antwort = await assistantApi.ask( + gestellt, + verlauf.map((r) => ({ frage: r.frage, antwort: r.antwort })) + ); + setVerlauf((v) => [...v, { frage: gestellt, antwort: antwort.answer, werkzeuge: antwort.tools }]); + } catch (err) { + setFehler(err instanceof Error ? err.message : 'Die Anfrage ist fehlgeschlagen.'); + setFrage(gestellt); + } finally { + setLaeuft(false); + } + }; + + return ( + + setFrage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void fragen(frage); + } + }} + /> + + + } + > +
+ {verlauf.length === 0 && !laeuft && ( +
+

+ Frag nach Stunden, Umsatz, Budgets oder Auslastung. Der Assistent liest nur - er ändert nichts. +

+
+ {BEISPIELE.map((b) => ( + + ))} +
+
+ )} + + {verlauf.map((runde, i) => ( +
+

+ {runde.frage} +

+
+

{runde.antwort}

+ {runde.werkzeuge.length > 0 && ( +

+ Grundlage:{' '} + {runde.werkzeuge + .map((w) => { + const args = Object.entries(w.args) + .filter(([, v]) => v !== null && v !== undefined && v !== '') + .map(([k, v]) => `${k}=${String(v)}`) + .join(', '); + return args ? `${w.name} (${args})` : w.name; + }) + .join(' · ')} +

+ )} +
+
+ ))} + + {laeuft && ( +

+ + Sucht die Zahlen zusammen… +

+ )} + {fehler && } +
+
+ + ); +}; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index bd77a7f..ae34dfe 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -3,6 +3,7 @@ import { useTheme } from '../../lib/theme'; import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types'; import { Icon } from '../ui/Icon'; import { PulseMark } from './PulseMark'; +import { Assistant } from '../assistant/Assistant'; interface HeaderProps { title: string; @@ -37,6 +38,8 @@ export const Header: React.FC = ({ title, subtitle, onToggleSidebar
+ {/* Nur Management und Administration - zeigt Zahlen ueber alle Mitarbeiter. */} + diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 488f956..c9c22a0 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -10,12 +10,13 @@ import { useLoader } from '../../lib/hooks'; import { useRouter } from '../../lib/router'; import { useToast } from '../Toast'; -type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'benutzer'; +type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'ki' | 'benutzer'; const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [ { key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true }, { key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true }, { key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true }, { key: 'hrworks', label: 'Abwesenheiten (HRworks)', icon: 'calendar', adminOnly: true }, + { key: 'ki', label: 'KI-Assistent', icon: 'spark', adminOnly: true }, // Management sieht in den Einstellungen nur diesen Reiter. { key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false }, { key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true }, @@ -60,6 +61,7 @@ export const Settings: React.FC = () => { {tab === 'export' && setData(s)} />} {tab === 'sso' && } {tab === 'hrworks' && } + {tab === 'ki' && }
)} @@ -438,3 +440,93 @@ const SsoSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ); }; + + +/* + * KI-Assistent: Schluessel und Modell. Der Schluessel wird verschluesselt + * abgelegt und nie wieder ausgeliefert - wie das SSO-Secret. + */ +const AiSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => { + const [ai, setAi] = useState({ ...settings.ai, api_key: '' }); + const { busy, save } = useSaver(); + const { addToast } = useToast(); + const [testing, setTesting] = useState(false); + const set = (patch: Partial) => setAi({ ...ai, ...patch }); + + useEffect(() => setAi({ ...settings.ai, api_key: '' }), [settings.ai]); + + const test = async () => { + setTesting(true); + try { + const r = await settingsApi.aiTest(); + addToast(`Verbindung ok – ${r.model} antwortet: ${r.answer || 'OK'}`, 'success'); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error'); + } finally { + setTesting(false); + } + }; + + return ( +
+
+ + + + } + > +
+ + {settings.ai.enabled + ? 'Der Assistent ist aktiv – Management und Administration sehen oben rechts „Fragen“.' + : settings.ai.key_set + ? 'Schlüssel hinterlegt, der Assistent ist noch nicht eingeschaltet.' + : 'Noch kein Schlüssel hinterlegt.'} +
+ + + {/* Kein type="password" - Passwortmanager sollen hier nicht anspringen. */} + set({ api_key: e.target.value })} + placeholder={settings.ai.key_set ? '••••••••••••' : 'sk-…'} + /> + + + + set({ model: e.target.value })} /> + + + set({ enabled: v })} + label="Assistent einschalten" + hint="Zeigt den Knopf „Fragen“ im Kopf – nur für Management und Administration." + /> + +
+ Jede Frage geht zusammen mit den dafür nötigen Zahlen aus Pulse an OpenAI. Übertragen werden Kunden-, + Projekt- und Mitarbeiternamen sowie Stunden und Beträge – keine Zugangsdaten. OpenAI speichert die Anfragen + nicht (die Schnittstelle wird mit „store: false“ aufgerufen). Der Assistent liest nur und ändert nichts. +
+
+
+ ); +}; diff --git a/src/components/ui/Icon.tsx b/src/components/ui/Icon.tsx index d0b1678..bbcffad 100644 --- a/src/components/ui/Icon.tsx +++ b/src/components/ui/Icon.tsx @@ -33,6 +33,7 @@ const PATHS = { user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z', key: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', + spark: 'M12 3l1.9 4.6L18.5 9.5l-4.6 1.9L12 16l-1.9-4.6L5.5 9.5l4.6-1.9L12 3zM18 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9L18 15z', tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z', euro: 'M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z', pot: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4', diff --git a/src/lib/api.ts b/src/lib/api.ts index ff8c3fe..ee565a9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -136,6 +136,7 @@ export const settingsApi = { all: () => get('/settings'), save: (changes: Partial) => send<{ ok: true }>('PUT', '/settings', changes), ssoTest: () => send<{ ok: true; sample: string[]; checked: number; eligible: number }>('POST', '/settings/sso/test'), + aiTest: () => send<{ ok: true; model: string; answer: string }>('POST', '/settings/ai/test'), }; export interface ExportSettings { @@ -225,15 +226,35 @@ export const hrworksApi = { removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'), }; +export interface AiSettings { + enabled: boolean; + api_key: string; + key_set?: boolean; + configured?: boolean; + model: string; +} export interface AdminSettings extends PublicSettings { export: ExportSettings; sso: SsoSettings; hrworks: HrworksSettings; + ai: AiSettings; } -export type AdminSettingsInput = Omit & { +export type AdminSettingsInput = Omit & { export: Partial; sso: Partial & { clear_secret?: boolean }; hrworks: Partial & { clear_secret?: boolean }; + ai: Partial & { clear_key?: boolean }; +}; + +export interface AssistantAnswer { + answer: string; + model: string; + tools: { name: string; args: Record }[]; +} +export const assistantApi = { + status: () => get<{ available: boolean; enabled: boolean; model: string }>('/assistant/status'), + ask: (question: string, verlauf: { frage: string; antwort: string }[]) => + send('POST', '/assistant/ask', { question, verlauf }), }; export interface AdUser {