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:
co-authored by
Claude Opus 5
parent
c3aed8a3d3
commit
e908db2788
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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.' }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user