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