Files
FireAdministration/src/lib/integrations/llm.ts
T
Florian WildeandClaude Opus 5 845942f36c Phase 0: Fundament der Feuerwehr-Verwaltungsplattform
Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen.

Betrieb:
- docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS,
  Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer
  mit Mailpit und Adminer
- Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck
- .env.example mit einem Kommentar zu jeder Variable

Mandantentrennung:
- Row Level Security auf allen mandantenpflichtigen Tabellen
- Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer
  die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung
- fuerOrganisation() als einziger Weg zu Fachdaten

Nachvollziehbarkeit:
- AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE
  nur im gekennzeichneten Aufbewahrungslauf
- Geheimnisse werden vor dem Schreiben entfernt

Anmeldung:
- OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als
  Vorbelegung beim ersten Login
- Lokaler E-Mail/Passwort-Login als Rueckfallweg
- Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person)

Weiteres:
- lib/authz.ts mit 9 Rollen und 38 Einzelrechten
- lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ)
- Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces
- tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung
- Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete
- 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests
- README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog,
  Versionierungsregeln und Changelog

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 00:13:53 +02:00

107 lines
3.0 KiB
TypeScript

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<LlmAntwort>;
verfuegbar(): boolean;
}
/** Standard, solange LLM_ENABLED=false: liefert eine klare Absage. */
class AbgeschaltetesLlm implements LlmDienst {
verfuegbar(): boolean {
return false;
}
async vervollstaendigen(): Promise<LlmAntwort> {
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<LlmAntwort> {
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;
}