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');
|
||||
}
|
||||
Reference in New Issue
Block a user