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) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-21 17:31:51 +02:00
co-authored by Claude Opus 5
parent c3aed8a3d3
commit e908db2788
11 changed files with 909 additions and 2 deletions
+89
View File
@@ -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');
}
+445
View File
@@ -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);
}
+12
View File
@@ -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)]
+40
View File
@@ -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;
+29
View File
@@ -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();
+3
View File
@@ -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.' }));
+172
View File
@@ -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 (
<>
<button
onClick={() => setOffen(true)}
title="Fragen zu den Daten stellen"
className="flex items-center gap-2 rounded-xl border border-brand-200 bg-brand-50 px-2.5 py-2 text-xs font-bold text-brand-700 transition hover:border-brand-400 hover:bg-brand-100 sm:px-3"
>
<Icon name="spark" className="h-4 w-4" />
<span className="hidden md:inline">Fragen</span>
</button>
{offen && <AssistantDialog model={status.model} onClose={() => setOffen(false)} />}
</>
);
};
const AssistantDialog: React.FC<{ model: string; onClose: () => void }> = ({ model, onClose }) => {
const [frage, setFrage] = useState('');
const [verlauf, setVerlauf] = useState<Runde[]>([]);
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const ende = useRef<HTMLDivElement>(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 (
<Modal
title="Fragen an die Daten"
subtitle={`Antworten kommen aus Pulse, gerechnet wie in den Reports · Modell ${model}`}
size="lg"
onClose={onClose}
footer={
<div className="flex w-full items-center gap-2">
<input
autoFocus
className={inputClass}
placeholder="Frage eingeben…"
value={frage}
onChange={(e) => setFrage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void fragen(frage);
}
}}
/>
<Button onClick={() => void fragen(frage)} disabled={laeuft || !frage.trim()}>
{laeuft ? 'Denkt…' : 'Fragen'}
</Button>
</div>
}
>
<div className="space-y-4">
{verlauf.length === 0 && !laeuft && (
<div className="space-y-3">
<p className="text-sm text-ink-500">
Frag nach Stunden, Umsatz, Budgets oder Auslastung. Der Assistent liest nur - er ändert nichts.
</p>
<div className="flex flex-wrap gap-2">
{BEISPIELE.map((b) => (
<button
key={b}
onClick={() => void fragen(b)}
className="rounded-xl border border-ink-200 px-3 py-2 text-left text-xs font-semibold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
>
{b}
</button>
))}
</div>
</div>
)}
{verlauf.map((runde, i) => (
<div key={i} className="space-y-2">
<p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-sm bg-brand-500 px-3.5 py-2 text-sm font-semibold text-white">
{runde.frage}
</p>
<div className="w-fit max-w-[92%] space-y-2 rounded-2xl rounded-bl-sm bg-ink-50 px-3.5 py-2.5">
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-800">{runde.antwort}</p>
{runde.werkzeuge.length > 0 && (
<p className="text-[11px] text-ink-400">
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(' · ')}
</p>
)}
</div>
</div>
))}
{laeuft && (
<p className="flex items-center gap-2 text-sm text-ink-400">
<Icon name="spark" className="h-4 w-4 animate-pulse" />
Sucht die Zahlen zusammen…
</p>
)}
{fehler && <ErrorBox message={fehler} />}
<div ref={ende} />
</div>
</Modal>
);
};
+3
View File
@@ -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<HeaderProps> = ({ title, subtitle, onToggleSidebar
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Nur Management und Administration - zeigt Zahlen ueber alle Mitarbeiter. */}
<Assistant />
<label className="sr-only" htmlFor="theme-palette">
Design
</label>
+93 -1
View File
@@ -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' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
{tab === 'hrworks' && <HrworksSettings settings={data} onSaved={reload} />}
{tab === 'ki' && <AiSettings settings={data} onSaved={reload} />}
</div>
)}
</div>
@@ -438,3 +440,93 @@ const SsoSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> =
</div>
);
};
/*
* 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<typeof ai>) => 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 (
<div className="space-y-4">
<Section
title="KI-Assistent"
hint="Fragen zu Kunden, Projekten, Budgets und Auslastung – im Kopf der Anwendung, für Management und Administration"
footer={
<>
<Button variant="secondary" icon="refresh" busy={testing} onClick={test} disabled={!settings.ai.key_set && !ai.api_key}>
Verbindung testen
</Button>
<Button icon="check" busy={busy} onClick={() => save({ ai: { ...ai, api_key: ai.api_key || undefined } }, onSaved)}>
Speichern
</Button>
</>
}
>
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${settings.ai.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'}`}>
<Icon name={settings.ai.enabled ? 'check' : 'info'} className="h-5 w-5" />
{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.'}
</div>
<Field
label="OpenAI API-Schlüssel"
hint={settings.ai.key_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Wird verschlüsselt gespeichert und nie wieder angezeigt.'}
>
{/* Kein type="password" - Passwortmanager sollen hier nicht anspringen. */}
<input
name="pulse-openai-credential"
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
className={`${inputClass} font-mono text-xs`}
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
value={ai.api_key}
onChange={(e) => set({ api_key: e.target.value })}
placeholder={settings.ai.key_set ? '••••••••••••' : 'sk-…'}
/>
</Field>
<Field label="Modell" hint="Voreingestellt gpt-5.6-luna. Ein anderes Modell muss Werkzeuge über die Responses-API unterstützen.">
<input className={`${inputClass} font-mono text-xs`} value={ai.model} onChange={(e) => set({ model: e.target.value })} />
</Field>
<Toggle
checked={ai.enabled}
onChange={(v) => set({ enabled: v })}
label="Assistent einschalten"
hint="Zeigt den Knopf „Fragen“ im Kopf – nur für Management und Administration."
/>
<div className="rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3 text-xs leading-relaxed text-ink-500">
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.
</div>
</Section>
</div>
);
};
+1
View File
@@ -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',
+22 -1
View File
@@ -136,6 +136,7 @@ export const settingsApi = {
all: () => get<AdminSettings>('/settings'),
save: (changes: Partial<AdminSettingsInput>) => 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<AdminSettings, 'export' | 'sso' | 'hrworks'> & {
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso' | 'hrworks' | 'ai'> & {
export: Partial<ExportSettings>;
sso: Partial<SsoSettings> & { clear_secret?: boolean };
hrworks: Partial<HrworksSettings> & { clear_secret?: boolean };
ai: Partial<AiSettings> & { clear_key?: boolean };
};
export interface AssistantAnswer {
answer: string;
model: string;
tools: { name: string; args: Record<string, unknown> }[];
}
export const assistantApi = {
status: () => get<{ available: boolean; enabled: boolean; model: string }>('/assistant/status'),
ask: (question: string, verlauf: { frage: string; antwort: string }[]) =>
send<AssistantAnswer>('POST', '/assistant/ask', { question, verlauf }),
};
export interface AdUser {