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.' }));