import "server-only"; import { env } from "@/lib/env"; /** * Sprachmodell-Anbindung hinter einer Schnittstelle. * * Vorgesehen für: Einsatzberichte aus Stichpunkten formulieren, Prüfmängel * zusammenfassen, Protokolle vorstrukturieren. Immer als Vorschlag, nie als * Entscheidung — und immer mit einem Menschen, der gegenzeichnet. * * Es wird ausschließlich gegen einen OpenAI-kompatiblen Endpunkt gesprochen, * der selbst betrieben werden kann (Ollama, vLLM, llama.cpp). Kein Cloud-Zwang, * keine Daten, die das Gerätehaus verlassen müssen. */ export interface LlmAnfrage { systemAnweisung: string; benutzerEingabe: string; maxToken?: number; temperatur?: number; } export interface LlmAntwort { erfolgreich: boolean; text?: string; fehler?: string; } export interface LlmDienst { vervollstaendigen(anfrage: LlmAnfrage): Promise; verfuegbar(): boolean; } /** Standard, solange LLM_ENABLED=false: liefert eine klare Absage. */ class AbgeschaltetesLlm implements LlmDienst { verfuegbar(): boolean { return false; } async vervollstaendigen(): Promise { return { erfolgreich: false, fehler: "Die Textunterstützung ist in dieser Installation abgeschaltet (LLM_ENABLED=false).", }; } } class OpenAiKompatiblesLlm implements LlmDienst { verfuegbar(): boolean { return Boolean(env.LLM_BASE_URL && env.LLM_MODELL); } async vervollstaendigen(anfrage: LlmAnfrage): Promise { if (!this.verfuegbar()) { return { erfolgreich: false, fehler: "LLM ist nicht vollständig konfiguriert." }; } try { const antwort = await fetch(`${env.LLM_BASE_URL}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", ...(env.LLM_API_KEY ? { Authorization: `Bearer ${env.LLM_API_KEY}` } : {}), }, body: JSON.stringify({ model: env.LLM_MODELL, messages: [ { role: "system", content: anfrage.systemAnweisung }, { role: "user", content: anfrage.benutzerEingabe }, ], max_tokens: anfrage.maxToken ?? 1024, temperature: anfrage.temperatur ?? 0.3, }), }); if (!antwort.ok) { return { erfolgreich: false, fehler: `Endpunkt antwortete mit ${antwort.status}` }; } const daten = (await antwort.json()) as { choices?: Array<{ message?: { content?: string } }>; }; const text = daten.choices?.[0]?.message?.content; return text ? { erfolgreich: true, text } : { erfolgreich: false, fehler: "Leere Antwort vom Endpunkt." }; } catch (fehler) { return { erfolgreich: false, fehler: String(fehler) }; } } } let cache: LlmDienst | null = null; export function llm(): LlmDienst { if (!cache) { cache = env.LLM_ENABLED ? new OpenAiKompatiblesLlm() : new AbgeschaltetesLlm(); } return cache; } /** Nur für Tests. */ export function _llmErsetzen(ersatz: LlmDienst | null): void { cache = ersatz; }