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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user