Phase 8: Lokale KI

Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper,
Berichtsassistent und Belegerfassung — alles gegen selbst betriebene
Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was
Menschen getan haben.

Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede
Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich
grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und
Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal.

Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion.
Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von
vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen.

Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und
faster-whisper stehen im Compose-Profil "ki".

73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 11:06:03 +02:00
co-authored by Claude Opus 5
parent 12243f177d
commit f7ccd9a908
134 changed files with 8624 additions and 72 deletions
+329
View File
@@ -0,0 +1,329 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { Camera, Check, Trash2 } from "lucide-react";
import { KiStand } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
import { dateiHochladen } from "@/lib/hochladen";
import { KI_STAND_TEXT, KI_STAND_VARIANTE } from "@/lib/ki/texte";
import { alsDatum, alsEuro } from "@/lib/utils";
/**
* Die Belegliste.
*
* Der ganze Ablauf in einer Ansicht: fotografieren, prüfen, gegenzeichnen.
*
* Was das Modell gelesen hat, steht in **änderbaren Feldern** — genau dafür
* ist es ein Entwurf. Wer den Betrag berichtigt und dann bestätigt, hat
* gegengezeichnet, was danach dasteht, nicht was die Maschine gelesen hat.
* Beides bleibt nachvollziehbar: der KI-Vorgang daneben hält die ursprüngliche
* Antwort fest.
*
* Felder, die das Modell nicht erkannt hat, sind hervorgehoben. Ein leeres
* Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung,
* die niemand bemerkt.
*/
export function BelegListe() {
const werkzeuge = trpc.useUtils();
const [laedtHoch, setLaedtHoch] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const dateiRef = React.useRef<HTMLInputElement>(null);
const moeglichkeiten = trpc.assistent.moeglichkeiten.useQuery();
const belege = trpc.assistent.belege.useQuery({ anzahl: 50 });
const erfassen = trpc.assistent.belegErfassen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(ergebnis.hinweis);
void werkzeuge.assistent.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
onSettled: () => setLaedtHoch(false),
});
async function fotoWaehlen(datei: File) {
setMeldung(null);
setHinweis(null);
setLaedtHoch(true);
try {
const abgelegt = await dateiHochladen(datei, "Belegentwurf", null, "beleg");
erfassen.mutate({ fotoDateiId: abgelegt.id });
} catch (fehler) {
setMeldung(fehler instanceof Error ? fehler.message : String(fehler));
setLaedtHoch(false);
}
}
const offen = belege.data?.filter((beleg) => beleg.stand === KiStand.ENTWURF) ?? [];
const erledigt = belege.data?.filter((beleg) => beleg.stand !== KiStand.ENTWURF) ?? [];
return (
<div className="space-y-6">
{!moeglichkeiten.data?.bild && !moeglichkeiten.isLoading && (
<KiFehltHinweis
was="Es ist kein Modell eingerichtet, das Bilder lesen kann (LLM_VISION_MODELL)."
ohneGeht="Belege lassen sich trotzdem fotografieren und ablegen — die Angaben werden dann von Hand eingetragen."
/>
)}
{meldung && <Fehlermeldung meldung={meldung} kennung="belegfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<input
ref={dateiRef}
type="file"
accept="image/*"
// capture öffnet auf dem Handy direkt die Kamera — genau das will
// jemand, der mit einer Quittung in der Hand dasteht.
capture="environment"
className="hidden"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void fotoWaehlen(datei);
ereignis.target.value = "";
}}
/>
<Button onClick={() => dateiRef.current?.click()} disabled={laedtHoch}>
<Camera aria-hidden />
{laedtHoch ? "Wird gelesen …" : "Beleg fotografieren"}
</Button>
<span className="text-sm text-[var(--muted-foreground)]">
{offen.length} {offen.length === 1 ? "Beleg wartet" : "Belege warten"} auf
Gegenzeichnung.
</span>
</div>
{offen.length === 0 && erledigt.length === 0 && !belege.isLoading && (
<NichtsGefunden
titel="Noch keine Belege"
hinweis="Fotografieren Sie eine Quittung — die Angaben werden ausgelesen und zum Prüfen vorgelegt."
/>
)}
{offen.length > 0 && (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Zu prüfen</h2>
{offen.map((beleg) => (
<BelegKarte key={beleg.id} beleg={beleg} fehler={setMeldung} />
))}
</section>
)}
{erledigt.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold">Erledigt</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="py-2 pr-4">Datum</th>
<th className="py-2 pr-4">Händler</th>
<th className="py-2 pr-4">Kategorie</th>
<th className="py-2 pr-4 text-right">Betrag</th>
<th className="py-2 pr-4">Stand</th>
<th className="py-2">Gegengezeichnet</th>
</tr>
</thead>
<tbody>
{erledigt.map((beleg) => (
<tr key={beleg.id} className="border-b border-[var(--border)]">
<td className="py-2 pr-4">{alsDatum(beleg.belegdatum)}</td>
<td className="py-2 pr-4">{beleg.haendler ?? "—"}</td>
<td className="py-2 pr-4">{beleg.kategorie ?? "—"}</td>
<td className="py-2 pr-4 text-right tabular-nums">
{beleg.betrag === null ? "—" : alsEuro(Number(beleg.betrag))}
</td>
<td className="py-2 pr-4">
<Badge variant={KI_STAND_VARIANTE[beleg.stand]}>
{KI_STAND_TEXT[beleg.stand]}
</Badge>
</td>
<td className="py-2">
{beleg.bestaetigtVon
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}
/** Ein Beleg, wie der Router ihn liefert — direkt von dort abgeleitet. */
type Beleg = inferRouterOutputs<AppRouter>["assistent"]["belege"][number];
/**
* Ein Beleg zum Gegenzeichnen.
*
* Foto links, Felder rechts. Das Foto ist nicht Zierde: es ist das, wogegen
* geprüft wird, und muss beim Prüfen sichtbar sein — nicht hinter einem Klick.
*/
function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: string) => void }) {
const werkzeuge = trpc.useUtils();
const [betrag, setBetrag] = React.useState(beleg.betrag === null ? "" : String(beleg.betrag));
const [datum, setDatum] = React.useState(
beleg.belegdatum ? new Date(beleg.belegdatum).toISOString().slice(0, 10) : "",
);
const [haendler, setHaendler] = React.useState(beleg.haendler ?? "");
const [kategorie, setKategorie] = React.useState(beleg.kategorie ?? "");
const bestaetigen = trpc.assistent.belegBestaetigen.useMutation({
onSuccess: () => void werkzeuge.assistent.invalidate(),
onError: (aufruf) => fehler(aufruf.message),
});
const verwerfen = trpc.assistent.belegVerwerfen.useMutation({
onSuccess: () => void werkzeuge.assistent.invalidate(),
onError: (aufruf) => fehler(aufruf.message),
});
/** Ein Feld, das das Modell nicht gefüllt hat, wird hervorgehoben. */
const lueckeKlasse = (wert: string) =>
wert.trim() ? "" : "border-[var(--pruefung-faellig)] bg-[var(--pruefung-faellig)]/5";
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">
{beleg.haendler ?? "Beleg ohne erkannten Händler"}
</CardTitle>
{beleg.kiVorgangId && <KiAbzeichen />}
</div>
<CardDescription>
Erfasst am {alsDatum(beleg.erstelltAm)}
{beleg.erfasstVon && ` von ${beleg.erfasstVon.vorname} ${beleg.erfasstVon.nachname}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
<a
href={`/api/dateien/${beleg.fotoDatei.id}`}
target="_blank"
rel="noreferrer"
className="block"
>
<Image
src={`/api/dateien/${beleg.fotoDatei.id}`}
alt="Belegfoto"
width={240}
height={320}
unoptimized
className="h-auto w-full rounded-md border border-[var(--border)] object-contain"
/>
</a>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`betrag-${beleg.id}`}>Betrag</Label>
<Input
id={`betrag-${beleg.id}`}
type="number"
step="0.01"
min="0"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
className={lueckeKlasse(betrag)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`datum-${beleg.id}`}>Belegdatum</Label>
<Input
id={`datum-${beleg.id}`}
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
className={lueckeKlasse(datum)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`haendler-${beleg.id}`}>Händler</Label>
<Input
id={`haendler-${beleg.id}`}
value={haendler}
onChange={(ereignis) => setHaendler(ereignis.target.value)}
className={lueckeKlasse(haendler)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`kategorie-${beleg.id}`}>Kategorie</Label>
<Input
id={`kategorie-${beleg.id}`}
value={kategorie}
onChange={(ereignis) => setKategorie(ereignis.target.value)}
placeholder="Kraftstoff, Verpflegung, Material"
className={lueckeKlasse(kategorie)}
/>
</div>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
Gelb hinterlegte Felder hat die Maschine nicht erkannt — bitte gegen das Foto prüfen
und eintragen. Mit dem Gegenzeichnen übernehmen Sie, was hier steht.
</p>
<div className="flex flex-wrap gap-2 border-t border-[var(--border)] pt-3">
<Button
size="sm"
onClick={() =>
bestaetigen.mutate({
id: beleg.id,
betrag: betrag ? Number(betrag) : null,
waehrung: beleg.waehrung,
belegdatum: datum ? new Date(datum) : null,
haendler: haendler || null,
kategorie: kategorie || null,
steuersatz: beleg.steuersatz === null ? null : Number(beleg.steuersatz),
})
}
disabled={!betrag || bestaetigen.isPending}
>
<Check aria-hidden />
Gegenzeichnen
</Button>
<Button
size="sm"
variant="outline"
onClick={() => verwerfen.mutate({ id: beleg.id })}
disabled={verwerfen.isPending}
>
<Trash2 aria-hidden />
Verwerfen
</Button>
</div>
</CardContent>
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { BelegListe } from "@/app/(app)/belege/beleg-liste";
export const metadata = { title: "Belege" };
/**
* Belegerfassung.
*
* Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus
* kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber
* erfasster Beleg mit Foto.
*/
export default async function BelegeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "beleg.erfassen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Quittung fotografieren, Angaben prüfen, gegenzeichnen.
</p>
</div>
<BelegListe />
</div>
);
}
@@ -39,6 +39,7 @@ import { FUNKTION_TEXT, QUELLE_TEXT } from "@/lib/einsatz/texte";
import { ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
import { taktischeEinheit } from "@/lib/einsatz/staerke";
import { Berichtsassistent } from "@/components/ki/berichtsassistent";
/**
* Einsatzdetail — die Arbeitsfläche während und nach dem Einsatz.
@@ -678,6 +679,23 @@ export function EinsatzDetail({
>
Bericht speichern
</Button>
{/* Phase 8: Diktat und Entwurf. Was hier herauskommt, landet im
Feld darüber — gespeichert wird es erst, wenn jemand
speichert. Zwei Entscheidungen, nicht eine. */}
<div className="border-t border-[var(--border)] pt-4">
<Berichtsassistent
einsatzId={einsatzId}
onUebernehmen={(text) =>
setBericht((bisher) => {
const vorhanden = bisher ?? einsatz.bericht ?? "";
return vorhanden.trim() ? `${vorhanden.trim()}
${text}` : text;
})
}
/>
</div>
</>
) : (
<p className="whitespace-pre-line text-sm">
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { KiProtokoll } from "@/app/(app)/ki-protokoll/protokoll";
export const metadata = { title: "KI-Protokoll" };
/**
* Was die Maschine getan hat.
*
* Die Antwort auf die Frage, die irgendwann jemand stellt — in der
* Jahreshauptversammlung, beim Datenschutzbeauftragten der Gemeinde, oder
* wenn ein Bericht angezweifelt wird: **Was macht das Ding eigentlich?**
*
* Hier steht es. Jede Befragung, jedes Modell, jede Entscheidung, mit Namen
* und Zeitpunkt.
*/
export default async function KiProtokollSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "ki.protokoll")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">KI-Protokoll</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Jede Befragung eines Modells, was dabei herauskam und wer darüber entschieden hat.
</p>
</div>
<KiProtokoll />
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import * as React from "react";
import { KiArt, KiStand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KI_ART_TEXT, KI_STAND_TEXT, KI_STAND_VARIANTE } from "@/lib/ki/texte";
import { alsDatumZeit } from "@/lib/utils";
/**
* Das KI-Protokoll.
*
* Aufgebaut wie ein Kassenbuch und nicht wie ein Logfile: oben die Summen,
* darunter die Einzelposten. Wer hierherkommt, will meistens erst wissen, wie
* viel überhaupt läuft — und danach eine bestimmte Zeile.
*
* Die Zeile mit den offenen Vorschlägen ist die wichtigste: sie zeigt, wie
* viele Ausgaben erzeugt und nie angesehen wurden. Steht dort eine große Zahl,
* benutzt jemand den Assistenten und liest nicht gegen.
*/
export function KiProtokoll() {
const [art, setArt] = React.useState<KiArt | "alle">("alle");
const [stand, setStand] = React.useState<KiStand | "alle">("alle");
const [aufgeklappt, setAufgeklappt] = React.useState<string | null>(null);
const kennzahlen = trpc.assistent.kennzahlen.useQuery();
const vorgaenge = trpc.assistent.protokoll.useQuery({
art: art === "alle" ? undefined : art,
stand: stand === "alle" ? undefined : stand,
anzahl: 100,
});
return (
<div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<Kennzahl titel="Vorgänge insgesamt" wert={kennzahlen.data?.gesamt} />
<Kennzahl
titel="Offen"
wert={kennzahlen.data?.offen}
hinweis="erzeugt, aber nicht entschieden"
hervorheben={(kennzahlen.data?.offen ?? 0) > 0}
/>
<Kennzahl titel="Übernommen" wert={kennzahlen.data?.uebernommen} />
<Kennzahl titel="Verworfen" wert={kennzahlen.data?.verworfen} />
<Kennzahl titel="Fehlgeschlagen" wert={kennzahlen.data?.fehlgeschlagen} />
</div>
<div className="flex flex-wrap gap-3">
<Select value={art} onValueChange={(wert) => setArt(wert as KiArt | "alle")}>
<SelectTrigger className="w-64">
<SelectValue placeholder="Alle Arten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Arten</SelectItem>
{Object.values(KiArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{KI_ART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as KiStand | "alle")}>
<SelectTrigger className="w-52">
<SelectValue placeholder="Alle Zustände" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Zustände</SelectItem>
{Object.values(KiStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{KI_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!vorgaenge.isLoading && (vorgaenge.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch nichts"
hinweis="Sobald jemand den Assistenten benutzt, steht hier, was geschehen ist."
/>
)}
<ul className="space-y-2">
{vorgaenge.data?.map((vorgang) => (
<li key={vorgang.id} className="rounded-lg border border-[var(--border)] bg-[var(--card)]">
<button
type="button"
onClick={() => setAufgeklappt(aufgeklappt === vorgang.id ? null : vorgang.id)}
className="flex w-full flex-wrap items-center gap-3 p-4 text-left hover:bg-[var(--accent)]"
aria-expanded={aufgeklappt === vorgang.id}
>
<Badge variant={KI_STAND_VARIANTE[vorgang.stand]}>
{KI_STAND_TEXT[vorgang.stand]}
</Badge>
<span className="font-medium">{KI_ART_TEXT[vorgang.art]}</span>
<span className="text-sm text-[var(--muted-foreground)]">
{alsDatumZeit(vorgang.erstelltAm)} · {vorgang.modell}
{vorgang.dauerMs !== null && ` · ${(vorgang.dauerMs / 1000).toFixed(1)} s`}
</span>
<span className="ml-auto text-sm text-[var(--muted-foreground)]">
{vorgang.erzeugtVon
? `${vorgang.erzeugtVon.vorname} ${vorgang.erzeugtVon.nachname}`
: "Hintergrundprozess"}
{vorgang.entschiedenVon &&
` → ${vorgang.entschiedenVon.vorname} ${vorgang.entschiedenVon.nachname}`}
</span>
</button>
{aufgeklappt === vorgang.id && (
<div className="space-y-3 border-t border-[var(--border)] p-4 text-sm">
<div>
<p className="font-medium">Eingabe</p>
<p className="mt-1 whitespace-pre-wrap text-[var(--muted-foreground)]">
{vorgang.eingabe}
</p>
</div>
{vorgang.ausgabe && (
<div>
<p className="font-medium">Ausgabe des Modells</p>
<p className="mt-1 whitespace-pre-wrap text-[var(--muted-foreground)]">
{vorgang.ausgabe}
</p>
</div>
)}
{vorgang.fehler && (
<div>
<p className="font-medium text-[var(--destructive)]">Fehler</p>
<p className="mt-1 text-[var(--destructive)]">{vorgang.fehler}</p>
</div>
)}
<dl className="grid gap-2 text-xs text-[var(--muted-foreground)] sm:grid-cols-3">
<div>
<dt>Betrifft</dt>
<dd>{vorgang.entitaet ?? "—"}</dd>
</div>
<div>
<dt>Token (Ein/Aus)</dt>
<dd>
{vorgang.tokenEingabe ?? "—"} / {vorgang.tokenAusgabe ?? "—"}
</dd>
</div>
<div>
<dt>Entschieden</dt>
<dd>
{vorgang.entschiedenAm ? alsDatumZeit(vorgang.entschiedenAm) : "noch offen"}
</dd>
</div>
</dl>
</div>
)}
</li>
))}
</ul>
<p className="text-xs text-[var(--muted-foreground)]">
Eingabe, Ausgabe und Modell lassen sich nachträglich nicht ändern — die Datenbank hält
dagegen. Ein Vorgang lässt sich auch nicht löschen. Das ist der Zweck dieser Liste: sie
soll belegen, was war, nicht was jemand gern hätte.
</p>
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
hervorheben = false,
}: {
titel: string;
wert: number | undefined;
hinweis?: string;
hervorheben?: boolean;
}) {
return (
<Card className={hervorheben ? "border-[var(--pruefung-faellig)]" : undefined}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">{wert ?? "—"}</p>
{hinweis && <CardDescription className="mt-1">{hinweis}</CardDescription>}
</CardContent>
</Card>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import { AuditAktion } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
import { alsDatumZeit } from "@/lib/utils";
/**
* Das Audit-Log lesen.
*
* Eine Zeile je Handlung, aufklappbar auf Vorher/Nachher. Mehr braucht es
* nicht: das Protokoll wird nicht durchgearbeitet, sondern befragt — „wer hat
* dieses Gerät ausgesondert", „wann wurde die PIN geändert".
*
* Deshalb ist der Filter nach Entität wichtiger als eine Volltextsuche. Wer
* eine Frage an das Protokoll hat, kennt fast immer die Art des Datensatzes.
*/
const AKTION_TEXT: Record<AuditAktion, string> = {
ERSTELLT: "angelegt",
GEAENDERT: "geändert",
GELOESCHT: "gelöscht",
WIEDERHERGESTELLT: "wiederhergestellt",
ANGEMELDET: "angemeldet",
ABGEMELDET: "abgemeldet",
ANMELDUNG_FEHLGESCHLAGEN: "Anmeldung fehlgeschlagen",
EXPORTIERT: "exportiert",
DATEI_HOCHGELADEN: "Datei hochgeladen",
DATEI_ABGERUFEN: "Datei abgerufen",
BERECHTIGUNG_GEAENDERT: "Berechtigung geändert",
};
const AKTION_VARIANTE: Record<AuditAktion, "einsatzbereit" | "gesperrt" | "secondary" | "outline"> =
{
ERSTELLT: "einsatzbereit",
GEAENDERT: "outline",
GELOESCHT: "gesperrt",
WIEDERHERGESTELLT: "einsatzbereit",
ANGEMELDET: "secondary",
ABGEMELDET: "secondary",
ANMELDUNG_FEHLGESCHLAGEN: "gesperrt",
EXPORTIERT: "outline",
DATEI_HOCHGELADEN: "outline",
DATEI_ABGERUFEN: "secondary",
BERECHTIGUNG_GEAENDERT: "gesperrt",
};
export function Auditansicht() {
const [entitaet, setEntitaet] = React.useState("");
const [anzahl, setAnzahl] = React.useState(50);
const [aufgeklappt, setAufgeklappt] = React.useState<string | null>(null);
const eintraege = trpc.audit.letzte.useQuery({
anzahl,
entitaet: entitaet.trim() || undefined,
});
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<Input
value={entitaet}
onChange={(ereignis) => setEntitaet(ereignis.target.value)}
placeholder="Nach Art filtern: Geraet, Person, Einsatz, KiVorgang …"
className="min-w-64 flex-1"
aria-label="Nach Entität filtern"
/>
<Select value={String(anzahl)} onValueChange={(wert) => setAnzahl(Number(wert))}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[50, 100, 200].map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert} Einträge
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!eintraege.isLoading && (eintraege.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Keine Einträge"
hinweis={
entitaet
? `Zu „${entitaet}" ist nichts protokolliert. Die Schreibweise muss genau stimmen — etwa „Geraet" statt „Gerät".`
: "Das Protokoll ist leer."
}
/>
)}
<ul className="space-y-2">
{eintraege.data?.map((eintrag) => (
<li key={eintrag.id} className="rounded-lg border border-[var(--border)] bg-[var(--card)]">
<button
type="button"
onClick={() => setAufgeklappt(aufgeklappt === eintrag.id ? null : eintrag.id)}
className="flex w-full flex-wrap items-center gap-3 p-3 text-left hover:bg-[var(--accent)]"
aria-expanded={aufgeklappt === eintrag.id}
>
<Badge variant={AKTION_VARIANTE[eintrag.aktion]}>
{AKTION_TEXT[eintrag.aktion]}
</Badge>
<span className="font-medium">{eintrag.entitaet}</span>
<span className="min-w-0 flex-1 truncate text-sm text-[var(--muted-foreground)]">
{eintrag.beschreibung ?? "—"}
</span>
<span className="text-sm text-[var(--muted-foreground)]">
{alsDatumZeit(eintrag.zeitpunkt)}
</span>
<span className="text-sm text-[var(--muted-foreground)]">
{eintrag.akteur
? `${eintrag.akteur.vorname} ${eintrag.akteur.nachname}`
: (eintrag.akteurName ?? eintrag.akteurTyp)}
</span>
</button>
{aufgeklappt === eintrag.id && (
<div className="grid gap-3 border-t border-[var(--border)] p-3 text-xs md:grid-cols-2">
<div>
<p className="font-medium">Vorher</p>
<pre className="mt-1 overflow-x-auto rounded bg-[var(--muted)] p-2">
{eintrag.vorher ? JSON.stringify(eintrag.vorher, null, 2) : "—"}
</pre>
</div>
<div>
<p className="font-medium">Nachher</p>
<pre className="mt-1 overflow-x-auto rounded bg-[var(--muted)] p-2">
{eintrag.nachher ? JSON.stringify(eintrag.nachher, null, 2) : "—"}
</pre>
</div>
</div>
)}
</li>
))}
</ul>
<p className="text-xs text-[var(--muted-foreground)]">
Das Protokoll ist append-only: Ändern ist dauerhaft gesperrt, Löschen nur im
Aufbewahrungslauf des Hintergrundprozesses. Ohne diese Ausnahme wäre das Löschkonzept
nach DSGVO nicht umsetzbar.
</p>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Auditansicht } from "@/app/(app)/protokoll/auditansicht";
export const metadata = { title: "Protokoll" };
/**
* Das Audit-Log.
*
* Geschrieben wird es seit Phase 0, angesehen werden konnte es bisher nur in
* der Datenbank. Mit dem KI-Protokoll daneben wäre das schief geworden: die
* Wehr könnte nachlesen, was die Maschine getan hat, aber nicht, was Menschen
* getan haben. Deshalb hier die Ansicht dazu.
*
* Bewusst nur lesend. Das Protokoll ist append-only — die Datenbank lässt
* weder Ändern noch Löschen zu, außer im Aufbewahrungslauf des Workers.
*/
export default async function ProtokollSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "system.auditlog")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Protokoll</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Wer hat wann was geändert. Jede schreibende Handlung in FLORIAN steht hier.
</p>
</div>
<Auditansicht />
</div>
);
}
+449
View File
@@ -0,0 +1,449 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, History, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { WikiKategorie } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { Markdown } from "@/components/ki/markdown";
import { WIKI_KATEGORIE_TEXT } from "@/lib/ki/texte";
import { alsDatumZeit } from "@/lib/utils";
/**
* Eine Wiki-Seite.
*
* Der Text steht im Vordergrund, alles andere daneben. Wer eine
* Dienstvorschrift aufschlägt, will sie lesen und nicht erst an einer
* Werkzeugleiste vorbei.
*
* Zwei Hinweise, die selten auffallen und trotzdem wichtig sind: dass eine
* Seite ein Entwurf ist (dann wird sie nicht beantwortet), und dass ihr
* Suchstand veraltet ist (dann findet die semantische Suche noch den alten
* Text). Beides steht sichtbar über dem Inhalt.
*/
export function WikiSeitenansicht({
slug,
darfBearbeiten,
}: {
slug: string;
darfBearbeiten: boolean;
}) {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [bearbeiten, setBearbeiten] = React.useState(false);
const [verlaufOffen, setVerlaufOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const seite = trpc.wiki.einzeln.useQuery({ slug });
const neuEinbetten = trpc.wiki.neuEinbetten.useMutation({
onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis),
onError: (fehler) => setMeldung(fehler.message),
});
const loeschen = trpc.wiki.loeschen.useMutation({
onSuccess: () => {
void werkzeuge.wiki.invalidate();
router.push("/wiki");
},
onError: (fehler) => setMeldung(fehler.message),
});
if (seite.isLoading) {
return <div className="h-64 animate-pulse rounded-lg bg-[var(--muted)]" aria-hidden />;
}
if (seite.error || !seite.data) {
return <Fehlermeldung meldung={seite.error?.message ?? "Seite nicht gefunden."} />;
}
const daten = seite.data;
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild className="nicht-drucken">
<Link href="/wiki">
<ArrowLeft aria-hidden />
Zurück zum Wissen
</Link>
</Button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{daten.titel}</h1>
<Badge variant="outline">{WIKI_KATEGORIE_TEXT[daten.art]}</Badge>
{!daten.veroeffentlicht && <Badge variant="secondary">Entwurf</Badge>}
</div>
{daten.quelle && (
<p className="text-sm text-[var(--muted-foreground)]">Fundstelle: {daten.quelle}</p>
)}
<p className="text-xs text-[var(--muted-foreground)]">
Zuletzt geändert am {alsDatumZeit(daten.geaendertAm)}
{daten.autor && ` von ${daten.autor.vorname} ${daten.autor.nachname}`}
{daten.versionen.length > 0 && ` · ${daten.versionen.length} frühere Fassungen`}
</p>
</div>
<div className="flex flex-wrap gap-2 nicht-drucken">
{daten.versionen.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setVerlaufOffen(true)}>
<History aria-hidden />
Verlauf
</Button>
)}
{darfBearbeiten && (
<>
<Button variant="outline" size="sm" onClick={() => setBearbeiten(true)}>
<Pencil aria-hidden />
Bearbeiten
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => loeschen.mutate({ id: daten.id })}
aria-label="Seite löschen"
>
<Trash2 aria-hidden />
</Button>
</>
)}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="wikifehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{!daten.veroeffentlicht && (
<div
role="note"
className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm"
>
Diese Seite ist ein <strong>Entwurf</strong>. Sie taucht nicht in der Suche auf und wird
bei Fragen nicht herangezogen.
</div>
)}
{daten.veroeffentlicht && daten.suchstandVeraltet && (
<div
role="note"
className="flex flex-wrap items-center gap-3 rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm"
>
<span className="flex-1">
Der Suchstand ist älter als der Text. Bis die Seite neu eingebettet ist, findet die
Frage-Funktion noch die vorherige Fassung — gelesen wird hier natürlich die aktuelle.
</span>
{darfBearbeiten && (
<Button
size="sm"
variant="outline"
onClick={() => neuEinbetten.mutate({ id: daten.id })}
disabled={neuEinbetten.isPending}
>
<RefreshCw aria-hidden />
Jetzt nachziehen
</Button>
)}
</div>
)}
<Card>
<CardContent className="pt-6">
<Markdown text={daten.inhalt} />
</CardContent>
</Card>
{daten.schlagworte.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{daten.schlagworte.map((wort) => (
<Badge key={wort} variant="secondary">
{wort}
</Badge>
))}
</div>
)}
<p className="text-xs text-[var(--muted-foreground)]">
{daten.abschnitte} {daten.abschnitte === 1 ? "Abschnitt" : "Abschnitte"} für die Suche
{daten.eingebettetAm && ` · zuletzt eingebettet am ${alsDatumZeit(daten.eingebettetAm)}`}
</p>
{darfBearbeiten && (
<SeiteBearbeiten
offen={bearbeiten}
seite={daten}
schliessen={() => setBearbeiten(false)}
fehler={setMeldung}
fertig={() => {
setBearbeiten(false);
void werkzeuge.wiki.invalidate();
}}
/>
)}
<VerlaufDialog
offen={verlaufOffen}
schliessen={() => setVerlaufOffen(false)}
versionen={daten.versionen}
/>
</div>
);
}
/** Die Seite, wie der Router sie liefert — direkt von dort abgeleitet. */
type Seite = inferRouterOutputs<AppRouter>["wiki"]["einzeln"];
function SeiteBearbeiten({
offen,
seite,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
seite: Seite;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [titel, setTitel] = React.useState(seite.titel);
const [art, setArt] = React.useState<WikiKategorie>(seite.art);
const [quelle, setQuelle] = React.useState(seite.quelle ?? "");
const [inhalt, setInhalt] = React.useState(seite.inhalt);
const [veroeffentlicht, setVeroeffentlicht] = React.useState(seite.veroeffentlicht);
const [bemerkung, setBemerkung] = React.useState("");
// Beim Öffnen den aktuellen Stand übernehmen — sonst steht dort der Text von
// vor dem letzten Speichern.
React.useEffect(() => {
if (!offen) return;
setTitel(seite.titel);
setArt(seite.art);
setQuelle(seite.quelle ?? "");
setInhalt(seite.inhalt);
setVeroeffentlicht(seite.veroeffentlicht);
setBemerkung("");
}, [offen, seite]);
const vorschau = trpc.wiki.vorschauAbschnitte.useQuery({ inhalt }, { enabled: offen });
const aendern = trpc.wiki.aendern.useMutation({
onSuccess: fertig,
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Seite bearbeiten</DialogTitle>
<DialogDescription>
Die bisherige Fassung wird aufgehoben. Eine Dienstvorschrift, die sich unbemerkt
ändert, ist schlimmer als keine.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bearbeiten-titel">Titel</Label>
<Input
id="bearbeiten-titel"
value={titel}
onChange={(ereignis) => setTitel(ereignis.target.value)}
/>
<p className="text-xs text-[var(--muted-foreground)]">
Die Adresse der Seite (<code>/wiki/{seite.slug}</code>) bleibt gleich, damit
Verweise und Lesezeichen weiter stimmen.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bearbeiten-art">Bereich</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as WikiKategorie)}>
<SelectTrigger id="bearbeiten-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(WikiKategorie).map((wert) => (
<SelectItem key={wert} value={wert}>
{WIKI_KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="bearbeiten-quelle">Fundstelle</Label>
<Input
id="bearbeiten-quelle"
value={quelle}
onChange={(ereignis) => setQuelle(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bearbeiten-inhalt">Inhalt</Label>
<Textarea
id="bearbeiten-inhalt"
rows={18}
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="font-mono text-sm"
/>
{vorschau.data && (
<p className="text-xs text-[var(--muted-foreground)]">
Wird für die Suche in {vorschau.data.anzahl}{" "}
{vorschau.data.anzahl === 1 ? "Abschnitt" : "Abschnitte"} zerlegt — an den
Überschriften.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="bearbeiten-bemerkung">Was wurde geändert? (freiwillig)</Label>
<Input
id="bearbeiten-bemerkung"
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
placeholder="Prüffrist nach neuer DGUV-Information angepasst"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={veroeffentlicht}
onCheckedChange={(wert) => setVeroeffentlicht(wert === true)}
/>
Veröffentlicht
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
aendern.mutate({
id: seite.id,
titel,
art,
inhalt,
quelle: quelle || null,
veroeffentlicht,
bemerkung: bemerkung || null,
})
}
disabled={!titel.trim() || aendern.isPending}
>
{aendern.isPending ? "Wird gespeichert …" : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Die früheren Fassungen im Wortlaut. */
function VerlaufDialog({
offen,
schliessen,
versionen,
}: {
offen: boolean;
schliessen: () => void;
versionen: Seite["versionen"];
}) {
const [gewaehlt, setGewaehlt] = React.useState<string | null>(null);
const fassung = trpc.wiki.fassung.useQuery(
{ id: gewaehlt ?? "" },
{ enabled: offen && Boolean(gewaehlt) },
);
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Frühere Fassungen</DialogTitle>
<DialogDescription>
Bei jeder Änderung wird der vorherige Text vollständig aufgehoben.
</DialogDescription>
</DialogHeader>
<ul className="space-y-2">
{versionen.map((version) => (
<li key={version.id}>
<button
type="button"
onClick={() => setGewaehlt(gewaehlt === version.id ? null : version.id)}
className="w-full rounded-md border border-[var(--border)] p-3 text-left text-sm hover:bg-[var(--accent)]"
>
<span className="font-medium">Fassung {version.nummer}</span>
<span className="text-[var(--muted-foreground)]">
{" "}
· {alsDatumZeit(version.erstelltAm)}
{version.autor && ` · ${version.autor.nachname}`}
</span>
{version.bemerkung && (
<span className="block text-[var(--muted-foreground)]">{version.bemerkung}</span>
)}
</button>
{gewaehlt === version.id && fassung.data && (
<div className="mt-2 rounded-md border border-[var(--border)] bg-[var(--muted)] p-4">
<p className="mb-2 text-sm font-medium">{fassung.data.titel}</p>
<Markdown text={fassung.data.inhalt} />
</div>
)}
</li>
))}
</ul>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Schließen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { WikiSeitenansicht } from "@/app/(app)/wiki/[slug]/ansicht";
export const metadata = { title: "Wiki-Seite" };
export default async function WikiDetailSeite({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { slug } = await params;
return <WikiSeitenansicht slug={slug} darfBearbeiten={hatRecht(sitzung, "wiki.bearbeiten")} />;
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { WikiUebersicht } from "@/app/(app)/wiki/uebersicht";
export const metadata = { title: "Wissen" };
/**
* Das Wiki der Wehr.
*
* Dienstvorschriften, Ausbildungsunterlagen, Merkblätter — das, was sonst als
* PDF im Ordner steht und im Einsatz niemand findet.
*/
export default async function WikiSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Wissen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Dienstvorschriften, Ausbildungsunterlagen und Merkblätter der Wehr — durchsuchbar.
</p>
</div>
<WikiUebersicht darfBearbeiten={hatRecht(sitzung, "wiki.bearbeiten")} />
</div>
);
}
+492
View File
@@ -0,0 +1,492 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { BookPlus, Search, Sparkles } from "lucide-react";
import { WikiKategorie } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KiFehltHinweis, KiVorschlag } from "@/components/ki/kennzeichnung";
import { WIKI_KATEGORIE_TEXT } from "@/lib/ki/texte";
import { alsDatum } from "@/lib/utils";
/**
* Wiki-Übersicht mit beiden Suchen.
*
* Oben die Frage, darunter die Liste. Die Reihenfolge ist Absicht: wer
* hierherkommt, hat meistens eine Frage und nicht den Wunsch, eine Liste zu
* lesen.
*
* Die Wortsuche läuft immer und sofort mit. Die Frage ans Modell kostet ein
* paar Sekunden und wird deshalb ausdrücklich abgeschickt — und liefert immer
* beides: die Antwort und die Auszüge, aus denen sie gebaut wurde.
*/
export function WikiUebersicht({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [begriff, setBegriff] = React.useState("");
const [art, setArt] = React.useState<WikiKategorie | "alle">("alle");
const [mitEntwuerfen, setMitEntwuerfen] = React.useState(false);
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const moeglichkeiten = trpc.assistent.moeglichkeiten.useQuery();
const seiten = trpc.wiki.liste.useQuery({
suche: begriff.trim() || undefined,
art: art === "alle" ? undefined : art,
mitEntwuerfen,
});
const treffer = trpc.wiki.suchen.useQuery(
{ begriff: begriff.trim() },
{ enabled: begriff.trim().length >= 2 },
);
return (
<div className="space-y-6">
<Frage verfuegbar={moeglichkeiten.data?.text ?? false} />
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-64 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={begriff}
onChange={(ereignis) => setBegriff(ereignis.target.value)}
placeholder="Wortsuche: FwDV 7, Ablegereife, Schlauchpflege …"
className="pl-9"
aria-label="Wortsuche im Wiki"
/>
</div>
<Select value={art} onValueChange={(wert) => setArt(wert as WikiKategorie | "alle")}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Alle Bereiche" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Bereiche</SelectItem>
{Object.values(WikiKategorie).map((wert) => (
<SelectItem key={wert} value={wert}>
{WIKI_KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={mitEntwuerfen}
onCheckedChange={(wert) => setMitEntwuerfen(wert === true)}
/>
Entwürfe zeigen
</label>
<Button onClick={() => setAnlegenOffen(true)}>
<BookPlus aria-hidden />
Seite anlegen
</Button>
</>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="wikifehler" />}
{/* Wortsuche: Textstellen statt bloßer Seitennamen. */}
{begriff.trim().length >= 2 && treffer.data && treffer.data.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Wortsuche: {treffer.data.length} {treffer.data.length === 1 ? "Stelle" : "Stellen"}
</CardTitle>
<CardDescription>Was wörtlich in den Unterlagen steht.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{treffer.data.map((eintrag) => (
<div key={eintrag.id} className="border-b border-[var(--border)] pb-3 last:border-0">
<Link
href={`/wiki/${eintrag.slug}`}
className="font-medium underline-offset-4 hover:underline"
>
{eintrag.titel}
</Link>
<Badge variant="outline" className="ml-2">
{WIKI_KATEGORIE_TEXT[eintrag.art]}
</Badge>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">{eintrag.ausschnitt}</p>
</div>
))}
</CardContent>
</Card>
)}
{seiten.isLoading && (
<ul className="space-y-3" aria-hidden>
{Array.from({ length: 4 }).map((_, zeile) => (
<li
key={zeile}
className="h-20 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
/>
))}
</ul>
)}
{!seiten.isLoading && (seiten.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Seiten"
hinweis={
darfBearbeiten
? "Legen Sie an, was die Wehr nachschlagen soll: Dienstvorschriften, Merkblätter, Ausbildungsunterlagen."
: "In diesem Bereich ist noch nichts hinterlegt."
}
/>
)}
<ul className="grid gap-3 md:grid-cols-2">
{seiten.data?.map((seite) => (
<li
key={seite.id}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/wiki/${seite.slug}`}
className="font-medium underline-offset-4 hover:underline"
>
{seite.titel}
</Link>
<Badge variant="outline">{WIKI_KATEGORIE_TEXT[seite.art]}</Badge>
{!seite.veroeffentlicht && <Badge variant="secondary">Entwurf</Badge>}
</div>
{seite.quelle && (
<p className="mt-1 text-sm text-[var(--muted-foreground)]">{seite.quelle}</p>
)}
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
Zuletzt geändert am {alsDatum(seite.geaendertAm)}
{seite.autor && ` von ${seite.autor.nachname}`}
</p>
{seite.schlagworte.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{seite.schlagworte.slice(0, 6).map((wort) => (
<Badge key={wort} variant="secondary" className="text-xs">
{wort}
</Badge>
))}
</div>
)}
</li>
))}
</ul>
{darfBearbeiten && (
<SeiteAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={(slug) => {
setAnlegenOffen(false);
void werkzeuge.wiki.invalidate();
router.push(`/wiki/${slug}`);
}}
/>
)}
</div>
);
}
/**
* Die Frage ans Wiki.
*
* Zeigt immer beides: die Antwort und die Auszüge. Die Auszüge sind der
* eigentliche Wert — sie sind aus den Unterlagen der Wehr und damit belegt,
* während die Antwort eine Zusammenfassung ist, die stimmen kann oder nicht.
*/
function Frage({ verfuegbar }: { verfuegbar: boolean }) {
const [frage, setFrage] = React.useState("");
const [ergebnis, setErgebnis] = React.useState<
| {
antwort: string | null;
auszuege: Array<{ abschnittId: string; fundstelle: string; text: string; slug: string; quelle: string | null }>;
vorgangId: string | null;
fehler: string | null;
}
| null
>(null);
const fragen = trpc.wiki.fragen.useMutation({
onSuccess: (antwort) =>
setErgebnis({
antwort: antwort.antwort,
auszuege: antwort.auszuege,
vorgangId: antwort.vorgangId,
fehler: antwort.fehler,
}),
onError: (fehler) =>
setErgebnis({ antwort: null, auszuege: [], vorgangId: null, fehler: fehler.message }),
});
const entscheiden = trpc.assistent.entscheiden.useMutation();
if (!verfuegbar) {
return (
<KiFehltHinweis
was="Es ist kein Sprachmodell eingerichtet."
ohneGeht="Die Wortsuche unten funktioniert trotzdem — sie findet, was wörtlich in den Unterlagen steht."
/>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="size-4" aria-hidden />
Frage an die Unterlagen
</CardTitle>
<CardDescription>
Beantwortet wird ausschließlich aus den hinterlegten Seiten. Steht die Antwort dort
nicht, sagt FLORIAN das — statt etwas zu erfinden.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Input
value={frage}
onChange={(ereignis) => setFrage(ereignis.target.value)}
onKeyDown={(ereignis) => {
if (ereignis.key === "Enter" && frage.trim().length >= 3) {
fragen.mutate({ frage });
}
}}
placeholder="Wie oft muss ein Schlauch geprüft werden?"
className="min-w-64 flex-1"
aria-label="Frage an das Wiki"
/>
<Button
onClick={() => fragen.mutate({ frage })}
disabled={frage.trim().length < 3 || fragen.isPending}
>
{fragen.isPending ? "Sucht …" : "Fragen"}
</Button>
</div>
{ergebnis?.fehler && !ergebnis.antwort && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{ergebnis.fehler}
</div>
)}
{ergebnis?.antwort && (
<KiVorschlag
titel="Antwort aus den Unterlagen"
text={ergebnis.antwort}
uebernehmenText="Hat geholfen"
laeuft={entscheiden.isPending}
onUebernehmen={() => {
if (ergebnis.vorgangId) {
entscheiden.mutate({ vorgangId: ergebnis.vorgangId, uebernommen: true });
}
setErgebnis(null);
}}
onVerwerfen={() => {
if (ergebnis.vorgangId) {
entscheiden.mutate({ vorgangId: ergebnis.vorgangId, uebernommen: false });
}
setErgebnis(null);
}}
fussnote={
<div className="space-y-1">
<p className="font-medium">Fundstellen:</p>
<ol className="list-decimal space-y-1 pl-5">
{ergebnis.auszuege.map((auszug) => (
<li key={auszug.abschnittId}>
<Link
href={`/wiki/${auszug.slug}`}
className="underline underline-offset-4"
>
{auszug.fundstelle}
</Link>
{auszug.quelle && ` (${auszug.quelle})`}
</li>
))}
</ol>
</div>
}
/>
)}
{/* Die Auszüge auch dann, wenn das Modell nicht geantwortet hat: sie
sind für sich brauchbar und stammen unverändert aus den Unterlagen. */}
{ergebnis && !ergebnis.antwort && ergebnis.auszuege.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium">Passende Stellen aus den Unterlagen:</p>
{ergebnis.auszuege.map((auszug) => (
<div key={auszug.abschnittId} className="rounded-md border border-[var(--border)] p-3">
<Link href={`/wiki/${auszug.slug}`} className="text-sm font-medium underline-offset-4 hover:underline">
{auszug.fundstelle}
</Link>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">{auszug.text}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
/** Eine neue Seite anlegen. */
function SeiteAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (slug: string) => void;
fehler: (nachricht: string) => void;
}) {
const [titel, setTitel] = React.useState("");
const [art, setArt] = React.useState<WikiKategorie>(WikiKategorie.SONSTIGES);
const [quelle, setQuelle] = React.useState("");
const [inhalt, setInhalt] = React.useState("");
const [veroeffentlicht, setVeroeffentlicht] = React.useState(true);
const anlegen = trpc.wiki.anlegen.useMutation({
onSuccess: (seite) => {
setTitel("");
setInhalt("");
setQuelle("");
fertig(seite.slug);
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Seite anlegen</DialogTitle>
<DialogDescription>
Der Text ist Markdown — Überschriften mit #, Aufzählungen mit -. An den Überschriften
wird die Seite für die Suche zerlegt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="wiki-titel">Titel</Label>
<Input
id="wiki-titel"
value={titel}
onChange={(ereignis) => setTitel(ereignis.target.value)}
placeholder="Schlauchpflege und Prüffristen"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="wiki-art">Bereich</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as WikiKategorie)}>
<SelectTrigger id="wiki-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(WikiKategorie).map((wert) => (
<SelectItem key={wert} value={wert}>
{WIKI_KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="wiki-quelle">Fundstelle (freiwillig)</Label>
<Input
id="wiki-quelle"
value={quelle}
onChange={(ereignis) => setQuelle(ereignis.target.value)}
placeholder="DGUV Information 205-021"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="wiki-inhalt">Inhalt</Label>
<Textarea
id="wiki-inhalt"
rows={14}
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="font-mono text-sm"
placeholder={"## Prüffristen\n\nSchläuche werden ...\n"}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={veroeffentlicht}
onCheckedChange={(wert) => setVeroeffentlicht(wert === true)}
/>
Veröffentlichen — ein Entwurf wird nicht durchsucht und nicht beantwortet.
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
titel,
art,
inhalt,
quelle: quelle || null,
schlagworte: [],
veroeffentlicht,
})
}
disabled={!titel.trim() || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+11 -1
View File
@@ -31,6 +31,16 @@ const ERLAUBTE_TYPEN = new Set([
"image/webp",
"image/heic",
"application/pdf",
// Seit Phase 8: Diktataufnahmen. Welches Format der Browser liefert, hängt
// von ihm ab — Chrome nimmt webm/opus auf, Safari mp4/aac. Beide Wege
// müssen offen sein, sonst kann die Hälfte der Wehr nicht diktieren.
"audio/webm",
"audio/ogg",
"audio/mp4",
"audio/mpeg",
"audio/wav",
"audio/x-m4a",
]);
/** 20 MB. Ein Handyfoto liegt darunter, ein Fremdprotokoll erst recht. */
@@ -84,7 +94,7 @@ export async function POST(anfrage: Request) {
if (!ERLAUBTE_TYPEN.has(mimeTyp)) {
return NextResponse.json(
{
fehler: `Dateien vom Typ ${mimeTyp} werden nicht angenommen. Erlaubt sind Bilder und PDF.`,
fehler: `Dateien vom Typ ${mimeTyp} werden nicht angenommen. Erlaubt sind Bilder, PDF und Tonaufnahmen.`,
},
{ status: 415 },
);
+198
View File
@@ -0,0 +1,198 @@
"use client";
import * as React from "react";
import { PenLine, Sparkles } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Diktat } from "@/components/ki/diktat";
import { KiFehltHinweis, KiVorschlag } from "@/components/ki/kennzeichnung";
/**
* Der Berichtsassistent.
*
* Zwei Wege in denselben Kasten mit Stichpunkten:
*
* * **Diktieren.** Auf der Rückfahrt gesprochen, hier als Text.
* * **Tippen.** Stichpunkte, wie man sie ohnehin auf einen Zettel schreibt.
*
* Aus dem, was drinsteht, macht das Modell einen Entwurf. Was das Modell
* **nicht** bekommt, ist ebenso wichtig: die Zeitstempel und die Einsatznummer
* stehen schon als Felder im Bericht und würden sonst zweimal dastehen, beim
* zweiten Mal womöglich falsch.
*
* Der Entwurf landet nicht im Berichtsfeld. Er steht daneben, gekennzeichnet,
* mit zwei gleich großen Knöpfen. Erst „Übernehmen" schreibt ihn ins Feld —
* und auch dann ist er nur ein Textvorschlag im Formular, gespeichert wird
* er, wenn jemand speichert.
*/
export function Berichtsassistent({
einsatzId,
onUebernehmen,
}: {
einsatzId: string;
/** Der übernommene Text — die Ansicht trägt ihn ins Berichtsfeld ein. */
onUebernehmen: (text: string) => void;
}) {
const [offen, setOffen] = React.useState(false);
const [stichpunkte, setStichpunkte] = React.useState("");
const [lage, setLage] = React.useState("");
const [entwurf, setEntwurf] = React.useState<{
text: string;
vorgangId: string;
modell?: string | null;
dauerMs?: number | null;
} | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const moeglichkeiten = trpc.assistent.moeglichkeiten.useQuery();
const entwerfen = trpc.assistent.berichtEntwerfen.useMutation({
onSuccess: (ergebnis) => {
setEntwurf({
text: ergebnis.entwurf,
vorgangId: ergebnis.vorgangId,
modell: ergebnis.modell,
dauerMs: ergebnis.dauerMs,
});
setMeldung(null);
},
onError: (fehler) => setMeldung(fehler.message),
});
const entscheiden = trpc.assistent.entscheiden.useMutation();
if (moeglichkeiten.isLoading) return null;
const textModell = moeglichkeiten.data?.text ?? false;
const diktatMoeglich = moeglichkeiten.data?.diktat ?? false;
if (!textModell && !diktatMoeglich) {
return (
<KiFehltHinweis
was="Es ist kein Sprachmodell eingerichtet."
ohneGeht="Der Bericht lässt sich wie bisher von Hand schreiben — das Feld darüber ist dafür da."
/>
);
}
if (!offen) {
return (
<Button variant="outline" size="sm" onClick={() => setOffen(true)}>
<Sparkles aria-hidden />
Beim Schreiben helfen lassen
</Button>
);
}
return (
<div className="space-y-4 rounded-lg border border-[var(--border)] p-4">
<div className="flex items-center justify-between gap-2">
<p className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4" aria-hidden />
Berichtsassistent
</p>
<Button variant="ghost" size="sm" onClick={() => setOffen(false)}>
Schließen
</Button>
</div>
{diktatMoeglich && (
<Diktat
entitaet="Einsatz"
entitaetId={einsatzId}
// Der erkannte Text geht in die Stichpunkte, nicht in den Bericht:
// von dort aus entsteht erst der Entwurf, und der wird nochmals
// gegengelesen. Zwei Vorschläge hintereinander, zwei Entscheidungen.
onUebernehmen={(text) =>
setStichpunkte((bisher) => (bisher ? `${bisher}\n${text}` : text))
}
/>
)}
{textModell && (
<>
<div className="space-y-2">
<Label htmlFor="assistent-lage">Lage beim Eintreffen (freiwillig)</Label>
<Textarea
id="assistent-lage"
rows={2}
value={lage}
onChange={(ereignis) => setLage(ereignis.target.value)}
placeholder="Verrauchtes Treppenhaus, Bewohner bereits im Freien."
/>
</div>
<div className="space-y-2">
<Label htmlFor="assistent-stichpunkte">Stichpunkte zum Verlauf</Label>
<Textarea
id="assistent-stichpunkte"
rows={6}
value={stichpunkte}
onChange={(ereignis) => setStichpunkte(ereignis.target.value)}
placeholder={
"Angriffstrupp unter PA ins EG\nBrand in der Küche, Fettbrand\nLöschdecke, kein Wasser\nBelüftung mit Überdrucklüfter\nÜbergabe an Polizei"
}
/>
<p className="text-xs text-[var(--muted-foreground)]">
Zeitstempel, Einsatznummer und Fahrzeuge stehen schon im Bericht — die müssen hier
nicht noch einmal hinein.
</p>
</div>
{meldung && (
<p
role="alert"
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
>
{meldung}
</p>
)}
<Button
onClick={() =>
entwerfen.mutate({
einsatzId,
stichpunkte,
lage: lage || null,
})
}
disabled={stichpunkte.trim().length < 10 || entwerfen.isPending}
>
<PenLine aria-hidden />
{entwerfen.isPending ? "Schreibt …" : "Entwurf erstellen"}
</Button>
{entwurf && (
<KiVorschlag
titel="Berichtsentwurf"
text={entwurf.text}
modell={entwurf.modell}
dauerMs={entwurf.dauerMs}
uebernehmenText="In das Berichtsfeld übernehmen"
laeuft={entscheiden.isPending}
onUebernehmen={() => {
onUebernehmen(entwurf.text);
entscheiden.mutate({ vorgangId: entwurf.vorgangId, uebernommen: true });
setEntwurf(null);
setOffen(false);
}}
onVerwerfen={() => {
entscheiden.mutate({ vorgangId: entwurf.vorgangId, uebernommen: false });
setEntwurf(null);
}}
fussnote={
<p>
Bitte gegenlesen: Namen, Uhrzeiten und Maßnahmen müssen mit dem übereinstimmen,
was wirklich war. Der Bericht ist ein Nachweis, und verantwortlich ist, wer ihn
freigibt.
</p>
}
/>
)}
</>
)}
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
"use client";
import * as React from "react";
import { Mic, Square, Trash2 } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { dateiHochladen } from "@/lib/hochladen";
import { KiVorschlag, KiFehltHinweis } from "@/components/ki/kennzeichnung";
import { alsLaenge } from "@/lib/ki/texte";
/**
* Diktieren statt tippen.
*
* Der Weg, den es in jeder Wehr schon gibt: jemand spricht auf der Rückfahrt
* in sein Telefon, und daraus wird abends ein Bericht. Neu ist nur, dass
* abends schon ein Text dasteht.
*
* Drei Dinge sind an der Bedienung wichtig, und alle drei kommen daher, dass
* hier jemand mit einer Hand am Steuer sitzt:
*
* 1. **Ein Knopf.** Aufnehmen, stoppen. Keine Einstellungen, keine Auswahl
* des Mikrofons.
* 2. **Die Aufnahme bleibt.** Auch nach der Erkennung. Solange das
* Transkript nicht bestätigt ist, ist sie das einzige Original — und wer
* dem Text nicht traut, hört nach.
* 3. **Es wird nichts automatisch eingetragen.** Der Text kommt als
* Vorschlag mit zwei Knöpfen. „Übernehmen" reicht ihn an die Ansicht
* weiter, die ihn angefordert hat.
*/
export interface DiktatProps {
/** Woran das Diktat hängt: "Einsatz", "Uebung". */
entitaet: string;
entitaetId: string;
/** Wird mit dem bestätigten Text aufgerufen. */
onUebernehmen: (text: string) => void;
}
type Zustand = "bereit" | "nimmt-auf" | "laedt-hoch" | "erkennt" | "fertig";
export function Diktat({ entitaet, entitaetId, onUebernehmen }: DiktatProps) {
const werkzeuge = trpc.useUtils();
const [zustand, setZustand] = React.useState<Zustand>("bereit");
const [sekunden, setSekunden] = React.useState(0);
const [diktatId, setDiktatId] = React.useState<string | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const rekorderRef = React.useRef<MediaRecorder | null>(null);
const stueckeRef = React.useRef<Blob[]>([]);
const stromRef = React.useRef<MediaStream | null>(null);
const moeglichkeiten = trpc.assistent.moeglichkeiten.useQuery();
const einreichen = trpc.assistent.diktatEinreichen.useMutation({
onSuccess: (ergebnis) => {
setDiktatId(ergebnis.id);
setZustand("erkennt");
},
onError: (fehler) => {
setMeldung(fehler.message);
setZustand("bereit");
},
});
// Solange erkannt wird, in Abständen nachfragen. Kein Ereignisstrom: die
// Erkennung dauert einmalig ein bis zwei Minuten, und dafür eine offene
// Verbindung zu halten wäre Aufwand ohne Gewinn.
const diktat = trpc.assistent.diktat.useQuery(
{ id: diktatId ?? "" },
{
enabled: Boolean(diktatId) && (zustand === "erkennt" || zustand === "fertig"),
refetchInterval: zustand === "erkennt" ? 3000 : false,
},
);
const entscheiden = trpc.assistent.diktatEntscheiden.useMutation({
onSuccess: () => {
void werkzeuge.assistent.invalidate();
zuruecksetzen();
},
onError: (fehler) => setMeldung(fehler.message),
});
React.useEffect(() => {
if (zustand !== "erkennt" || !diktat.data) return;
if (diktat.data.transkript) setZustand("fertig");
else if (diktat.data.stand === "FEHLGESCHLAGEN") {
setMeldung(diktat.data.kiVorgang?.fehler ?? "Die Erkennung ist fehlgeschlagen.");
setZustand("bereit");
setDiktatId(null);
}
}, [zustand, diktat.data]);
// Der Zähler läuft über die Uhr, nicht über gezählte Sekunden: ein Fenster
// im Hintergrund bekommt weniger Zeitgeber, und die Anzeige liefe nach.
React.useEffect(() => {
if (zustand !== "nimmt-auf") return;
const beginn = Date.now();
const zeitgeber = setInterval(() => {
const vergangen = Math.floor((Date.now() - beginn) / 1000);
setSekunden(vergangen);
const grenze = moeglichkeiten.data?.maxSekunden ?? 600;
if (vergangen >= grenze) stoppen();
}, 500);
return () => clearInterval(zeitgeber);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [zustand, moeglichkeiten.data?.maxSekunden]);
function zuruecksetzen() {
setZustand("bereit");
setSekunden(0);
setDiktatId(null);
setMeldung(null);
stueckeRef.current = [];
}
async function starten() {
setMeldung(null);
try {
const strom = await navigator.mediaDevices.getUserMedia({ audio: true });
stromRef.current = strom;
stueckeRef.current = [];
// Das Format bestimmt der Browser: Chrome nimmt webm/opus auf, Safari
// mp4/aac. Beides wird angenommen — sonst könnte die Hälfte der Wehr
// nicht diktieren.
const rekorder = new MediaRecorder(strom);
rekorderRef.current = rekorder;
rekorder.ondataavailable = (ereignis) => {
if (ereignis.data.size > 0) stueckeRef.current.push(ereignis.data);
};
rekorder.onstop = () => {
strom.getTracks().forEach((spur) => spur.stop());
void hochladen();
};
rekorder.start();
setZustand("nimmt-auf");
setSekunden(0);
} catch {
setMeldung(
"Auf das Mikrofon lässt sich nicht zugreifen. Der Browser fragt nur über HTTPS " +
"oder auf localhost — und die Freigabe muss erteilt sein.",
);
}
}
function stoppen() {
if (rekorderRef.current?.state === "recording") {
rekorderRef.current.stop();
setZustand("laedt-hoch");
}
}
async function hochladen() {
const stuecke = stueckeRef.current;
if (stuecke.length === 0) {
setMeldung("Es wurde nichts aufgenommen.");
setZustand("bereit");
return;
}
const typ = rekorderRef.current?.mimeType ?? "audio/webm";
const aufnahme = new Blob(stuecke, { type: typ });
const endung = typ.includes("mp4") ? "m4a" : typ.includes("ogg") ? "ogg" : "webm";
try {
const datei = await dateiHochladen(
aufnahme,
entitaet,
entitaetId,
"diktat",
`diktat-${Date.now()}.${endung}`,
);
einreichen.mutate({
audioDateiId: datei.id,
dauerSekunden: sekunden,
entitaet,
entitaetId,
});
} catch (fehler) {
setMeldung(fehler instanceof Error ? fehler.message : String(fehler));
setZustand("bereit");
}
}
if (moeglichkeiten.isLoading) return null;
if (!moeglichkeiten.data?.diktat) {
return (
<KiFehltHinweis
was="Das Diktat ist in dieser Installation nicht eingerichtet."
ohneGeht="Der Bericht lässt sich wie bisher von Hand schreiben."
/>
);
}
return (
<div className="space-y-4">
{meldung && (
<p
role="alert"
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
>
{meldung}
</p>
)}
{zustand === "bereit" && (
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void starten()}>
<Mic aria-hidden />
Diktat aufnehmen
</Button>
<span className="text-sm text-[var(--muted-foreground)]">
Höchstens {alsLaenge(moeglichkeiten.data.maxSekunden)} Minuten am Stück.
</span>
</div>
)}
{zustand === "nimmt-auf" && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border-2 border-[var(--destructive)] bg-[var(--destructive)]/5 p-4">
<span
className="size-3 shrink-0 animate-pulse rounded-full bg-[var(--destructive)]"
aria-hidden
/>
<span className="font-medium tabular-nums" aria-live="polite">
Nimmt auf … {alsLaenge(sekunden)}
</span>
<Button size="sm" variant="outline" onClick={stoppen} className="ml-auto">
<Square aria-hidden />
Fertig
</Button>
</div>
)}
{(zustand === "laedt-hoch" || zustand === "erkennt") && (
<div className="rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm">
<p className="font-medium">
{zustand === "laedt-hoch" ? "Aufnahme wird übertragen …" : "Wird erkannt …"}
</p>
<p className="mt-1 text-[var(--muted-foreground)]">
{zustand === "erkennt"
? "Das dauert bei einer langen Aufnahme ein bis zwei Minuten. Die Seite kann " +
"solange offen bleiben — der Text erscheint von selbst."
: "Die Aufnahme wird abgelegt und bleibt erhalten."}
</p>
</div>
)}
{zustand === "fertig" && diktat.data?.transkript && (
<KiVorschlag
titel="Erkannter Text"
text={diktat.data.transkript}
modell={diktat.data.kiVorgang?.modell}
dauerMs={diktat.data.kiVorgang?.dauerMs}
uebernehmenText="In den Bericht übernehmen"
laeuft={entscheiden.isPending}
fussnote={
<p>
Aufnahmelänge {alsLaenge(diktat.data.dauerSekunden)}. Die Aufnahme bleibt abgelegt,
bis über den Text entschieden ist — Fachbegriffe und Ortsnamen bitte nachlesen.
</p>
}
onUebernehmen={() => {
onUebernehmen(diktat.data!.transkript!);
entscheiden.mutate({ id: diktat.data!.id, uebernommen: true });
}}
onVerwerfen={() => entscheiden.mutate({ id: diktat.data!.id, uebernommen: false })}
/>
)}
{zustand === "fertig" && (
<Button variant="ghost" size="sm" onClick={zuruecksetzen}>
<Trash2 aria-hidden />
Neu aufnehmen
</Button>
)}
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import * as React from "react";
import { Bot, Check, TriangleAlert, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
/**
* Die Kennzeichnung von Maschinentext.
*
* Aus Phase-8.md: „Jede KI-Ausgabe ist als solche gekennzeichnet und muss
* bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt."
*
* Diese Datei ist die eine Stelle, an der das aussieht, wie es aussieht —
* damit es überall gleich aussieht. Ein Vorschlag, der auf der einen Seite
* einen Rahmen hat und auf der anderen nicht, wird auf der anderen Seite
* irgendwann für einen fertigen Text gehalten.
*
* Drei Dinge macht sie sichtbar, und alle drei sind Absicht:
*
* 1. **Der Rahmen.** Nicht dieselbe Fläche wie der übrige Text. Was aus
* einer Maschine kommt, steht daneben, nicht mittendrin.
* 2. **Das Modell.** Wer in einem Jahr nachvollziehen will, wie ein Satz
* entstanden ist, braucht den Namen.
* 3. **Zwei Knöpfe, gleich groß.** „Übernehmen" ist nicht der Standardweg
* und sieht auch nicht so aus. Wer nichts drückt, hat nichts übernommen.
*/
export function KiAbzeichen({ modell }: { modell?: string | null }) {
return (
<Badge variant="secondary" title={modell ? `Erzeugt von ${modell}` : undefined}>
<Bot className="mr-1 size-3" aria-hidden />
Maschinell erzeugt
</Badge>
);
}
export interface VorschlagProps {
titel: string;
/** Der erzeugte Text. Wird als Fließtext angezeigt, nicht als Formularfeld. */
text: string;
modell?: string | null;
dauerMs?: number | null;
/** Was unter dem Text steht — Fundstellen, Hinweise. */
fussnote?: React.ReactNode;
/** Beschriftung des Übernehmen-Knopfs, je nach Zusammenhang. */
uebernehmenText?: string;
laeuft?: boolean;
onUebernehmen: () => void;
onVerwerfen: () => void;
}
/**
* Ein Vorschlag, wie er überall aussieht.
*
* Bewusst kein bearbeitbares Feld: wer den Text ändern will, übernimmt ihn
* zuerst und ändert ihn dann dort, wo er hingehört. So bleibt im KI-Vorgang
* stehen, was das Modell wirklich geliefert hat — und daneben, was ein Mensch
* daraus gemacht hat.
*/
export function KiVorschlag({
titel,
text,
modell,
dauerMs,
fussnote,
uebernehmenText = "Übernehmen",
laeuft = false,
onUebernehmen,
onVerwerfen,
}: VorschlagProps) {
return (
<section
aria-label={titel}
className="space-y-4 rounded-lg border-2 border-dashed border-[var(--pruefung-faellig)]/50 bg-[var(--pruefung-faellig)]/5 p-4"
>
<div className="flex flex-wrap items-center gap-2">
<KiAbzeichen modell={modell} />
<span className="text-sm font-medium">{titel}</span>
{modell && (
<span className="text-xs text-[var(--muted-foreground)]">
{modell}
{dauerMs ? ` · ${(dauerMs / 1000).toFixed(1)} s` : ""}
</span>
)}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{text}</p>
{fussnote && (
<div className="border-t border-[var(--border)] pt-3 text-sm text-[var(--muted-foreground)]">
{fussnote}
</div>
)}
<div className="flex flex-wrap gap-2 border-t border-[var(--border)] pt-3">
<Button size="sm" onClick={onUebernehmen} disabled={laeuft}>
<Check aria-hidden />
{uebernehmenText}
</Button>
<Button size="sm" variant="outline" onClick={onVerwerfen} disabled={laeuft}>
<X aria-hidden />
Verwerfen
</Button>
</div>
<p className="flex gap-2 text-xs text-[var(--muted-foreground)]">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span>
Dieser Text stammt von einer Maschine und ist ungeprüft. Bitte lesen und
berichtigen, bevor er in einen Nachweis geht — verantwortlich ist, wer
unterschreibt.
</span>
</p>
</section>
);
}
/**
* Der Hinweis, wenn ein Modell fehlt.
*
* Sagt in einem Satz, was fehlt, und in einem zweiten, wie es ohne geht. Der
* zweite ist der wichtigere: eine Wehr ohne Grafikkarte soll die Anwendung
* benutzen können, ohne dass ständig etwas kaputt aussieht.
*/
export function KiFehltHinweis({
was,
ohneGeht,
}: {
was: string;
ohneGeht: string;
}) {
return (
<div className="flex gap-3 rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm">
<Bot className="mt-0.5 size-5 shrink-0 text-[var(--muted-foreground)]" aria-hidden />
<div>
<p className="font-medium">{was}</p>
<p className="mt-1 text-[var(--muted-foreground)]">{ohneGeht}</p>
</div>
</div>
);
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import * as React from "react";
/**
* Markdown anzeigen.
*
* Selbst geschrieben, und zwar aus einem Grund, der wichtiger ist als die
* ersparte Abhängigkeit: **hier wird kein HTML erzeugt.** Der Text kommt von
* Anwendern, und jeder Weg, der ihn durch `dangerouslySetInnerHTML` schickt,
* ist ein Weg für ein `<script>` in einer Dienstvorschrift. Diese Datei baut
* React-Elemente; ein spitzes Klammernpaar im Text bleibt ein spitzes
* Klammernpaar und wird nie zu einem Tag.
*
* Unterstützt wird, was in einer Dienstvorschrift vorkommt: Überschriften,
* Absätze, Aufzählungen, nummerierte Listen, Zitate, Tabellen, Codeblöcke und
* innerhalb von Text fett, kursiv und Code. Bilder und Links auf fremde Server
* bewusst nicht — FLORIAN lädt nichts von außen nach, auch nicht aus einer
* Wiki-Seite.
*/
export function Markdown({ text }: { text: string }) {
const bloecke = React.useMemo(() => zerlegen(text), [text]);
return <div className="space-y-4 text-sm leading-relaxed">{bloecke}</div>;
}
function zerlegen(text: string): React.ReactNode[] {
const zeilen = text.replace(/\r\n/g, "\n").split("\n");
const ausgabe: React.ReactNode[] = [];
let stelle = 0;
let schluessel = 0;
while (stelle < zeilen.length) {
const zeile = zeilen[stelle]!;
// Leerzeile
if (!zeile.trim()) {
stelle += 1;
continue;
}
// Codeblock
if (/^\s*```/.test(zeile)) {
const inhalt: string[] = [];
stelle += 1;
while (stelle < zeilen.length && !/^\s*```/.test(zeilen[stelle]!)) {
inhalt.push(zeilen[stelle]!);
stelle += 1;
}
stelle += 1;
ausgabe.push(
<pre
key={schluessel++}
className="overflow-x-auto rounded-md bg-[var(--muted)] p-3 font-mono text-xs"
>
<code>{inhalt.join("\n")}</code>
</pre>,
);
continue;
}
// Überschrift
const ueberschrift = /^(#{1,6})\s+(.*)$/.exec(zeile);
if (ueberschrift) {
const ebene = ueberschrift[1]!.length;
const inhalt = inline(ueberschrift[2]!);
const klassen = [
"text-xl font-semibold tracking-tight",
"text-lg font-semibold tracking-tight",
"text-base font-semibold",
"text-sm font-semibold",
"text-sm font-semibold",
"text-sm font-semibold",
][ebene - 1];
const Tag = `h${Math.min(ebene + 1, 6)}` as "h2";
ausgabe.push(
<Tag key={schluessel++} className={`${klassen} pt-2`}>
{inhalt}
</Tag>,
);
stelle += 1;
continue;
}
// Waagerechte Linie
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(zeile)) {
ausgabe.push(<hr key={schluessel++} className="border-[var(--border)]" />);
stelle += 1;
continue;
}
// Tabelle: Kopfzeile, Trennzeile, dann Datenzeilen.
if (zeile.includes("|") && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(zeilen[stelle + 1] ?? "")) {
const kopf = spalten(zeile);
stelle += 2;
const zeilenInhalt: string[][] = [];
while (stelle < zeilen.length && zeilen[stelle]!.includes("|")) {
zeilenInhalt.push(spalten(zeilen[stelle]!));
stelle += 1;
}
ausgabe.push(
<div key={schluessel++} className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
{kopf.map((feld, nummer) => (
<th key={nummer} className="py-2 pr-4 font-medium">
{inline(feld)}
</th>
))}
</tr>
</thead>
<tbody>
{zeilenInhalt.map((reihe, nummer) => (
<tr key={nummer} className="border-b border-[var(--border)]">
{reihe.map((feld, spalte) => (
<td key={spalte} className="py-2 pr-4 align-top">
{inline(feld)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>,
);
continue;
}
// Zitat
if (/^\s*>\s?/.test(zeile)) {
const inhalt: string[] = [];
while (stelle < zeilen.length && /^\s*>\s?/.test(zeilen[stelle]!)) {
inhalt.push(zeilen[stelle]!.replace(/^\s*>\s?/, ""));
stelle += 1;
}
ausgabe.push(
<blockquote
key={schluessel++}
className="border-l-4 border-[var(--primary)] pl-4 text-[var(--muted-foreground)]"
>
{inline(inhalt.join(" "))}
</blockquote>,
);
continue;
}
// Aufzählung
if (/^\s*[-*+]\s+/.test(zeile)) {
const punkte: string[] = [];
while (stelle < zeilen.length && /^\s*[-*+]\s+/.test(zeilen[stelle]!)) {
punkte.push(zeilen[stelle]!.replace(/^\s*[-*+]\s+/, ""));
stelle += 1;
}
ausgabe.push(
<ul key={schluessel++} className="list-disc space-y-1 pl-5">
{punkte.map((punkt, nummer) => (
<li key={nummer}>{inline(punkt)}</li>
))}
</ul>,
);
continue;
}
// Nummerierte Liste
if (/^\s*\d+[.)]\s+/.test(zeile)) {
const punkte: string[] = [];
while (stelle < zeilen.length && /^\s*\d+[.)]\s+/.test(zeilen[stelle]!)) {
punkte.push(zeilen[stelle]!.replace(/^\s*\d+[.)]\s+/, ""));
stelle += 1;
}
ausgabe.push(
<ol key={schluessel++} className="list-decimal space-y-1 pl-5">
{punkte.map((punkt, nummer) => (
<li key={nummer}>{inline(punkt)}</li>
))}
</ol>,
);
continue;
}
// Absatz: alles bis zur nächsten Leerzeile.
const absatz: string[] = [];
while (stelle < zeilen.length && zeilen[stelle]!.trim() && !istBlockanfang(zeilen[stelle]!)) {
absatz.push(zeilen[stelle]!);
stelle += 1;
}
if (absatz.length > 0) {
ausgabe.push(<p key={schluessel++}>{inline(absatz.join(" "))}</p>);
} else {
// Sicherheitsnetz gegen eine Endlosschleife, falls oben ein Fall
// durchrutscht: eine Zeile, die kein Block ist und kein Absatz sein
// will, wird als Absatz genommen.
ausgabe.push(<p key={schluessel++}>{inline(zeile)}</p>);
stelle += 1;
}
}
return ausgabe;
}
function istBlockanfang(zeile: string): boolean {
return (
/^(#{1,6})\s+/.test(zeile) ||
/^\s*[-*+]\s+/.test(zeile) ||
/^\s*\d+[.)]\s+/.test(zeile) ||
/^\s*>\s?/.test(zeile) ||
/^\s*```/.test(zeile) ||
/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(zeile)
);
}
function spalten(zeile: string): string[] {
return zeile
.replace(/^\s*\|/, "")
.replace(/\|\s*$/, "")
.split("|")
.map((feld) => feld.trim());
}
/**
* Auszeichnungen innerhalb einer Zeile.
*
* Rekursiv über die drei Formen, die vorkommen. Alles andere bleibt Text —
* und zwar wirklich Text, als React-Kind, nicht als HTML.
*/
function inline(text: string, schluessel = 0): React.ReactNode {
// Code zuerst: was in Backticks steht, wird nicht weiter ausgezeichnet.
const code = /`([^`]+)`/.exec(text);
if (code) {
return (
<React.Fragment key={schluessel}>
{inline(text.slice(0, code.index), schluessel + 1)}
<code className="rounded bg-[var(--muted)] px-1 py-0.5 font-mono text-xs">{code[1]}</code>
{inline(text.slice(code.index + code[0].length), schluessel + 2)}
</React.Fragment>
);
}
const fett = /\*\*([^*]+)\*\*/.exec(text);
if (fett) {
return (
<React.Fragment key={schluessel}>
{inline(text.slice(0, fett.index), schluessel + 1)}
<strong className="font-semibold">{inline(fett[1]!, schluessel + 2)}</strong>
{inline(text.slice(fett.index + fett[0].length), schluessel + 3)}
</React.Fragment>
);
}
const kursiv = /(?<![*\w])\*([^*]+)\*(?![*\w])/.exec(text);
if (kursiv) {
return (
<React.Fragment key={schluessel}>
{inline(text.slice(0, kursiv.index), schluessel + 1)}
<em>{inline(kursiv[1]!, schluessel + 2)}</em>
{inline(text.slice(kursiv.index + kursiv[0].length), schluessel + 3)}
</React.Fragment>
);
}
return text;
}
+29 -4
View File
@@ -6,7 +6,9 @@ import { usePathname } from "next/navigation";
import {
Album,
BellRing,
BookMarked,
BookOpenCheck,
Bot,
CalendarDays,
ClipboardCheck,
Flame,
@@ -14,6 +16,7 @@ import {
GraduationCap,
IdCard,
Map,
ReceiptText,
Menu,
MonitorSmartphone,
QrCode,
@@ -138,11 +141,33 @@ const EINTRAEGE: Eintrag[] = [
rechte: ["benachrichtigung.eigene"],
abPhase: 7,
},
{
titel: "Wissen",
pfad: "/wiki",
symbol: BookMarked,
rechte: ["wiki.lesen"],
abPhase: 8,
},
{
titel: "Belege",
pfad: "/belege",
symbol: ReceiptText,
rechte: ["beleg.erfassen"],
abPhase: 8,
},
{
titel: "KI-Protokoll",
pfad: "/ki-protokoll",
symbol: Bot,
rechte: ["ki.protokoll"],
abPhase: 8,
},
{ titel: "Kasse", pfad: "/kasse", symbol: Wallet, rechte: ["kasse.lesen"], abPhase: 9 },
{
// Das Audit-Log wird seit Phase 0 geschrieben, eine Ansicht dafür gibt es
// noch nicht — sie gehört zum Löschkonzept in Phase 8. Bis dahin steht der
// Eintrag ausgegraut da statt ins Leere zu führen.
// Das Audit-Log wird seit Phase 0 geschrieben; die Ansicht dazu kam in
// Phase 8, zusammen mit dem KI-Protokoll. Beides nebeneinander beantwortet
// die beiden Fragen, die im Zweifel gestellt werden: was hat ein Mensch
// getan, und was hat die Maschine getan.
titel: "Protokoll",
pfad: "/protokoll",
symbol: Album,
@@ -153,7 +178,7 @@ const EINTRAEGE: Eintrag[] = [
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 7;
const AKTUELLE_PHASE = 8;
export interface SeitenleisteProps {
rechte: Recht[];
+36 -1
View File
@@ -144,6 +144,15 @@ export const RECHTE = {
bereich: "Benachrichtigungen",
bezeichnung: "Textvorlagen der Wehr bearbeiten",
},
// Wissen und lokale KI (Phase 8)
"wiki.lesen": { bereich: "Wissen", bezeichnung: "Dienstvorschriften und Unterlagen lesen" },
"wiki.bearbeiten": { bereich: "Wissen", bezeichnung: "Wiki-Seiten anlegen und ändern" },
/// Diktat, Berichtsentwurf, Frage ans Wiki. Wer schreibt, darf sich helfen
/// lassen — entschieden wird trotzdem von Hand.
"ki.nutzen": { bereich: "Wissen", bezeichnung: "KI-Unterstützung benutzen" },
"ki.protokoll": { bereich: "Wissen", bezeichnung: "Alle KI-Vorgänge einsehen" },
"beleg.erfassen": { bereich: "Kasse", bezeichnung: "Belege fotografieren und erfassen" },
} as const;
export type Recht = keyof typeof RECHTE;
@@ -172,6 +181,9 @@ const GRUNDRECHTE: Recht[] = [
// Benachrichtigungswege selbst ein — das ist keine Sache des Kommandanten.
"termin.lesen",
"benachrichtigung.eigene",
// Dienstvorschriften sind zum Lesen da. Eine Wehr, in der nur die Führung
// nachschlagen darf, hat kein Wiki, sondern einen Aktenschrank.
"wiki.lesen",
];
/**
@@ -207,6 +219,10 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
"termin.bearbeiten",
"termin.anwesenheit",
"benachrichtigung.vorlagen",
"wiki.bearbeiten",
"ki.nutzen",
"ki.protokoll",
"beleg.erfassen",
],
GERAETEWART: [
@@ -217,6 +233,7 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
"pruefung.planen",
"pruefung.durchfuehren",
"datei.hochladen",
"ki.nutzen",
],
ATEMSCHUTZGERAETEWART: [
@@ -228,9 +245,20 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
"atemschutz.bearbeiten",
"atemschutz.ueberwachung",
"datei.hochladen",
"ki.nutzen",
],
KASSIER: [...GRUNDRECHTE, "kasse.lesen", "kasse.bearbeiten", "kasse.abschluss", "datei.hochladen"],
KASSIER: [
...GRUNDRECHTE,
"kasse.lesen",
"kasse.bearbeiten",
"kasse.abschluss",
"datei.hochladen",
// Die Belegerfassung gehört zur Kasse: der Kassier fotografiert die
// Quittung und zeichnet gegen, was das Modell gelesen hat.
"beleg.erfassen",
"ki.nutzen",
],
SCHRIFTFUEHRER: [
...GRUNDRECHTE,
@@ -244,6 +272,10 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
"termin.bearbeiten",
"termin.anwesenheit",
"benachrichtigung.vorlagen",
// Und er ist derjenige, der Berichte schreibt — also derjenige, dem
// Diktat und Berichtsentwurf am meisten nützen.
"wiki.bearbeiten",
"ki.nutzen",
],
AUSBILDER: [
@@ -254,6 +286,9 @@ export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
// Übungstermine anlegen und die Anwesenheit abhaken gehört zur Ausbildung.
"termin.bearbeiten",
"termin.anwesenheit",
// Ausbildungsunterlagen sind der halbe Inhalt des Wikis.
"wiki.bearbeiten",
"ki.nutzen",
],
MITGLIED: [...GRUNDRECHTE],
+35
View File
@@ -143,10 +143,45 @@ const schema = z.object({
/// Wie lange zugestellte Benachrichtigungen aufgehoben werden.
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: zahl(90),
/**
* Sprachmodelle, ausschließlich gegen einen selbst betriebenen Endpunkt
* (Ollama, vLLM, llama.cpp). Ab Phase 8 tatsächlich benutzt.
*
* Es gibt keinen Rückfall auf einen Anbieter. Fehlt der Endpunkt, fehlt die
* Funktion — sie wird nicht durch einen fremden Dienst ersetzt.
*/
LLM_ENABLED: boolAusText(false),
LLM_BASE_URL: z.string().optional(),
LLM_API_KEY: z.string().optional(),
LLM_MODELL: z.string().optional(),
/// Zeitlimit für eine Antwort. Ein Bericht darf dauern, aber nicht ewig.
LLM_TIMEOUT: zahl(120),
/**
* Modell für Einbettungen (semantische Suche im Wiki).
*
* ACHTUNG: Die Spalte wikiabschnitt.einbettung ist auf 768 Dimensionen
* festgelegt — das liefert nomic-embed-text. Ein Modell mit anderer Länge
* braucht eine Migration, siehe README.
*/
LLM_EINBETTUNG_MODELL: z.string().default("nomic-embed-text"),
LLM_EINBETTUNG_DIMENSIONEN: zahl(768),
/// Modell, das Bilder lesen kann — für die Belegerfassung.
LLM_VISION_MODELL: z.string().optional(),
/**
* Spracherkennung über einen selbst betriebenen faster-whisper
* (Compose-Profil "ki"). Ohne ihn ist das Diktat abgeschaltet.
*/
WHISPER_ENABLED: boolAusText(false),
WHISPER_URL: z.string().url().optional(),
WHISPER_MODELL: z.string().default("base"),
WHISPER_SPRACHE: z.string().default("de"),
/// Zeitlimit für eine Aufnahme. Zehn Minuten Sprache brauchen Zeit.
WHISPER_TIMEOUT: zahl(300),
/// Höchstlänge einer Aufnahme in Sekunden.
DIKTAT_MAX_SEKUNDEN: zahl(600),
// Terminal im Gerätehaus (Phase 4)
/// Wetterwarnungen des DWD auf dem Hallenmonitor. Die einzige Verbindung,
+236 -22
View File
@@ -4,13 +4,28 @@ 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
* Vorgesehen für: Einsatzberichte aus Stichpunkten formulieren, Fragen ans
* eigene Wiki beantworten, Quittungen auslesen. 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.
*
* **Es gibt keinen Rückfall auf einen Anbieter.** Fehlt der Endpunkt, fehlt
* die Funktion. Das ist die einzige Bauart, bei der der Satz „die Daten
* bleiben im Haus" auch dann noch stimmt, wenn der eigene Server aus ist.
*
* Drei Fähigkeiten, drei Modelle — die Trennung ist keine Förmlichkeit:
*
* vervollstaendigen ein Sprachmodell, das Text schreibt (LLM_MODELL)
* einbetten ein Einbettungsmodell für die Suche
* (LLM_EINBETTUNG_MODELL, feste Länge!)
* bildLesen ein Modell, das Bilder versteht (LLM_VISION_MODELL)
*
* Eine Wehr kann alle drei auf derselben Maschine betreiben; sie sind
* trotzdem einzeln konfigurierbar, weil ein Rechner ohne Grafikkarte das
* Einbettungsmodell noch schafft und das Vision-Modell nicht mehr.
*/
export interface LlmAnfrage {
@@ -18,30 +33,74 @@ export interface LlmAnfrage {
benutzerEingabe: string;
maxToken?: number;
temperatur?: number;
/**
* Bilder als Data-URI (`data:image/jpeg;base64,...`). Nur sinnvoll mit
* einem Modell, das Bilder lesen kann.
*/
bilder?: string[];
/** Antwort als JSON erzwingen, soweit der Endpunkt das unterstützt. */
alsJson?: boolean;
}
export interface LlmAntwort {
erfolgreich: boolean;
text?: string;
fehler?: string;
/** Wie lange es gedauert hat — landet im KI-Vorgang. */
dauerMs?: number;
tokenEingabe?: number;
tokenAusgabe?: number;
/** Welches Modell tatsächlich geantwortet hat. */
modell?: string;
}
export interface EinbettungsAntwort {
erfolgreich: boolean;
vektor?: number[];
fehler?: string;
modell?: string;
dauerMs?: number;
}
export interface LlmDienst {
vervollstaendigen(anfrage: LlmAnfrage): Promise<LlmAntwort>;
/** Einen Text in einen Vektor verwandeln — für die semantische Suche. */
einbetten(text: string): Promise<EinbettungsAntwort>;
/** Ein Bild auswerten. Braucht LLM_VISION_MODELL. */
bildLesen(anfrage: LlmAnfrage & { bilder: string[] }): Promise<LlmAntwort>;
verfuegbar(): boolean;
/** Ob ein Modell eingerichtet ist, das Bilder lesen kann. */
bildLesenMoeglich(): boolean;
einbettungsModell(): string;
}
const ABGESCHALTET =
"Die KI-Funktionen sind in dieser Installation abgeschaltet (LLM_ENABLED=false).";
/** Standard, solange LLM_ENABLED=false: liefert eine klare Absage. */
class AbgeschaltetesLlm implements LlmDienst {
verfuegbar(): boolean {
return false;
}
bildLesenMoeglich(): boolean {
return false;
}
einbettungsModell(): string {
return env.LLM_EINBETTUNG_MODELL;
}
async vervollstaendigen(): Promise<LlmAntwort> {
return {
erfolgreich: false,
fehler: "Die Textunterstützung ist in dieser Installation abgeschaltet (LLM_ENABLED=false).",
};
return { erfolgreich: false, fehler: ABGESCHALTET };
}
async einbetten(): Promise<EinbettungsAntwort> {
return { erfolgreich: false, fehler: ABGESCHALTET };
}
async bildLesen(): Promise<LlmAntwort> {
return { erfolgreich: false, fehler: ABGESCHALTET };
}
}
@@ -50,43 +109,198 @@ class OpenAiKompatiblesLlm implements LlmDienst {
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." };
}
bildLesenMoeglich(): boolean {
return Boolean(env.LLM_BASE_URL && env.LLM_VISION_MODELL);
}
einbettungsModell(): string {
return env.LLM_EINBETTUNG_MODELL;
}
private basis(): string {
return (env.LLM_BASE_URL ?? "").replace(/\/+$/, "");
}
private kopfzeilen(): Record<string, string> {
return {
"Content-Type": "application/json",
...(env.LLM_API_KEY ? { Authorization: `Bearer ${env.LLM_API_KEY}` } : {}),
};
}
private async chat(anfrage: LlmAnfrage, modell: string): Promise<LlmAntwort> {
const beginn = Date.now();
// Bilder gehen als Inhaltsteile mit, nicht als Text. Das ist das Format,
// das sowohl Ollama als auch vLLM verstehen.
const benutzerInhalt = anfrage.bilder?.length
? [
{ type: "text", text: anfrage.benutzerEingabe },
...anfrage.bilder.map((bild) => ({
type: "image_url",
image_url: { url: bild },
})),
]
: anfrage.benutzerEingabe;
try {
const antwort = await fetch(`${env.LLM_BASE_URL}/chat/completions`, {
const antwort = await fetch(`${this.basis()}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(env.LLM_API_KEY ? { Authorization: `Bearer ${env.LLM_API_KEY}` } : {}),
},
headers: this.kopfzeilen(),
body: JSON.stringify({
model: env.LLM_MODELL,
model: modell,
messages: [
{ role: "system", content: anfrage.systemAnweisung },
{ role: "user", content: anfrage.benutzerEingabe },
{ role: "user", content: benutzerInhalt },
],
max_tokens: anfrage.maxToken ?? 1024,
// Niedrig und nicht null: ein Einsatzbericht soll nicht erfinden,
// aber auch nicht dreimal denselben Satz bauen.
temperature: anfrage.temperatur ?? 0.3,
...(anfrage.alsJson ? { response_format: { type: "json_object" } } : {}),
}),
signal: AbortSignal.timeout(env.LLM_TIMEOUT * 1000),
});
if (!antwort.ok) {
return { erfolgreich: false, fehler: `Endpunkt antwortete mit ${antwort.status}` };
return {
erfolgreich: false,
fehler: `Der Endpunkt antwortete mit ${antwort.status}. Läuft das Modell "${modell}"?`,
dauerMs: Date.now() - beginn,
};
}
const daten = (await antwort.json()) as {
model?: string;
choices?: Array<{ message?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const text = daten.choices?.[0]?.message?.content;
return text
? { erfolgreich: true, text }
: { erfolgreich: false, fehler: "Leere Antwort vom Endpunkt." };
if (!text) {
return {
erfolgreich: false,
fehler: "Der Endpunkt hat eine leere Antwort geliefert.",
dauerMs: Date.now() - beginn,
};
}
return {
erfolgreich: true,
text,
dauerMs: Date.now() - beginn,
tokenEingabe: daten.usage?.prompt_tokens,
tokenAusgabe: daten.usage?.completion_tokens,
modell: daten.model ?? modell,
};
} catch (fehler) {
return { erfolgreich: false, fehler: String(fehler) };
// Der Text landet in der Oberfläche und wird von einem Menschen gelesen.
// „TimeoutError" allein hilft im Gerätehaus niemandem weiter.
const abgelaufen = fehler instanceof Error && fehler.name === "TimeoutError";
return {
erfolgreich: false,
fehler: abgelaufen
? `Das Modell hat nach ${env.LLM_TIMEOUT} Sekunden nicht geantwortet. ` +
"Auf einem Rechner ohne Grafikkarte ist das normal — entweder ein " +
"kleineres Modell wählen oder LLM_TIMEOUT hochsetzen."
: fehler instanceof Error
? fehler.message
: String(fehler),
dauerMs: Date.now() - beginn,
};
}
}
async vervollstaendigen(anfrage: LlmAnfrage): Promise<LlmAntwort> {
if (!this.verfuegbar()) {
return {
erfolgreich: false,
fehler: "LLM_BASE_URL oder LLM_MODELL fehlt — der Endpunkt ist nicht eingerichtet.",
};
}
return this.chat(anfrage, env.LLM_MODELL!);
}
async bildLesen(anfrage: LlmAnfrage & { bilder: string[] }): Promise<LlmAntwort> {
if (!this.bildLesenMoeglich()) {
return {
erfolgreich: false,
fehler:
"Es ist kein Modell eingerichtet, das Bilder lesen kann (LLM_VISION_MODELL). " +
"Die Angaben lassen sich von Hand eintragen.",
};
}
return this.chat(anfrage, env.LLM_VISION_MODELL!);
}
async einbetten(text: string): Promise<EinbettungsAntwort> {
if (!this.verfuegbar()) {
return { erfolgreich: false, fehler: "Der LLM-Endpunkt ist nicht eingerichtet." };
}
const beginn = Date.now();
try {
const antwort = await fetch(`${this.basis()}/embeddings`, {
method: "POST",
headers: this.kopfzeilen(),
body: JSON.stringify({ model: env.LLM_EINBETTUNG_MODELL, input: text }),
signal: AbortSignal.timeout(env.LLM_TIMEOUT * 1000),
});
if (!antwort.ok) {
return {
erfolgreich: false,
fehler:
`Der Endpunkt antwortete mit ${antwort.status}. ` +
`Ist "${env.LLM_EINBETTUNG_MODELL}" geladen? (ollama pull ${env.LLM_EINBETTUNG_MODELL})`,
dauerMs: Date.now() - beginn,
};
}
const daten = (await antwort.json()) as {
model?: string;
data?: Array<{ embedding?: number[] }>;
};
const vektor = daten.data?.[0]?.embedding;
if (!Array.isArray(vektor) || vektor.length === 0) {
return {
erfolgreich: false,
fehler: "Der Endpunkt hat keinen Vektor geliefert.",
dauerMs: Date.now() - beginn,
};
}
// Die Länge muss zur Spalte passen, sonst weist die Datenbank die Zeile
// ab — mit einer Meldung, die niemand versteht. Lieber hier und im
// Klartext, mit dem Hinweis, was zu tun ist.
if (vektor.length !== env.LLM_EINBETTUNG_DIMENSIONEN) {
return {
erfolgreich: false,
fehler:
`Das Modell "${env.LLM_EINBETTUNG_MODELL}" liefert ${vektor.length} Dimensionen, ` +
`die Datenbank erwartet ${env.LLM_EINBETTUNG_DIMENSIONEN}. Entweder ein Modell mit ` +
"passender Länge wählen oder die Spalte per Migration ändern — siehe README.",
dauerMs: Date.now() - beginn,
};
}
return {
erfolgreich: true,
vektor,
modell: daten.model ?? env.LLM_EINBETTUNG_MODELL,
dauerMs: Date.now() - beginn,
};
} catch (fehler) {
return {
erfolgreich: false,
fehler: fehler instanceof Error ? fehler.message : String(fehler),
dauerMs: Date.now() - beginn,
};
}
}
}
+151
View File
@@ -0,0 +1,151 @@
import "server-only";
import { env } from "@/lib/env";
/**
* Spracherkennung hinter einer Schnittstelle.
*
* Gedacht für den Weg, den es in jeder Wehr schon gibt: der Einsatzleiter
* spricht auf der Rückfahrt in sein Telefon, und daraus wird abends ein
* Bericht. Neu ist nur, dass abends schon ein Text dasteht, den jemand
* geradezieht, statt einer Aufnahme, die jemand abtippt.
*
* Gesprochen wird gegen einen selbst betriebenen faster-whisper mit
* OpenAI-kompatibler Schnittstelle (Compose-Profil `ki`). Kein Anbieter:
* Einsatzberichte sprechen über Menschen in ihrer schlechtesten Stunde, und
* solche Aufnahmen gehören nirgendwo anders hin als ins Gerätehaus.
*
* Ohne den Container ist das Diktat abgeschaltet — es wird nicht durch einen
* fremden Dienst ersetzt.
*/
export interface Transkript {
erfolgreich: boolean;
text?: string;
/** Erkannte oder erzwungene Sprache. */
sprache?: string;
dauerSekunden?: number;
fehler?: string;
dauerMs?: number;
}
export interface SpracherkennungsDienst {
transkribieren(
audio: Buffer,
dateiname: string,
optionen?: { sprache?: string },
): Promise<Transkript>;
verfuegbar(): boolean;
}
class AbgeschalteteSpracherkennung implements SpracherkennungsDienst {
verfuegbar(): boolean {
return false;
}
async transkribieren(): Promise<Transkript> {
return {
erfolgreich: false,
fehler:
"Das Diktat ist in dieser Installation abgeschaltet (WHISPER_ENABLED=false). " +
"Der Text lässt sich von Hand eintragen.",
};
}
}
class FasterWhisper implements SpracherkennungsDienst {
verfuegbar(): boolean {
return Boolean(env.WHISPER_URL);
}
async transkribieren(
audio: Buffer,
dateiname: string,
optionen: { sprache?: string } = {},
): Promise<Transkript> {
if (!this.verfuegbar()) {
return { erfolgreich: false, fehler: "WHISPER_URL ist nicht gesetzt." };
}
const beginn = Date.now();
const formular = new FormData();
formular.append("file", new Blob([new Uint8Array(audio)]), dateiname);
formular.append("model", env.WHISPER_MODELL);
// Die Sprache mitgeben statt erkennen lassen: die Erkennung kostet Zeit
// und liegt bei kurzen Aufnahmen mit Fachbegriffen gern daneben —
// „Löschangriff" klingt für ein Modell nicht zwingend nach Deutsch.
formular.append("language", optionen.sprache ?? env.WHISPER_SPRACHE);
formular.append("response_format", "verbose_json");
try {
const antwort = await fetch(
`${env.WHISPER_URL!.replace(/\/+$/, "")}/v1/audio/transcriptions`,
{
method: "POST",
body: formular,
signal: AbortSignal.timeout(env.WHISPER_TIMEOUT * 1000),
},
);
if (!antwort.ok) {
return {
erfolgreich: false,
fehler: `Die Spracherkennung antwortete mit ${antwort.status}.`,
dauerMs: Date.now() - beginn,
};
}
const daten = (await antwort.json()) as {
text?: string;
language?: string;
duration?: number;
};
if (!daten.text?.trim()) {
return {
erfolgreich: false,
fehler:
"Es wurde nichts erkannt. Möglich, dass die Aufnahme zu leise war oder das " +
"Mikrofon nicht aufgezeichnet hat.",
dauerMs: Date.now() - beginn,
};
}
return {
erfolgreich: true,
text: daten.text.trim(),
sprache: daten.language,
dauerSekunden: daten.duration ? Math.round(daten.duration) : undefined,
dauerMs: Date.now() - beginn,
};
} catch (fehler) {
const abgelaufen = fehler instanceof Error && fehler.name === "TimeoutError";
return {
erfolgreich: false,
fehler: abgelaufen
? `Die Spracherkennung hat nach ${env.WHISPER_TIMEOUT} Sekunden nicht geantwortet. ` +
"Auf einem Rechner ohne Grafikkarte dauert eine lange Aufnahme entsprechend — " +
"entweder ein kleineres Modell (WHISPER_MODELL=tiny) oder mehr Zeit."
: fehler instanceof Error
? fehler.message
: String(fehler),
dauerMs: Date.now() - beginn,
};
}
}
}
let cache: SpracherkennungsDienst | null = null;
export function spracherkennung(): SpracherkennungsDienst {
if (!cache) {
cache = env.WHISPER_ENABLED ? new FasterWhisper() : new AbgeschalteteSpracherkennung();
}
return cache;
}
/** Nur für Tests. */
export function _spracherkennungErsetzen(ersatz: SpracherkennungsDienst | null): void {
cache = ersatz;
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Die Anweisungen an das Modell.
*
* Sie stehen an einer Stelle und nicht verstreut in den Routern, weil sie das
* Verhalten der ganzen Phase bestimmen. Wer wissen will, was FLORIAN einem
* Modell aufträgt, liest diese Datei — und niemand ändert einen Satz darin
* versehentlich beim Umbauen einer Ansicht.
*
* Drei Regeln stehen in jeder Anweisung, und sie sind keine Höflichkeit:
*
* 1. **Nichts erfinden.** Was nicht in den Angaben steht, steht nicht im
* Ergebnis. Ein Einsatzbericht ist ein Nachweis; eine plausibel klingende
* Uhrzeit, die niemand gemessen hat, ist eine Falschaussage.
* 2. **Keine Bewertung.** Kein „vorbildlich", kein „zu spät". Ein Bericht
* hält fest, was war. Was daraus folgt, entscheidet ein Mensch.
* 3. **Deutsch, sachlich, Behördendeutsch wo es üblich ist.** Der Bericht
* geht an die Gemeinde und wird im Zweifel vor Gericht gelesen.
*
* Reine Textbausteine, keine Aufrufe — damit sie sich prüfen lassen.
*/
/** Der Kopf, der über jeder Anweisung steht. */
const GRUNDREGELN = [
"Du unterstützt die Schriftführung einer deutschen Freiwilligen Feuerwehr.",
"",
"Unverhandelbare Regeln:",
"- Erfinde nichts. Verwende ausschließlich die genannten Angaben.",
"- Fehlt eine Angabe, schreibe nichts dazu und erfinde keinen Ersatz.",
"- Bewerte nicht. Kein Lob, kein Tadel, keine Vermutung über Ursachen.",
"- Schreibe auf Deutsch, sachlich, in vollständigen Sätzen.",
"- Nenne keine Personen mit Namen, wenn sie dir nicht genannt wurden.",
].join("\n");
/**
* Einsatzbericht aus Stichpunkten.
*
* Bewusst ohne Gliederungsvorgabe für Uhrzeiten: die Zeitstempel stehen schon
* strukturiert im System und werden im PDF aus den Feldern gesetzt. Das Modell
* schreibt den **Verlauf**, nicht die Tabelle daneben — sonst stehen dieselben
* Zeiten zweimal da, und beim zweiten Mal womöglich falsch.
*/
export const BERICHT_ANWEISUNG = [
GRUNDREGELN,
"",
"Aufgabe: Formuliere aus den Angaben einen Einsatzbericht für die Akte.",
"",
"Aufbau:",
"- Ein Absatz zur Lage beim Eintreffen.",
"- Ein bis drei Absätze zum Verlauf der Maßnahmen, in der Reihenfolge,",
" in der sie geschehen sind.",
"- Ein kurzer Absatz zum Abschluss und zur Übergabe.",
"",
"Nicht wiederholen: Einsatznummer, Alarmzeit und die übrigen Zeitstempel.",
"Die stehen bereits als Felder im Bericht und würden sonst doppelt erscheinen.",
"",
"Antworte ausschließlich mit dem Berichtstext, ohne Überschrift und ohne",
"einleitenden Satz darüber, was du tust.",
].join("\n");
/**
* Antwort aus dem eigenen Wiki.
*
* Der wichtigste Satz ist der über das Nichtwissen. Ein Modell, das auf eine
* Frage nach der Prüffrist von Steckleitern etwas Plausibles antwortet, ohne
* dass es in den Unterlagen steht, ist gefährlicher als gar keine Antwort.
*/
export const WIKI_ANWEISUNG = [
"Du beantwortest Fragen zu den Unterlagen einer deutschen Freiwilligen Feuerwehr.",
"",
"Unverhandelbare Regeln:",
"- Antworte AUSSCHLIESSLICH aus den unten angegebenen Auszügen.",
"- Steht die Antwort nicht darin, sage genau das: dass es in den hinterlegten",
" Unterlagen nicht steht. Rate nicht und ergänze nichts aus eigenem Wissen.",
"- Nenne am Ende die Fundstellen, aus denen du geantwortet hast, mit ihren",
" Nummern in eckigen Klammern.",
"- Fasse dich kurz. Wer fragt, steht meistens neben einem Gerät.",
"",
"Antworte auf Deutsch.",
].join("\n");
/**
* Quittung auslesen.
*
* JSON, weil das Ergebnis in ein Formular geht und nicht gelesen wird. Und mit
* ausdrücklicher Erlaubnis, Felder leer zu lassen — ein Modell, das den Betrag
* nicht findet und deshalb einen erfindet, ist schlimmer als ein leeres Feld.
*/
export const BELEG_ANWEISUNG = [
"Du liest eine Quittung oder Rechnung aus und gibst die Angaben als JSON zurück.",
"",
"Antworte mit genau diesem Aufbau, ohne weiteren Text:",
"{",
' "betrag": Zahl oder null, // Gesamtbetrag, Punkt als Dezimaltrennzeichen',
' "waehrung": "EUR" oder null,',
' "belegdatum": "JJJJ-MM-TT" oder null,',
' "haendler": Text oder null,',
' "steuersatz": Zahl oder null, // in Prozent',
' "kategorie": Text oder null // z. B. "Kraftstoff", "Verpflegung", "Material"',
"}",
"",
"Unverhandelbare Regeln:",
"- Lies nur ab, was tatsächlich auf dem Beleg steht.",
"- Was du nicht sicher erkennst, setze auf null. Rate nicht.",
"- Der Betrag ist die Endsumme, nicht ein Einzelposten.",
"- Deutsche Datumsangaben (24.05.2026) in das Format JJJJ-MM-TT umsetzen.",
"- Deutsche Kommazahlen (12,50) mit Punkt schreiben (12.50).",
].join("\n");
/**
* Das Diktat aufräumen.
*
* Absichtlich zurückhaltend: Fachbegriffe und Ortsnamen bleiben, wie sie
* gesprochen wurden. Ein Modell, das „Hohlstrahlrohr" für einen Hörfehler hält
* und zu „Hohlstrahler" verbessert, macht aus einem brauchbaren Transkript
* einen falschen Bericht.
*/
export const DIKTAT_ANWEISUNG = [
"Du bereitest ein gesprochenes Diktat einer Feuerwehr für die Akte auf.",
"",
"Erlaubt ist ausschließlich:",
"- Satzzeichen und Absätze setzen.",
"- Füllwörter entfernen (ähm, also, ja).",
"- Offensichtliche Versprecher und Wortwiederholungen glätten.",
"",
"Nicht erlaubt:",
"- Inhalte ergänzen, umstellen oder weglassen.",
"- Fachbegriffe, Ortsnamen, Fahrzeug- und Gerätebezeichnungen ändern.",
" Was du nicht kennst, lässt du genau so stehen.",
"- Aus Stichworten Sätze machen.",
"",
"Antworte ausschließlich mit dem aufbereiteten Text.",
].join("\n");
/**
* Die Angaben eines Einsatzes für das Modell aufbereiten.
*
* Als lesbare Liste und nicht als JSON: Sprachmodelle arbeiten mit Fließtext
* zuverlässiger, und es lässt sich beim Fehlersuchen mitlesen.
*/
export function einsatzAlsEingabe(angaben: {
stichwort: string;
kategorie?: string | null;
ort?: string | null;
lage?: string | null;
stichpunkte: string;
fahrzeuge?: string[];
kraefte?: number | null;
dauerMinuten?: number | null;
material?: string[];
}): string {
const zeilen: string[] = [`Einsatzstichwort: ${angaben.stichwort}`];
if (angaben.kategorie) zeilen.push(`Kategorie: ${angaben.kategorie}`);
if (angaben.ort) zeilen.push(`Einsatzort: ${angaben.ort}`);
if (angaben.kraefte) zeilen.push(`Eingesetzte Kräfte: ${angaben.kraefte}`);
if (angaben.fahrzeuge?.length) zeilen.push(`Fahrzeuge: ${angaben.fahrzeuge.join(", ")}`);
if (angaben.dauerMinuten) zeilen.push(`Einsatzdauer: ${angaben.dauerMinuten} Minuten`);
if (angaben.material?.length) zeilen.push(`Eingesetztes Material: ${angaben.material.join(", ")}`);
if (angaben.lage) zeilen.push(`Lage beim Eintreffen: ${angaben.lage}`);
zeilen.push("", "Stichpunkte zum Verlauf:", angaben.stichpunkte.trim());
return zeilen.join("\n");
}
/**
* Frage und Auszüge für die Wiki-Antwort zusammenstellen.
*
* Die Auszüge sind nummeriert, damit das Modell auf sie verweisen kann und die
* Oberfläche den Verweis wieder auflösen kann.
*/
export function wikiAlsEingabe(
frage: string,
auszuege: Array<{ fundstelle: string; text: string }>,
): string {
const teile = auszuege.map(
(auszug, stelle) => `[${stelle + 1}] ${auszug.fundstelle}\n${auszug.text}`,
);
return [
"Auszüge aus den Unterlagen der Wehr:",
"",
teile.join("\n\n"),
"",
"----",
"",
`Frage: ${frage.trim()}`,
].join("\n");
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Die Antwort des Modells auf ein Belegfoto auswerten.
*
* Ein Sprachmodell antwortet auch dann in JSON, wenn man es freundlich darum
* bittet — aber nicht immer nur in JSON. Es packt gern einen erklärenden Satz
* davor, umrahmt die Antwort mit ```json, oder schreibt „12,50 €" statt 12.5.
* Das hier räumt das auf, ohne dabei etwas zu erfinden.
*
* **Die Regel: im Zweifel leer.** Ein nicht erkannter Betrag ist ein leeres
* Feld, das jemand ausfüllt. Ein geratener Betrag ist eine falsche Buchung,
* die niemand bemerkt.
*
* Reines Auswerten ohne Netz und ohne Datenbank — damit es sich prüfen lässt.
*/
export interface Belegangaben {
betrag: number | null;
waehrung: string | null;
belegdatum: Date | null;
haendler: string | null;
steuersatz: number | null;
kategorie: string | null;
}
export interface Belegergebnis extends Belegangaben {
/** Was tatsächlich erkannt wurde — für den Hinweis in der Oberfläche. */
erkannt: Array<keyof Belegangaben>;
fehlend: Array<keyof Belegangaben>;
}
/** Der leere Beleg: alles offen, nichts behauptet. */
const LEER: Belegangaben = {
betrag: null,
waehrung: null,
belegdatum: null,
haendler: null,
steuersatz: null,
kategorie: null,
};
/**
* JSON aus einer Antwort herausschälen.
*
* Erst der Codeblock, dann die äußersten geschweiften Klammern. Beides kommt
* vor, je nach Modell und Tagesform.
*/
export function jsonHerausloesen(antwort: string): string | null {
const codeblock = /```(?:json)?\s*([\s\S]*?)```/i.exec(antwort);
if (codeblock?.[1]) return codeblock[1].trim();
const anfang = antwort.indexOf("{");
const ende = antwort.lastIndexOf("}");
if (anfang !== -1 && ende > anfang) return antwort.slice(anfang, ende + 1);
return null;
}
/**
* Eine Zahl aus dem lesen, was ein Modell für eine Zahl hält.
*
* „12.50", 12.5, „12,50 €", „EUR 12,50" — alles kommt vor. Was sich nicht
* eindeutig lesen lässt, wird null.
*/
export function zahl(wert: unknown): number | null {
if (typeof wert === "number") return Number.isFinite(wert) ? wert : null;
if (typeof wert !== "string") return null;
const bereinigt = wert
.replace(/[€$£]/g, "")
.replace(/\b(?:EUR|USD|CHF)\b/gi, "")
.trim();
if (!bereinigt) return null;
// Tausenderpunkt und Dezimalkomma auseinanderhalten: "1.234,56" ist
// tausendzweihundert, "1.234" in einer Modellantwort dagegen meist eins
// Komma zwei — deshalb entscheidet das letzte Trennzeichen.
const letztesKomma = bereinigt.lastIndexOf(",");
const letzterPunkt = bereinigt.lastIndexOf(".");
let normalisiert: string;
if (letztesKomma > letzterPunkt) {
normalisiert = bereinigt.replace(/\./g, "").replace(",", ".");
} else {
normalisiert = bereinigt.replace(/,/g, "");
}
const zahlenteil = /-?\d+(?:\.\d+)?/.exec(normalisiert);
if (!zahlenteil) return null;
const ergebnis = Number(zahlenteil[0]);
return Number.isFinite(ergebnis) ? ergebnis : null;
}
/**
* Ein Datum lesen.
*
* Erwartet wird JJJJ-MM-TT, angenommen wird auch das deutsche Format — ein
* Modell, das einmal 24.05.2026 zurückgibt, soll nicht dazu führen, dass das
* Datum verloren geht.
*/
export function datum(wert: unknown): Date | null {
if (typeof wert !== "string") return null;
const text = wert.trim();
if (!text) return null;
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(text);
if (iso) return bauen(Number(iso[1]), Number(iso[2]), Number(iso[3]));
const deutsch = /^(\d{1,2})\.(\d{1,2})\.(\d{2,4})/.exec(text);
if (deutsch) {
const jahr = Number(deutsch[3]);
return bauen(jahr < 100 ? 2000 + jahr : jahr, Number(deutsch[2]), Number(deutsch[1]));
}
return null;
}
function bauen(jahr: number, monat: number, tag: number): Date | null {
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
// Ein Beleg von 1980 oder aus dem Jahr 2200 ist ein Lesefehler, keine Angabe.
if (jahr < 1990 || jahr > 2100) return null;
const wert = new Date(Date.UTC(jahr, monat - 1, tag));
// Der 31. Februar wird von Date stillschweigend zum 3. März — abfangen.
if (wert.getUTCMonth() !== monat - 1 || wert.getUTCDate() !== tag) return null;
return wert;
}
function text(wert: unknown, maxLaenge: number): string | null {
if (typeof wert !== "string") return null;
const bereinigt = wert.trim();
if (!bereinigt || bereinigt.toLowerCase() === "null") return null;
return bereinigt.slice(0, maxLaenge);
}
/**
* Die Antwort auswerten.
*
* Gibt immer ein Ergebnis zurück, notfalls ein leeres. Ein Modell, dessen
* Antwort sich nicht lesen lässt, führt zu einem leeren Formular — nicht zu
* einem Fehler, der den Anwender vor die Wahl stellt, ob er es nochmal
* versucht oder aufgibt.
*/
export function belegAuswerten(antwort: string): Belegergebnis {
const roh = jsonHerausloesen(antwort);
if (!roh) return ergaenzen(LEER);
let daten: Record<string, unknown>;
try {
const geparst: unknown = JSON.parse(roh);
if (typeof geparst !== "object" || geparst === null) return ergaenzen(LEER);
daten = geparst as Record<string, unknown>;
} catch {
return ergaenzen(LEER);
}
const betrag = zahl(daten.betrag);
const steuersatz = zahl(daten.steuersatz);
return ergaenzen({
// Ein negativer Betrag auf einer Quittung ist ein Lesefehler; die
// Datenbank würde ihn ohnehin abweisen.
betrag: betrag !== null && betrag >= 0 ? betrag : null,
waehrung: text(daten.waehrung, 3)?.toUpperCase() ?? null,
belegdatum: datum(daten.belegdatum),
haendler: text(daten.haendler, 200),
steuersatz: steuersatz !== null && steuersatz >= 0 && steuersatz <= 100 ? steuersatz : null,
kategorie: text(daten.kategorie, 80),
});
}
/** Was erkannt wurde und was nicht — die Oberfläche hebt die Lücken hervor. */
function ergaenzen(angaben: Belegangaben): Belegergebnis {
const felder: Array<keyof Belegangaben> = [
"betrag",
"waehrung",
"belegdatum",
"haendler",
"steuersatz",
"kategorie",
];
return {
...angaben,
erkannt: felder.filter((feld) => angaben[feld] !== null),
fehlend: felder.filter((feld) => angaben[feld] === null),
};
}
+51
View File
@@ -0,0 +1,51 @@
import type { KiArt, KiStand, WikiKategorie } from "@prisma/client";
/**
* Klartext für die KI-Ansichten.
*
* Eigene Datei, weil die Oberfläche sie im Browser braucht — die Module
* daneben sind `server-only`. Dieselbe Trennung wie bei wetter-texte.ts aus
* Phase 4 und termin/texte.ts aus Phase 7.
*/
export const WIKI_KATEGORIE_TEXT: Record<WikiKategorie, string> = {
DIENSTVORSCHRIFT: "Dienstvorschrift",
AUSBILDUNG: "Ausbildung",
GERAETE: "Geräte und Technik",
EINSATZ: "Einsatz",
ORGANISATION: "Organisation",
SONSTIGES: "Sonstiges",
};
export const KI_ART_TEXT: Record<KiArt, string> = {
DIKTAT: "Diktat",
BERICHTSENTWURF: "Berichtsentwurf",
WIKI_ANTWORT: "Frage ans Wiki",
BELEGERFASSUNG: "Belegerfassung",
EINBETTUNG: "Seite durchsuchbar gemacht",
};
export const KI_STAND_TEXT: Record<KiStand, string> = {
ENTWURF: "Offen",
UEBERNOMMEN: "Übernommen",
VERWORFEN: "Verworfen",
FEHLGESCHLAGEN: "Fehlgeschlagen",
};
/** Farbe je Zustand — dieselbe Bedeutung wie überall sonst in FLORIAN. */
export const KI_STAND_VARIANTE: Record<
KiStand,
"einsatzbereit" | "pruefungFaellig" | "gesperrt" | "secondary"
> = {
ENTWURF: "pruefungFaellig",
UEBERNOMMEN: "einsatzbereit",
VERWORFEN: "secondary",
FEHLGESCHLAGEN: "gesperrt",
};
/** Eine Dauer in Sekunden als m:ss. */
export function alsLaenge(sekunden: number | null | undefined): string {
if (sekunden === null || sekunden === undefined) return "—";
const minuten = Math.floor(sekunden / 60);
return `${minuten}:${String(sekunden % 60).padStart(2, "0")}`;
}
+196
View File
@@ -0,0 +1,196 @@
import "server-only";
import { KiArt, KiStand } from "@prisma/client";
import { fuerOrganisation, type MandantenClient } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import type { Sitzung } from "@/lib/authz";
/**
* Buchführung über das, was ein Modell geliefert hat.
*
* Die Regel aus Phase-8.md lautet: jede KI-Ausgabe ist gekennzeichnet und muss
* bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt.
* Diese Datei ist die Stelle, an der das durchgesetzt wird — und zwar so, dass
* es sich hinterher belegen lässt.
*
* Der Ablauf ist immer derselbe:
*
* 1. `festhalten()` schreibt, was das Modell geliefert hat. Stand: ENTWURF.
* 2. Ein Mensch sieht es an.
* 3. `entscheiden()` vermerkt, wer wann übernommen oder verworfen hat.
*
* Schritt 2 lässt sich nicht überspringen: es gibt keine Funktion, die eine
* Ausgabe erzeugt und gleich übernimmt. Wer das umgehen will, muss Code
* schreiben, und das fällt in einer Durchsicht auf.
*
* Die Datenbank hält gegen, falls doch: Eingabe, Ausgabe, Modell und Art sind
* nach dem Anlegen unveränderlich (Trigger), gelöscht werden kann ein Vorgang
* nicht (REVOKE DELETE), und über einen Vorschlag wird genau einmal
* entschieden.
*/
export interface VorgangEingabe {
art: KiArt;
modell: string;
eingabe: string;
ausgabe?: string | null;
fehler?: string | null;
entitaet?: string | null;
entitaetId?: string | null;
dauerMs?: number | null;
tokenEingabe?: number | null;
tokenAusgabe?: number | null;
erzeugtVonId?: string | null;
}
/** Wie viel von der Eingabe aufgehoben wird. Der Rest steht als Datei da. */
const MAX_EINGABE = 8000;
const MAX_AUSGABE = 40_000;
/**
* Einen Vorgang festhalten — innerhalb eines bestehenden Mandantenkontexts.
*
* Schlägt der Aufruf ans Modell fehl, wird das genauso festgehalten: ein
* Modell, das dreimal am Tag in eine Zeitüberschreitung läuft, ist eine
* Information, und ohne Eintrag merkt es niemand.
*/
export async function festhaltenIn(
db: MandantenClient,
organisationId: string,
eingabe: VorgangEingabe,
): Promise<{ id: string; stand: KiStand }> {
const fehlgeschlagen = Boolean(eingabe.fehler);
const vorgang = await db.kiVorgang.create({
data: {
organisationId,
art: eingabe.art,
modell: eingabe.modell.slice(0, 120),
stand: fehlgeschlagen ? KiStand.FEHLGESCHLAGEN : KiStand.ENTWURF,
eingabe: eingabe.eingabe.slice(0, MAX_EINGABE),
ausgabe: eingabe.ausgabe?.slice(0, MAX_AUSGABE) ?? null,
fehler: eingabe.fehler?.slice(0, 1000) ?? null,
entitaet: eingabe.entitaet ?? null,
entitaetId: eingabe.entitaetId ?? null,
dauerMs: eingabe.dauerMs ?? null,
tokenEingabe: eingabe.tokenEingabe ?? null,
tokenAusgabe: eingabe.tokenAusgabe ?? null,
erzeugtVonId: eingabe.erzeugtVonId ?? null,
},
select: { id: true, stand: true },
});
return vorgang;
}
/** Dasselbe, wenn kein Mandantenkontext offen ist. */
export async function festhalten(
organisationId: string,
eingabe: VorgangEingabe,
): Promise<{ id: string; stand: KiStand }> {
return fuerOrganisation(organisationId, (db) => festhaltenIn(db, organisationId, eingabe));
}
/**
* Übernehmen oder verwerfen.
*
* Der Audit-Eintrag ist hier wichtiger als anderswo: er ist der Beleg dafür,
* dass ein Mensch entschieden hat. Ohne ihn wäre in einem Jahr nicht mehr zu
* sagen, ob ein Satz im Einsatzbericht von der Schriftführerin stammt oder von
* einem Modell, das sie nicht gelesen hat.
*/
export async function entscheidenIn(
db: MandantenClient,
organisationId: string,
angaben: {
vorgangId: string;
uebernommen: boolean;
sitzung: Sitzung;
},
): Promise<{ stand: KiStand }> {
const vorher = await db.kiVorgang.findUnique({
where: { id: angaben.vorgangId },
select: { art: true, stand: true, modell: true, entitaet: true, entitaetId: true },
});
if (!vorher) throw new Error("Diesen KI-Vorgang gibt es nicht.");
const stand = angaben.uebernommen ? KiStand.UEBERNOMMEN : KiStand.VERWORFEN;
const jetzt = new Date();
await db.kiVorgang.update({
where: { id: angaben.vorgangId },
data: { stand, entschiedenVonId: angaben.sitzung.personId, entschiedenAm: jetzt },
});
await protokolliereIn(db, {
organisationId,
sitzung: angaben.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "KiVorgang",
entitaetId: angaben.vorgangId,
beschreibung: angaben.uebernommen
? `KI-Vorschlag (${vorher.art}, ${vorher.modell}) übernommen`
: `KI-Vorschlag (${vorher.art}, ${vorher.modell}) verworfen`,
vorher: { stand: vorher.stand },
nachher: { stand, entschiedenAm: jetzt },
kontext: { betrifft: vorher.entitaet, betrifftId: vorher.entitaetId },
});
return { stand };
}
/**
* Ein Vorgang, den der Hintergrundprozess angelegt hat.
*
* Einbettungen entstehen ohne Zutun eines Menschen und werden auch von keinem
* übernommen — sie erzeugen keinen Text, sie machen einen vorhandenen
* auffindbar. Deshalb bekommen sie keinen ENTWURF-Zustand, über den jemand
* entscheiden müsste, sondern werden gleich als erledigt vermerkt.
*
* Das ist die einzige Ausnahme von der Bestätigungspflicht, und sie ist es aus
* einem Grund: es gibt nichts zu bestätigen.
*/
export async function einbettungFesthaltenIn(
db: MandantenClient,
organisationId: string,
angaben: {
modell: string;
abschnitte: number;
seiteId: string;
dauerMs: number;
fehler?: string | null;
},
): Promise<void> {
await db.kiVorgang.create({
data: {
organisationId,
art: KiArt.EINBETTUNG,
// Kein ENTWURF: hier gibt es nichts zu entscheiden. Und kein
// UEBERNOMMEN, weil das eine Entscheidung behaupten würde, die niemand
// getroffen hat — also VERWORFEN wäre falsch und ENTWURF irreführend.
// Bleibt FEHLGESCHLAGEN oder ENTWURF; für den guten Fall ENTWURF mit
// sofortiger Entscheidung durch den Hintergrundprozess.
stand: angaben.fehler ? KiStand.FEHLGESCHLAGEN : KiStand.UEBERNOMMEN,
modell: angaben.modell.slice(0, 120),
eingabe: `${angaben.abschnitte} Abschnitte`,
ausgabe: angaben.fehler ? null : `${angaben.abschnitte} Einbettungen erzeugt`,
fehler: angaben.fehler?.slice(0, 1000) ?? null,
entitaet: "Wikiseite",
entitaetId: angaben.seiteId,
dauerMs: angaben.dauerMs,
entschiedenAm: angaben.fehler ? null : new Date(),
},
});
if (!angaben.fehler) {
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wikiseite",
entitaetId: angaben.seiteId,
beschreibung: `${angaben.abschnitte} Abschnitte eingebettet (${angaben.modell})`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
});
}
}
+289
View File
@@ -0,0 +1,289 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { fuerOrganisation, type MandantenClient } from "@/lib/db";
import { llm } from "@/lib/integrations/llm";
import { einbettungstext, fundstelle, zerlegen } from "@/lib/ki/zerlegen";
import { einbettungFesthaltenIn } from "@/lib/ki/vorgang";
/**
* Das Wiki: einbetten und finden.
*
* Hier trifft Prisma auf pgvector, und die beiden kennen sich nicht. Die
* Spalte `einbettung` ist im Schema als `Unsupported` deklariert; gelesen und
* geschrieben wird sie über `$queryRaw`. Das ist der übliche Weg — und der
* Grund, warum dieser Umgang an einer Stelle steht statt in jedem Router.
*
* **Zwei Suchen, nicht eine.** Die semantische Suche findet, was gemeint ist:
* wer „wie lange hält ein Schlauch" fragt, bekommt den Abschnitt über
* Nutzungsdauern, auch wenn dort „Ablegereife" steht. Die Wortsuche findet,
* was dasteht: wer „FwDV 7" eingibt, will die Seite mit genau dieser
* Zeichenfolge. Kein Modell ist da besser als LIKE, und beide zusammen sind
* besser als jede für sich.
*
* Jede Abfrage läuft im Mandantenkontext. Das ist bei Roh-SQL keine
* Selbstverständlichkeit, sondern eine Zusicherung, die man einhalten muss:
* die Row Level Security greift, weil `fuerOrganisation` die Transaktion
* aufspannt, in der `app.current_organisation` gesetzt ist.
*/
export interface Fundstueck {
abschnittId: string;
seiteId: string;
seitentitel: string;
slug: string;
fundstelle: string;
text: string;
quelle: string | null;
/** 0 = gleich, 2 = maximal verschieden. Kleiner ist besser. */
abstand: number;
}
/** Wie viele Abschnitte in den Zusammenhang einer Frage gehen. */
export const AUSZUEGE = 5;
/**
* Ab welchem Abstand ein Treffer nicht mehr zählt.
*
* Kosinusabstand: 0 heißt gleich, 1 heißt unabhängig. Alles über 0,6 hat mit
* der Frage nichts mehr zu tun — und ein Modell, dem man drei zufällige
* Abschnitte als Zusammenhang gibt, antwortet daraus. Lieber „steht nicht in
* den Unterlagen" als eine Antwort aus dem Nichts.
*/
export const MAX_ABSTAND = 0.6;
/**
* Einen Vektor als pgvector-Literal.
*
* pgvector nimmt die Textfassung `[0.1,0.2,...]` entgegen. Die Zahlen kommen
* aus dem Modell und sind damit vertrauenswürdig — geprüft wird trotzdem, weil
* ein NaN in dieser Zeichenkette eine Fehlermeldung erzeugt, die niemand
* versteht.
*/
export function alsVektorliteral(vektor: number[]): string {
for (const wert of vektor) {
if (!Number.isFinite(wert)) {
throw new Error("Die Einbettung enthält einen ungültigen Wert.");
}
}
return `[${vektor.join(",")}]`;
}
/**
* Eine Seite neu einbetten.
*
* Zerlegen, jeden Abschnitt einbetten, alles ersetzen. Bewusst ersetzen statt
* abgleichen: eine geänderte Überschrift verschiebt die Nummerierung, und ein
* Abgleich über Textgleichheit wäre mehr Code für denselben Zustand.
*
* Läuft im Hintergrundprozess, nicht in der Anfrage: bei einer langen Seite
* sind das zwanzig Aufrufe ans Modell, und niemand soll dafür vor einem
* drehenden Rädchen sitzen.
*/
export async function seiteEinbetten(
organisationId: string,
seiteId: string,
): Promise<{ abschnitte: number; fehler?: string }> {
const beginn = Date.now();
const dienst = llm();
const seite = await fuerOrganisation(organisationId, (db) =>
db.wikiseite.findUnique({
where: { id: seiteId },
select: { id: true, titel: true, inhalt: true, geloeschtAm: true },
}),
);
if (!seite || seite.geloeschtAm) {
return { abschnitte: 0, fehler: "Diese Seite gibt es nicht (mehr)." };
}
if (!dienst.verfuegbar()) {
return {
abschnitte: 0,
fehler:
"Der LLM-Endpunkt ist nicht eingerichtet. Die Seite ist lesbar und über die " +
"Wortsuche auffindbar, aber nicht über die semantische Suche.",
};
}
const abschnitte = zerlegen(seite.inhalt);
if (abschnitte.length === 0) {
// Eine leere Seite ist kein Fehler — sie hat nur nichts einzubetten.
await fuerOrganisation(organisationId, async (db) => {
await db.wikiabschnitt.deleteMany({ where: { seiteId } });
await db.wikiseite.update({ where: { id: seiteId }, data: { eingebettetAm: new Date() } });
});
return { abschnitte: 0 };
}
// Erst alle Einbettungen holen, dann schreiben. Andersherum stünde bei einem
// Aussetzer des Modells eine halb eingebettete Seite in der Datenbank, und
// die Suche fände die zweite Hälfte nicht mehr.
const vektoren: Array<{ text: string; ueberschrift: string | null; vektor: number[] }> = [];
for (const abschnitt of abschnitte) {
const antwort = await dienst.einbetten(einbettungstext(abschnitt, seite.titel));
if (!antwort.erfolgreich || !antwort.vektor) {
await fuerOrganisation(organisationId, (db) =>
einbettungFesthaltenIn(db, organisationId, {
modell: dienst.einbettungsModell(),
abschnitte: abschnitte.length,
seiteId,
dauerMs: Date.now() - beginn,
fehler: antwort.fehler ?? "Unbekannter Fehler beim Einbetten.",
}),
);
return { abschnitte: 0, fehler: antwort.fehler };
}
vektoren.push({
text: abschnitt.text,
ueberschrift: fundstelle("", abschnitt.pfad) || abschnitt.ueberschrift,
vektor: antwort.vektor,
});
}
await fuerOrganisation(organisationId, async (db) => {
await db.wikiabschnitt.deleteMany({ where: { seiteId } });
for (const [stelle, eintrag] of vektoren.entries()) {
// Roh-SQL, weil Prisma die Vektorspalte nicht kennt. Alle Werte gehen
// als Parameter — auch das Vektorliteral.
await db.$executeRaw`
INSERT INTO "wikiabschnitt"
("id", "organisationId", "seiteId", "reihenfolge", "ueberschrift", "text",
"einbettung", "modell", "erstelltAm")
VALUES
(gen_random_uuid(), ${organisationId}::uuid, ${seiteId}::uuid, ${stelle},
${eintrag.ueberschrift}, ${eintrag.text},
${alsVektorliteral(eintrag.vektor)}::vector, ${dienst.einbettungsModell()}, now())
`;
}
await db.wikiseite.update({ where: { id: seiteId }, data: { eingebettetAm: new Date() } });
await einbettungFesthaltenIn(db, organisationId, {
modell: dienst.einbettungsModell(),
abschnitte: vektoren.length,
seiteId,
dauerMs: Date.now() - beginn,
});
});
return { abschnitte: vektoren.length };
}
/**
* Die zur Frage passenden Abschnitte holen.
*
* Der Mandantenfilter steht zusätzlich in der Bedingung, obwohl RLS ihn
* ohnehin durchsetzt. Doppelt bei Roh-SQL: wer diese Abfrage später kopiert,
* soll den Filter im Text sehen und nicht darauf vertrauen, dass er von außen
* kommt.
*/
export async function aehnlicheAbschnitte(
db: MandantenClient,
organisationId: string,
vektor: number[],
anzahl = AUSZUEGE,
): Promise<Fundstueck[]> {
const literal = alsVektorliteral(vektor);
const zeilen = await db.$queryRaw<
Array<{
abschnittId: string;
seiteId: string;
seitentitel: string;
slug: string;
ueberschrift: string | null;
text: string;
quelle: string | null;
abstand: number;
}>
>(Prisma.sql`
SELECT
a."id" AS "abschnittId",
s."id" AS "seiteId",
s."titel" AS "seitentitel",
s."slug" AS "slug",
a."ueberschrift" AS "ueberschrift",
a."text" AS "text",
s."quelle" AS "quelle",
(a."einbettung" <=> ${literal}::vector) AS "abstand"
FROM "wikiabschnitt" a
JOIN "wikiseite" s ON s."id" = a."seiteId"
WHERE a."organisationId" = ${organisationId}::uuid
AND a."einbettung" IS NOT NULL
AND s."veroeffentlicht" = true
AND s."geloeschtAm" IS NULL
ORDER BY a."einbettung" <=> ${literal}::vector
LIMIT ${anzahl}
`);
return zeilen
.filter((zeile) => Number(zeile.abstand) <= MAX_ABSTAND)
.map((zeile) => ({
abschnittId: zeile.abschnittId,
seiteId: zeile.seiteId,
seitentitel: zeile.seitentitel,
slug: zeile.slug,
fundstelle: zeile.ueberschrift
? `${zeile.seitentitel} › ${zeile.ueberschrift}`
: zeile.seitentitel,
text: zeile.text,
quelle: zeile.quelle,
abstand: Number(zeile.abstand),
}));
}
/**
* Die semantische Suche, von der Frage bis zu den Auszügen.
*
* Gibt eine leere Liste zurück, wenn kein Modell erreichbar ist — die
* Wortsuche funktioniert weiter, und die Ansicht sagt, was fehlt.
*/
export async function passendeAuszuege(
organisationId: string,
frage: string,
anzahl = AUSZUEGE,
): Promise<{ auszuege: Fundstueck[]; fehler?: string }> {
const dienst = llm();
if (!dienst.verfuegbar()) {
return { auszuege: [], fehler: "Der LLM-Endpunkt ist nicht eingerichtet." };
}
const einbettung = await dienst.einbetten(frage);
if (!einbettung.erfolgreich || !einbettung.vektor) {
return { auszuege: [], fehler: einbettung.fehler };
}
const auszuege = await fuerOrganisation(organisationId, (db) =>
aehnlicheAbschnitte(db, organisationId, einbettung.vektor!, anzahl),
);
return { auszuege };
}
/**
* Ein sprechender Kurzname aus einem Titel.
*
* Umlaute werden ausgeschrieben, nicht weggeworfen: „Prüffristen" wird zu
* „prueffristen" und nicht zu „prffristen".
*/
export function alsWikiSlug(titel: string): string {
return titel
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 160);
}
Binary file not shown.
+4
View File
@@ -30,6 +30,10 @@ export const AUFTRAG = {
JAHRESEXPORT_ERZEUGEN: "jahresexport.erzeugen",
/** Erzeugt das Atemschutzprotokoll als PDF (Phase 5). */
ATEMSCHUTZPROTOKOLL_ERZEUGEN: "atemschutzprotokoll.erzeugen",
/** Bettet eine Wiki-Seite für die semantische Suche ein (Phase 8). */
WIKI_EINBETTEN: "wiki.einbetten",
/** Wandelt eine Diktataufnahme in Text (Phase 8). */
DIKTAT_ERKENNEN: "diktat.erkennen",
/** Stellt zu, was in der Benachrichtigungstabelle wartet (Phase 7). */
BENACHRICHTIGUNG_ZUSTELLEN: "benachrichtigung.zustellen",
/** Legt Termin- und Qualifikationserinnerungen an (Phase 7). */
+6
View File
@@ -27,6 +27,8 @@ import { lagekarteRouter, wasserfoerderungRouter } from "@/server/routers/karte/
import { terminRouter } from "@/server/routers/termin/termin";
import { benachrichtigungRouter } from "@/server/routers/termin/benachrichtigung";
import { dienstausweisRouter } from "@/server/routers/stammdaten/dienstausweis";
import { wikiRouter } from "@/server/routers/ki/wiki";
import { assistentRouter } from "@/server/routers/ki/assistent";
/**
* Wurzel-Router.
@@ -189,6 +191,10 @@ export const appRouter = router({
termin: terminRouter,
benachrichtigung: benachrichtigungRouter,
dienstausweis: dienstausweisRouter,
// Phase 8 — Lokale KI
wiki: wikiRouter,
assistent: assistentRouter,
});
export type AppRouter = typeof appRouter;
+688
View File
@@ -0,0 +1,688 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { KiArt, KiStand } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
import { llm } from "@/lib/integrations/llm";
import { spracherkennung } from "@/lib/integrations/whisper";
import { herunterladen } from "@/lib/storage";
import { BELEG_ANWEISUNG, BERICHT_ANWEISUNG, einsatzAlsEingabe } from "@/lib/ki/anweisungen";
import { belegAuswerten } from "@/lib/ki/beleg-lesen";
import { entscheidenIn, festhaltenIn } from "@/lib/ki/vorgang";
import { env } from "@/lib/env";
/**
* Der Assistent: Diktat, Berichtsentwurf, Belegerfassung.
*
* Alle drei folgen demselben Ablauf, und der ist die eigentliche Regel dieser
* Phase:
*
* 1. Ein Modell liefert etwas.
* 2. Es wird als KI-Vorgang festgehalten — Stand ENTWURF.
* 3. Ein Mensch sieht es an und übernimmt oder verwirft.
*
* Es gibt keine Prozedur, die Schritt 3 überspringt. Nichts von hier landet
* ohne Zutun in einem Einsatzbericht, einer Buchung oder einer Akte.
*
* **Ohne eingerichtete Modelle fehlt die Funktion**, sie wird nicht durch
* einen fremden Dienst ersetzt. Jede Prozedur sagt das mit einem Satz, den ein
* Mensch versteht, und nennt den Weg von Hand — der bleibt immer offen.
*/
export const assistentRouter = router({
/**
* Was in dieser Installation überhaupt geht.
*
* Die Oberfläche blendet danach aus. Ein Diktatknopf, der immer „nicht
* eingerichtet" sagt, ist schlimmer als kein Diktatknopf.
*/
moeglichkeiten: angemeldet.query(() => {
const dienst = llm();
const sprache = spracherkennung();
return {
text: dienst.verfuegbar(),
bild: dienst.bildLesenMoeglich(),
diktat: sprache.verfuegbar(),
/** Für den Hinweis in der Oberfläche, wenn nichts eingerichtet ist. */
modell: env.LLM_MODELL ?? null,
visionModell: env.LLM_VISION_MODELL ?? null,
einbettungsModell: dienst.einbettungsModell(),
maxSekunden: env.DIKTAT_MAX_SEKUNDEN,
};
}),
// ---------------------------------------------------------------------------
// Diktat
// ---------------------------------------------------------------------------
/**
* Eine Aufnahme zur Erkennung geben.
*
* Die Datei liegt schon in MinIO (über /api/dateien); hier wird nur der
* Auftrag eingestellt. Die Erkennung läuft im Hintergrund, weil sie bei
* zehn Minuten Sprache auf einem Rechner ohne Grafikkarte Minuten dauert —
* und niemand soll dafür vor einem drehenden Rädchen sitzen.
*/
diktatEinreichen: mitRecht("ki.nutzen")
.input(
z.object({
audioDateiId: z.string().uuid(),
dauerSekunden: z.number().int().min(0).max(7200).nullish(),
entitaet: z.string().trim().max(80).nullish(),
entitaetId: z.string().uuid().nullish(),
}),
)
.mutation(async ({ ctx, input }) => {
if (!spracherkennung().verfuegbar()) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"Das Diktat ist in dieser Installation nicht eingerichtet (WHISPER_ENABLED). " +
"Der Text lässt sich von Hand eintragen.",
});
}
if (input.dauerSekunden && input.dauerSekunden > env.DIKTAT_MAX_SEKUNDEN) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
`Die Aufnahme ist länger als ${Math.round(env.DIKTAT_MAX_SEKUNDEN / 60)} Minuten. ` +
"Bitte in mehreren Abschnitten aufnehmen — das wird auch besser erkannt.",
});
}
const diktat = await ctx.db(async (db) => {
const datei = await db.datei.findUnique({
where: { id: input.audioDateiId },
select: { id: true, mimeTyp: true },
});
if (!datei) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Aufnahme gibt es nicht." });
}
const angelegt = await db.diktat.create({
data: {
organisationId: ctx.sitzung.organisationId,
audioDateiId: input.audioDateiId,
dauerSekunden: input.dauerSekunden ?? null,
sprache: env.WHISPER_SPRACHE,
entitaet: input.entitaet ?? null,
entitaetId: input.entitaetId ?? null,
erzeugtVonId: ctx.sitzung.personId,
},
select: { id: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Diktat",
entitaetId: angelegt.id,
beschreibung: `Diktat eingereicht (${input.dauerSekunden ?? "?"} s)`,
kontext: { betrifft: input.entitaet, betrifftId: input.entitaetId },
});
return angelegt;
});
await beauftrage(AUFTRAG.DIKTAT_ERKENNEN, {
organisationId: ctx.sitzung.organisationId,
diktatId: diktat.id,
}).catch((fehler) => {
console.error("[diktat] Auftrag ließ sich nicht einstellen:", fehler);
});
return { id: diktat.id };
}),
/** Der Stand einer Aufnahme — die Ansicht fragt in Abständen nach. */
diktat: mitRecht("ki.nutzen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const diktat = await ctx.db((db) =>
db.diktat.findUnique({
where: { id: input.id },
include: { kiVorgang: { select: { fehler: true, dauerMs: true, modell: true } } },
}),
);
if (!diktat || diktat.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Diktat gibt es nicht." });
}
return diktat;
}),
/** Die eigenen Diktate, jüngste zuerst. */
diktate: mitRecht("ki.nutzen")
.input(
z
.object({
entitaet: z.string().trim().max(80).optional(),
entitaetId: z.string().uuid().optional(),
anzahl: z.number().int().min(1).max(100).default(20),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.diktat.findMany({
where: {
geloeschtAm: null,
...(input?.entitaet ? { entitaet: input.entitaet } : {}),
...(input?.entitaetId ? { entitaetId: input.entitaetId } : {}),
},
orderBy: { erstelltAm: "desc" },
take: input?.anzahl ?? 20,
select: {
id: true,
transkript: true,
stand: true,
dauerSekunden: true,
entitaet: true,
entitaetId: true,
erstelltAm: true,
erzeugtVon: { select: { vorname: true, nachname: true } },
},
}),
),
),
/**
* Ein Transkript übernehmen oder verwerfen.
*
* Übernehmen heißt hier ausdrücklich **nicht**, dass der Text irgendwo
* eingetragen wird — das tut die Ansicht, die ihn angefordert hat. Hier wird
* nur festgehalten, dass ein Mensch ihn gesehen und für richtig befunden hat.
*/
diktatEntscheiden: mitRecht("ki.nutzen")
.input(z.object({ id: z.string().uuid(), uebernommen: z.boolean() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const diktat = await db.diktat.findUnique({
where: { id: input.id },
select: { id: true, stand: true, kiVorgangId: true },
});
if (!diktat) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Diktat gibt es nicht." });
}
if (diktat.stand !== KiStand.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Über dieses Diktat wurde bereits entschieden.",
});
}
const stand = input.uebernommen ? KiStand.UEBERNOMMEN : KiStand.VERWORFEN;
await db.diktat.update({ where: { id: input.id }, data: { stand } });
if (diktat.kiVorgangId) {
await entscheidenIn(db, ctx.sitzung.organisationId, {
vorgangId: diktat.kiVorgangId,
uebernommen: input.uebernommen,
sitzung: ctx.sitzung,
});
}
return { stand };
}),
),
// ---------------------------------------------------------------------------
// Berichtsentwurf
// ---------------------------------------------------------------------------
/**
* Aus Stichpunkten einen Einsatzberichtsentwurf.
*
* Läuft in der Anfrage und nicht im Hintergrund: wer Stichpunkte eintippt,
* wartet auf das Ergebnis und will es sofort sehen. Ein Bericht ist ein paar
* hundert Token — das dauert auch ohne Grafikkarte keine Minute.
*
* Zurück kommt ein **Vorschlag**, kein Bericht. Was damit geschieht,
* entscheidet die Ansicht daneben — und die hat zwei Knöpfe.
*/
berichtEntwerfen: mitRecht("einsatz.bericht")
.input(
z.object({
einsatzId: z.string().uuid(),
stichpunkte: z.string().trim().min(10).max(8000),
lage: z.string().trim().max(2000).nullish(),
}),
)
.mutation(async ({ ctx, input }) => {
const dienst = llm();
if (!dienst.verfuegbar()) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"Es ist kein Sprachmodell eingerichtet (LLM_ENABLED). Der Bericht lässt sich " +
"wie bisher von Hand schreiben.",
});
}
const einsatz = await ctx.db((db) =>
db.einsatz.findUnique({
where: { id: input.einsatzId },
select: {
id: true,
einsatznummer: true,
stichwort: true,
kategorie: true,
ort: true,
strasse: true,
alarmiertUm: true,
einsatzendeUm: true,
status: true,
_count: { select: { kraefte: true } },
fahrzeuge: { select: { fahrzeug: { select: { funkrufname: true } } } },
},
}),
);
if (!einsatz) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Einsatz gibt es nicht." });
}
if (einsatz.status === "FREIGEGEBEN") {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Dieser Bericht ist freigegeben und damit festgeschrieben. Ein Entwurf käme zu spät.",
});
}
const dauerMinuten =
einsatz.alarmiertUm && einsatz.einsatzendeUm
? Math.round(
(einsatz.einsatzendeUm.getTime() - einsatz.alarmiertUm.getTime()) / 60_000,
)
: null;
const eingabe = einsatzAlsEingabe({
stichwort: einsatz.stichwort,
kategorie: einsatz.kategorie,
ort: [einsatz.strasse, einsatz.ort].filter(Boolean).join(", ") || null,
lage: input.lage ?? null,
stichpunkte: input.stichpunkte,
fahrzeuge: einsatz.fahrzeuge.map((eintrag) => eintrag.fahrzeug.funkrufname),
kraefte: einsatz._count.kraefte,
dauerMinuten,
});
const antwort = await dienst.vervollstaendigen({
systemAnweisung: BERICHT_ANWEISUNG,
benutzerEingabe: eingabe,
maxToken: 1200,
});
const vorgang = await ctx.db((db) =>
festhaltenIn(db, ctx.sitzung.organisationId, {
art: KiArt.BERICHTSENTWURF,
modell: antwort.modell ?? env.LLM_MODELL ?? "unbekannt",
eingabe,
ausgabe: antwort.text ?? null,
fehler: antwort.erfolgreich ? null : (antwort.fehler ?? "Unbekannter Fehler"),
entitaet: "Einsatz",
entitaetId: einsatz.id,
dauerMs: antwort.dauerMs ?? null,
tokenEingabe: antwort.tokenEingabe ?? null,
tokenAusgabe: antwort.tokenAusgabe ?? null,
erzeugtVonId: ctx.sitzung.personId,
}),
);
if (!antwort.erfolgreich) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: antwort.fehler ?? "Das Modell hat nicht geantwortet.",
});
}
return {
vorgangId: vorgang.id,
entwurf: antwort.text!,
modell: antwort.modell ?? env.LLM_MODELL,
dauerMs: antwort.dauerMs,
};
}),
/**
* Einen Vorschlag übernehmen oder verwerfen.
*
* Gilt für Berichtsentwürfe und Wiki-Antworten. Das eigentliche Eintragen
* macht die Ansicht — hier wird festgehalten, dass ein Mensch entschieden
* hat, und genau das ist der Zweck der Tabelle.
*/
entscheiden: mitRecht("ki.nutzen")
.input(z.object({ vorgangId: z.string().uuid(), uebernommen: z.boolean() }))
.mutation(async ({ ctx, input }) =>
ctx.db((db) =>
entscheidenIn(db, ctx.sitzung.organisationId, {
vorgangId: input.vorgangId,
uebernommen: input.uebernommen,
sitzung: ctx.sitzung,
}),
),
),
// ---------------------------------------------------------------------------
// Belegerfassung
// ---------------------------------------------------------------------------
/**
* Ein Belegfoto auswerten.
*
* Ergebnis ist ein **ausgefülltes Formular**, keine Buchung. Alle Felder
* sind änderbar, und was das Modell nicht erkannt hat, bleibt leer statt
* geraten zu werden.
*
* Ohne Vision-Modell entsteht trotzdem ein Entwurf — nur eben ein leerer.
* Das ist besser als eine Fehlermeldung: der Beleg ist fotografiert und
* abgelegt, und die Zahlen trägt jemand von Hand ein.
*/
belegErfassen: mitRecht("beleg.erfassen")
.input(z.object({ fotoDateiId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const dienst = llm();
const datei = await ctx.db((db) =>
db.datei.findUnique({
where: { id: input.fotoDateiId },
select: { id: true, objektSchluessel: true, mimeTyp: true },
}),
);
if (!datei) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Foto gibt es nicht." });
}
if (!datei.mimeTyp.startsWith("image/")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Für die Belegerfassung wird ein Foto gebraucht, kein PDF.",
});
}
let ausgewertet = belegAuswerten("");
let vorgangId: string | null = null;
let hinweis: string | null = null;
if (dienst.bildLesenMoeglich()) {
const inhalt = await herunterladen(datei.objektSchluessel);
const datenUri = `data:${datei.mimeTyp};base64,${inhalt.toString("base64")}`;
const antwort = await dienst.bildLesen({
systemAnweisung: BELEG_ANWEISUNG,
benutzerEingabe: "Lies diesen Beleg aus.",
bilder: [datenUri],
maxToken: 500,
temperatur: 0,
alsJson: true,
});
const vorgang = await ctx.db((db) =>
festhaltenIn(db, ctx.sitzung.organisationId, {
art: KiArt.BELEGERFASSUNG,
modell: antwort.modell ?? env.LLM_VISION_MODELL ?? "unbekannt",
// Nicht das Bild: das liegt als Datei da. Nur der Verweis darauf.
eingabe: `Beleg-Foto ${datei.id}`,
ausgabe: antwort.text ?? null,
fehler: antwort.erfolgreich ? null : (antwort.fehler ?? "Unbekannter Fehler"),
entitaet: "Datei",
entitaetId: datei.id,
dauerMs: antwort.dauerMs ?? null,
erzeugtVonId: ctx.sitzung.personId,
}),
);
vorgangId = vorgang.id;
if (antwort.erfolgreich && antwort.text) {
ausgewertet = belegAuswerten(antwort.text);
if (ausgewertet.erkannt.length === 0) {
hinweis =
"Auf dem Foto war nichts eindeutig zu lesen. Bitte die Angaben von Hand " +
"eintragen — ein schärferes Foto hilft beim nächsten Mal.";
}
} else {
hinweis = antwort.fehler ?? "Das Modell hat nicht geantwortet.";
}
} else {
hinweis =
"Es ist kein Modell eingerichtet, das Bilder lesen kann (LLM_VISION_MODELL). " +
"Der Beleg ist abgelegt — die Angaben bitte von Hand eintragen.";
}
const entwurf = await ctx.db(async (db) => {
const angelegt = await db.belegentwurf.create({
data: {
organisationId: ctx.sitzung.organisationId,
fotoDateiId: datei.id,
betrag: ausgewertet.betrag,
waehrung: ausgewertet.waehrung ?? "EUR",
belegdatum: ausgewertet.belegdatum,
haendler: ausgewertet.haendler,
steuersatz: ausgewertet.steuersatz,
kategorie: ausgewertet.kategorie,
kiVorgangId: vorgangId,
erfasstVonId: ctx.sitzung.personId,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Belegentwurf",
entitaetId: angelegt.id,
beschreibung: `Beleg erfasst (${ausgewertet.erkannt.length} von 6 Angaben erkannt)`,
nachher: {
betrag: ausgewertet.betrag,
haendler: ausgewertet.haendler,
erkannt: ausgewertet.erkannt,
},
});
return angelegt;
});
return { entwurf, erkannt: ausgewertet.erkannt, fehlend: ausgewertet.fehlend, hinweis };
}),
belege: mitRecht("beleg.erfassen")
.input(
z
.object({
stand: z.nativeEnum(KiStand).optional(),
anzahl: z.number().int().min(1).max(200).default(50),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.belegentwurf.findMany({
where: { geloeschtAm: null, ...(input?.stand ? { stand: input.stand } : {}) },
orderBy: { erstelltAm: "desc" },
take: input?.anzahl ?? 50,
include: {
fotoDatei: { select: { id: true, dateiname: true, mimeTyp: true } },
erfasstVon: { select: { vorname: true, nachname: true } },
bestaetigtVon: { select: { vorname: true, nachname: true } },
},
}),
),
),
/**
* Einen Belegentwurf gegenzeichnen.
*
* Die Felder sind änderbar — genau dafür ist es ein Entwurf. Wer den Betrag
* berichtigt und dann bestätigt, hat gegengezeichnet, was danach dasteht,
* nicht was das Modell gelesen hat. Beides bleibt nachvollziehbar: der
* KI-Vorgang daneben hält die ursprüngliche Antwort fest.
*/
belegBestaetigen: mitRecht("beleg.erfassen")
.input(
z.object({
id: z.string().uuid(),
betrag: z.number().min(0).max(1_000_000).nullish(),
waehrung: z.string().trim().length(3).default("EUR"),
belegdatum: z.date().nullish(),
haendler: z.string().trim().max(200).nullish(),
kategorie: z.string().trim().max(80).nullish(),
steuersatz: z.number().min(0).max(100).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...felder } = input;
const vorher = await db.belegentwurf.findUnique({ where: { id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beleg gibt es nicht." });
}
if (vorher.stand !== KiStand.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Beleg ist bereits gegengezeichnet.",
});
}
if (felder.betrag === null || felder.betrag === undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ohne Betrag lässt sich der Beleg nicht bestätigen.",
});
}
const nachher = await db.belegentwurf.update({
where: { id },
data: {
...felder,
stand: KiStand.UEBERNOMMEN,
bestaetigtVonId: ctx.sitzung.personId,
bestaetigtAm: new Date(),
},
});
if (vorher.kiVorgangId) {
await entscheidenIn(db, ctx.sitzung.organisationId, {
vorgangId: vorher.kiVorgangId,
uebernommen: true,
sitzung: ctx.sitzung,
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Belegentwurf",
entitaetId: id,
beschreibung: `Beleg über ${nachher.betrag?.toString() ?? "—"} ${nachher.waehrung} gegengezeichnet`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return nachher;
}),
),
belegVerwerfen: mitRecht("beleg.erfassen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const beleg = await db.belegentwurf.findUnique({ where: { id: input.id } });
if (!beleg || beleg.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Beleg gibt es nicht." });
}
await db.belegentwurf.update({
where: { id: input.id },
data: { stand: KiStand.VERWORFEN, geloeschtAm: new Date() },
});
if (beleg.kiVorgangId && beleg.stand === KiStand.ENTWURF) {
await entscheidenIn(db, ctx.sitzung.organisationId, {
vorgangId: beleg.kiVorgangId,
uebernommen: false,
sitzung: ctx.sitzung,
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GELOESCHT,
entitaet: "Belegentwurf",
entitaetId: input.id,
beschreibung: "Belegentwurf verworfen",
});
return { erfolgreich: true };
}),
),
// ---------------------------------------------------------------------------
// Protokoll
// ---------------------------------------------------------------------------
/**
* Alle KI-Vorgänge.
*
* Die Antwort auf die Frage, die irgendwann jemand stellt: „Was macht das
* Ding eigentlich?" Hier steht es — jede Befragung, jedes Modell, jede
* Entscheidung, mit Namen und Zeitpunkt.
*/
protokoll: mitRecht("ki.protokoll")
.input(
z
.object({
art: z.nativeEnum(KiArt).optional(),
stand: z.nativeEnum(KiStand).optional(),
anzahl: z.number().int().min(1).max(200).default(50),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.kiVorgang.findMany({
where: {
...(input?.art ? { art: input.art } : {}),
...(input?.stand ? { stand: input.stand } : {}),
},
orderBy: { erstelltAm: "desc" },
take: input?.anzahl ?? 50,
include: {
erzeugtVon: { select: { vorname: true, nachname: true } },
entschiedenVon: { select: { vorname: true, nachname: true } },
},
}),
),
),
/** Kennzahlen für die Übersicht. */
kennzahlen: mitRecht("ki.protokoll").query(async ({ ctx }) =>
ctx.db(async (db) => {
const [gesamt, offen, uebernommen, verworfen, fehlgeschlagen] = await Promise.all([
db.kiVorgang.count(),
db.kiVorgang.count({ where: { stand: KiStand.ENTWURF } }),
db.kiVorgang.count({ where: { stand: KiStand.UEBERNOMMEN } }),
db.kiVorgang.count({ where: { stand: KiStand.VERWORFEN } }),
db.kiVorgang.count({ where: { stand: KiStand.FEHLGESCHLAGEN } }),
]);
return { gesamt, offen, uebernommen, verworfen, fehlgeschlagen };
}),
),
});
+492
View File
@@ -0,0 +1,492 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { KiArt, WikiKategorie } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
import { llm } from "@/lib/integrations/llm";
import { WIKI_ANWEISUNG, wikiAlsEingabe } from "@/lib/ki/anweisungen";
import { alsWikiSlug, passendeAuszuege } from "@/lib/ki/wiki";
import { festhaltenIn } from "@/lib/ki/vorgang";
import { zerlegen } from "@/lib/ki/zerlegen";
/**
* Das Wiki der Wehr.
*
* Dienstvorschriften, Ausbildungsunterlagen, das, was sonst als PDF im
* Ordner steht und im Einsatz niemand findet. Inhalt ist Markdown, weil es
* sich ohne Werkzeug lesen lässt — auch in zwanzig Jahren, auch ohne FLORIAN.
*
* Zwei Suchen nebeneinander:
*
* `suchen` Wortsuche. Findet, was dasteht. Funktioniert immer, auch ohne
* Modell.
* `fragen` Semantische Suche mit Antwort. Findet, was gemeint ist —
* braucht ein Modell und sagt es, wenn keines da ist.
*
* Die Antwort ist ein KI-Vorgang wie jeder andere: sie wird festgehalten, ist
* als Vorschlag gekennzeichnet und nennt ihre Fundstellen. Ohne Fundstelle
* wäre sie wertlos — eine Antwort zur Prüffrist von Steckleitern muss sagen,
* wo sie herkommt.
*/
const seitenFelder = z.object({
titel: z.string().trim().min(1).max(200),
art: z.nativeEnum(WikiKategorie).default(WikiKategorie.SONSTIGES),
inhalt: z.string().max(200_000),
quelle: z.string().trim().max(300).nullish(),
schlagworte: z.array(z.string().trim().min(1).max(60)).max(30).default([]),
veroeffentlicht: z.boolean().default(true),
});
const listenauswahl = {
id: true,
slug: true,
titel: true,
art: true,
quelle: true,
schlagworte: true,
veroeffentlicht: true,
eingebettetAm: true,
geaendertAm: true,
autor: { select: { id: true, vorname: true, nachname: true } },
} as const;
export const wikiRouter = router({
liste: mitRecht("wiki.lesen")
.input(
z
.object({
suche: z.string().trim().max(200).optional(),
art: z.nativeEnum(WikiKategorie).optional(),
mitEntwuerfen: z.boolean().default(false),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.wikiseite.findMany({
where: {
geloeschtAm: null,
...(input?.mitEntwuerfen ? {} : { veroeffentlicht: true }),
...(input?.art ? { art: input.art } : {}),
...(input?.suche
? {
OR: [
{ titel: { contains: input.suche, mode: "insensitive" } },
{ inhalt: { contains: input.suche, mode: "insensitive" } },
{ schlagworte: { has: input.suche.toLowerCase() } },
],
}
: {}),
},
orderBy: [{ art: "asc" }, { titel: "asc" }],
take: 300,
select: listenauswahl,
}),
),
),
/**
* Wortsuche mit Textstelle.
*
* Gibt nicht nur die Seite zurück, sondern den Ausschnitt, in dem der
* Begriff steht. Wer „Ablegereife" sucht, will sehen, was dort steht, und
* nicht erst eine Seite mit vierzig Absätzen öffnen.
*/
suchen: mitRecht("wiki.lesen")
.input(z.object({ begriff: z.string().trim().min(2).max(200) }))
.query(async ({ ctx, input }) => {
const seiten = await ctx.db((db) =>
db.wikiseite.findMany({
where: {
geloeschtAm: null,
veroeffentlicht: true,
OR: [
{ titel: { contains: input.begriff, mode: "insensitive" } },
{ inhalt: { contains: input.begriff, mode: "insensitive" } },
],
},
orderBy: { titel: "asc" },
take: 30,
select: { id: true, slug: true, titel: true, art: true, inhalt: true, quelle: true },
}),
);
const begriffKlein = input.begriff.toLowerCase();
return seiten.map((seite) => {
const stelle = seite.inhalt.toLowerCase().indexOf(begriffKlein);
const von = Math.max(0, stelle - 120);
const bis = stelle === -1 ? 240 : stelle + input.begriff.length + 120;
const ausschnitt = seite.inhalt.slice(von, bis).replace(/\s+/g, " ").trim();
return {
id: seite.id,
slug: seite.slug,
titel: seite.titel,
art: seite.art,
quelle: seite.quelle,
// Auslassungszeichen nur, wo wirklich etwas fehlt.
ausschnitt: `${von > 0 ? "… " : ""}${ausschnitt}${bis < seite.inhalt.length ? " …" : ""}`,
imTitel: stelle === -1,
};
});
}),
einzeln: mitRecht("wiki.lesen")
.input(z.object({ slug: z.string().trim().min(1).max(160) }))
.query(async ({ ctx, input }) => {
const seite = await ctx.db((db) =>
db.wikiseite.findUnique({
where: { organisationId_slug: { organisationId: ctx.sitzung.organisationId, slug: input.slug } },
include: {
autor: { select: { id: true, vorname: true, nachname: true } },
versionen: {
orderBy: { nummer: "desc" },
take: 20,
select: {
id: true,
nummer: true,
titel: true,
bemerkung: true,
erstelltAm: true,
autor: { select: { vorname: true, nachname: true } },
},
},
_count: { select: { abschnitte: true } },
},
}),
);
if (!seite || seite.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Seite gibt es nicht." });
}
return {
...seite,
abschnitte: seite._count.abschnitte,
// Sichtbar machen, wenn die Suche nicht mehr auf dem Stand des Textes
// ist. Sonst sucht jemand nach einem Absatz, den er selbst geschrieben
// hat, und findet ihn nicht.
suchstandVeraltet:
seite.eingebettetAm === null ||
seite.eingebettetAm.getTime() < seite.geaendertAm.getTime(),
};
}),
anlegen: mitRecht("wiki.bearbeiten")
.input(seitenFelder)
.mutation(async ({ ctx, input }) => {
const slug = alsWikiSlug(input.titel);
if (!slug) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Aus diesem Titel lässt sich keine Adresse bilden. Bitte Buchstaben verwenden.",
});
}
const seite = await ctx.db(async (db) => {
const vorhanden = await db.wikiseite.findUnique({
where: { organisationId_slug: { organisationId: ctx.sitzung.organisationId, slug } },
select: { id: true, geloeschtAm: true },
});
if (vorhanden && !vorhanden.geloeschtAm) {
throw new TRPCError({
code: "CONFLICT",
message: `Eine Seite mit dem Titel „${input.titel}" gibt es bereits.`,
});
}
const angelegt = await db.wikiseite.create({
data: {
...input,
organisationId: ctx.sitzung.organisationId,
// Schlagworte klein: sonst findet „Atemschutz" nicht, was unter
// „atemschutz" abgelegt wurde.
schlagworte: input.schlagworte.map((wort) => wort.toLowerCase()),
slug,
autorId: ctx.sitzung.personId,
},
select: { id: true, slug: true, titel: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.ERSTELLT,
entitaet: "Wikiseite",
entitaetId: angelegt.id,
beschreibung: `Wiki-Seite „${input.titel}" angelegt`,
nachher: { titel: input.titel, art: input.art, veroeffentlicht: input.veroeffentlicht },
});
return angelegt;
});
await einbettenBeauftragen(ctx.sitzung.organisationId, seite.id);
return seite;
}),
aendern: mitRecht("wiki.bearbeiten")
.input(seitenFelder.partial().extend({ id: z.string().uuid(), bemerkung: z.string().trim().max(500).nullish() }))
.mutation(async ({ ctx, input }) => {
const { id, bemerkung, ...felder } = input;
const ergebnis = await ctx.db(async (db) => {
const vorher = await db.wikiseite.findUnique({ where: { id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Seite gibt es nicht." });
}
// Die alte Fassung wegschreiben, bevor die neue sie ersetzt. Eine
// Dienstvorschrift, die sich unbemerkt ändert, ist schlimmer als
// keine.
const letzte = await db.wikiversion.findFirst({
where: { seiteId: id },
orderBy: { nummer: "desc" },
select: { nummer: true },
});
await db.wikiversion.create({
data: {
organisationId: ctx.sitzung.organisationId,
seiteId: id,
nummer: (letzte?.nummer ?? 0) + 1,
titel: vorher.titel,
inhalt: vorher.inhalt,
bemerkung: bemerkung ?? null,
autorId: ctx.sitzung.personId,
},
});
const nachher = await db.wikiseite.update({
where: { id },
data: {
...felder,
...(felder.schlagworte
? { schlagworte: felder.schlagworte.map((wort) => wort.toLowerCase()) }
: {}),
// Der Kurzname bleibt, auch wenn der Titel sich ändert: sonst
// führen Verweise aus Berichten und Lesezeichen ins Leere.
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wikiseite",
entitaetId: id,
beschreibung: `Wiki-Seite „${nachher.titel}" geändert${bemerkung ? `: ${bemerkung}` : ""}`,
vorher: vorher as unknown as Record<string, unknown>,
nachher: nachher as unknown as Record<string, unknown>,
});
return { seite: nachher, textGeaendert: felder.inhalt !== undefined && felder.inhalt !== vorher.inhalt };
});
// Nur neu einbetten, wenn sich der Text geändert hat. Ein umgesetztes
// Schlagwort ist kein Grund, zwanzig Modellaufrufe auszulösen.
if (ergebnis.textGeaendert) {
await einbettenBeauftragen(ctx.sitzung.organisationId, id);
}
return ergebnis.seite;
}),
loeschen: mitRecht("wiki.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const seite = await db.wikiseite.findUnique({
where: { id: input.id },
select: { titel: true },
});
if (!seite) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Seite gibt es nicht." });
}
await db.wikiseite.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), veroeffentlicht: false },
});
// Die Abschnitte gehen mit: eine gelöschte Seite soll nicht mehr in
// Antworten auftauchen. Der Text bleibt in der Seite selbst und in den
// Fassungen erhalten.
await db.wikiabschnitt.deleteMany({ where: { seiteId: input.id } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
sitzung: ctx.sitzung,
aktion: AuditAktion.GELOESCHT,
entitaet: "Wikiseite",
entitaetId: input.id,
beschreibung: `Wiki-Seite „${seite.titel}" gelöscht`,
});
return { erfolgreich: true };
}),
),
/** Eine frühere Fassung im Wortlaut. */
fassung: mitRecht("wiki.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) => {
const fassung = await ctx.db((db) =>
db.wikiversion.findUnique({
where: { id: input.id },
include: { autor: { select: { vorname: true, nachname: true } } },
}),
);
if (!fassung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Fassung gibt es nicht." });
}
return fassung;
}),
/** Wie eine Seite zerlegt würde — beim Schreiben sichtbar. */
vorschauAbschnitte: mitRecht("wiki.bearbeiten")
.input(z.object({ inhalt: z.string().max(200_000) }))
.query(({ input }) => {
const abschnitte = zerlegen(input.inhalt);
return {
anzahl: abschnitte.length,
abschnitte: abschnitte.slice(0, 50).map((abschnitt) => ({
ueberschrift: abschnitt.pfad.join(" › ") || null,
zeichen: abschnitt.text.length,
anfang: abschnitt.text.slice(0, 120),
})),
};
}),
/** Die Einbettung von Hand anstoßen — nach einem Modellwechsel. */
neuEinbetten: mitRecht("wiki.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) => {
await einbettenBeauftragen(ctx.sitzung.organisationId, input.id);
return {
erfolgreich: true,
hinweis:
"Die Seite wird im Hintergrund neu eingebettet. Bei einer langen Seite dauert das " +
"eine Minute — solange bleibt der alte Suchstand nutzbar.",
};
}),
// ---------------------------------------------------------------------------
// Fragen
// ---------------------------------------------------------------------------
/**
* Eine Frage an die eigenen Unterlagen.
*
* Der wichtigste Fall ist der, in dem nichts gefunden wird: dann sagt die
* Antwort das, statt etwas Plausibles zu erfinden. Ein Modell, das auf die
* Frage nach der Prüffrist von Steckleitern eine Zahl nennt, die nirgends
* steht, ist gefährlicher als gar keine Antwort.
*/
fragen: mitRecht("wiki.lesen")
.input(z.object({ frage: z.string().trim().min(3).max(1000) }))
.mutation(async ({ ctx, input }) => {
const dienst = llm();
if (!dienst.verfuegbar()) {
return {
erfolgreich: false as const,
fehler:
"Es ist kein Sprachmodell eingerichtet. Die Wortsuche funktioniert weiter — " +
"sie findet, was wörtlich in den Unterlagen steht.",
auszuege: [],
antwort: null,
vorgangId: null,
};
}
const { auszuege, fehler } = await passendeAuszuege(ctx.sitzung.organisationId, input.frage);
if (fehler) {
return { erfolgreich: false as const, fehler, auszuege: [], antwort: null, vorgangId: null };
}
if (auszuege.length === 0) {
// Kein Modellaufruf ohne Zusammenhang. Es gäbe nichts zu antworten,
// und ein Modell, das trotzdem antwortet, antwortet aus sich selbst.
return {
erfolgreich: false as const,
fehler:
"Dazu steht nichts in den hinterlegten Unterlagen. Entweder ist die Seite noch " +
"nicht angelegt, oder sie ist noch nicht durchsuchbar gemacht worden.",
auszuege: [],
antwort: null,
vorgangId: null,
};
}
const eingabe = wikiAlsEingabe(input.frage, auszuege);
const antwort = await dienst.vervollstaendigen({
systemAnweisung: WIKI_ANWEISUNG,
benutzerEingabe: eingabe,
maxToken: 800,
// Niedrig: hier soll nichts formuliert, sondern zusammengetragen werden.
temperatur: 0.1,
});
const vorgang = await ctx.db((db) =>
festhaltenIn(db, ctx.sitzung.organisationId, {
art: KiArt.WIKI_ANTWORT,
modell: antwort.modell ?? "unbekannt",
eingabe: input.frage,
ausgabe: antwort.text ?? null,
fehler: antwort.erfolgreich ? null : (antwort.fehler ?? "Unbekannter Fehler"),
dauerMs: antwort.dauerMs ?? null,
tokenEingabe: antwort.tokenEingabe ?? null,
tokenAusgabe: antwort.tokenAusgabe ?? null,
erzeugtVonId: ctx.sitzung.personId,
}),
);
if (!antwort.erfolgreich) {
return {
erfolgreich: false as const,
fehler: antwort.fehler ?? "Das Modell hat nicht geantwortet.",
// Die Auszüge trotzdem mitgeben: sie sind für sich schon nützlich,
// auch ohne dass jemand sie zusammengefasst hat.
auszuege,
antwort: null,
vorgangId: vorgang.id,
};
}
return {
erfolgreich: true as const,
antwort: antwort.text!,
auszuege,
vorgangId: vorgang.id,
fehler: null,
};
}),
});
/**
* Die Einbettung in die Warteschlange geben.
*
* Fehlschläge werden geloggt und nicht weitergereicht: dass die Suche später
* aktualisiert wird, darf das Speichern einer Seite nicht verhindern. Steht
* der Worker still, sagt die Seitenansicht, dass der Suchstand veraltet ist.
*/
async function einbettenBeauftragen(organisationId: string, seiteId: string): Promise<void> {
await beauftrage(AUFTRAG.WIKI_EINBETTEN, { organisationId, seiteId }).catch((fehler) => {
console.error("[wiki] Einbettungsauftrag ließ sich nicht einstellen:", fehler);
});
}
+38
View File
@@ -21,6 +21,7 @@ import {
morgenlauf,
zustellen,
} from "@/worker/benachrichtigung";
import { diktatErkennen, veralteteSeitenEinbetten, wikiEinbetten } from "@/worker/ki";
/**
* Hintergrundprozess.
@@ -166,6 +167,34 @@ async function benachrichtigungErinnern(auftrag: Job<AuftragsDaten>) {
return ergebnis;
}
/**
* Eine Wiki-Seite durchsuchbar machen.
*
* Im Hintergrund, weil eine lange Seite zwanzig Modellaufrufe bedeutet. Bis
* es durch ist, bleibt der alte Suchstand nutzbar — und die Seitenansicht
* sagt, dass er veraltet ist.
*/
async function wikiEinbettenAuftrag(auftrag: Job<AuftragsDaten>) {
const { organisationId, seiteId } = auftrag.data as AuftragsDaten & { seiteId?: string };
if (!seiteId) {
throw new Error("Einbettungsauftrag ohne Angabe, wofür — seiteId fehlt.");
}
return wikiEinbetten(organisationId, seiteId);
}
/** Eine Diktataufnahme in Text verwandeln. */
async function diktatErkennenAuftrag(auftrag: Job<AuftragsDaten>) {
const { organisationId, diktatId } = auftrag.data as AuftragsDaten & { diktatId?: string };
if (!diktatId) {
throw new Error("Diktatauftrag ohne Angabe, wofür — diktatId fehlt.");
}
return diktatErkennen(organisationId, diktatId);
}
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
@@ -173,6 +202,13 @@ async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfe
// Fehlgeschlagene bleiben — sie zeigen einen kaputten Kanal an.
const benachrichtigungen = await benachrichtigungenAufraeumen(auftrag.data.organisationId);
// Seit Phase 8: Wiki-Seiten nachziehen, deren Suchstand veraltet ist. Fängt
// den Fall ab, dass der Worker stand oder das Modell nicht erreichbar war,
// als jemand gespeichert hat.
await veralteteSeitenEinbetten(auftrag.data.organisationId).catch((fehler) => {
console.error("[worker] Nachträgliches Einbetten fehlgeschlagen:", fehler);
});
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
return { entfernt: benachrichtigungen.entfernt };
@@ -186,6 +222,8 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
[AUFTRAG.ATEMSCHUTZPROTOKOLL_ERZEUGEN]: atemschutzprotokollErzeugenAuftrag,
[AUFTRAG.BENACHRICHTIGUNG_ZUSTELLEN]: benachrichtigungZustellen,
[AUFTRAG.BENACHRICHTIGUNG_ERINNERN]: benachrichtigungErinnern,
[AUFTRAG.WIKI_EINBETTEN]: wikiEinbettenAuftrag,
[AUFTRAG.DIKTAT_ERKENNEN]: diktatErkennenAuftrag,
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
};
+205
View File
@@ -0,0 +1,205 @@
import { KiArt, KiStand } from "@prisma/client";
import { fuerOrganisation } from "@/lib/db";
import { herunterladen } from "@/lib/storage";
import { spracherkennung } from "@/lib/integrations/whisper";
import { seiteEinbetten } from "@/lib/ki/wiki";
import { festhaltenIn } from "@/lib/ki/vorgang";
/**
* Die KI-Arbeit, die im Hintergrund läuft.
*
* Zwei Aufgaben, beide aus demselben Grund hier und nicht in der Anfrage:
* sie dauern zu lange, um jemanden davor warten zu lassen.
*
* einbetten Eine Wiki-Seite durchsuchbar machen. Bei zwanzig
* Abschnitten sind das zwanzig Modellaufrufe.
* diktatErkennen Zehn Minuten Sprache brauchen auf einem Rechner ohne
* Grafikkarte mehrere Minuten.
*
* Beide vermerken auch, was **nicht** geklappt hat. Ein Modell, das nicht
* erreichbar ist, soll nicht dazu führen, dass eine Aufnahme still im Nichts
* verschwindet — sie steht dann als fehlgeschlagen da, mit Grund, und die
* Aufnahme ist weiterhin abrufbar.
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[ki] ${nachricht}`, ...weiteres);
}
/** Eine Wiki-Seite einbetten. */
export async function wikiEinbetten(
organisationId: string,
seiteId: string,
): Promise<{ abschnitte: number; fehler?: string }> {
const ergebnis = await seiteEinbetten(organisationId, seiteId);
if (ergebnis.fehler) {
protokoll(`Seite ${seiteId} nicht eingebettet: ${ergebnis.fehler}`);
} else {
protokoll(`Seite ${seiteId}: ${ergebnis.abschnitte} Abschnitte eingebettet`);
}
return ergebnis;
}
/**
* Eine Aufnahme in Text verwandeln.
*
* Die Aufnahme bleibt liegen, auch wenn die Erkennung geklappt hat: solange
* das Transkript nicht bestätigt ist, ist sie das einzige Original. Erst der
* Aufbewahrungslauf räumt sie ab, und auch nur, wenn entschieden wurde.
*/
export async function diktatErkennen(
organisationId: string,
diktatId: string,
): Promise<{ erfolgreich: boolean; zeichen?: number; fehler?: string }> {
const dienst = spracherkennung();
const diktat = await fuerOrganisation(organisationId, (db) =>
db.diktat.findUnique({
where: { id: diktatId },
select: {
id: true,
sprache: true,
stand: true,
geloeschtAm: true,
erzeugtVonId: true,
entitaet: true,
entitaetId: true,
audioDatei: { select: { objektSchluessel: true, dateiname: true, groesseBytes: true } },
},
}),
);
if (!diktat || diktat.geloeschtAm) {
return { erfolgreich: false, fehler: "Dieses Diktat gibt es nicht (mehr)." };
}
// Ein bereits erkanntes Diktat nicht noch einmal durchs Modell schicken:
// der Auftrag kann bei einem Neustart des Workers ein zweites Mal kommen,
// und ein zweites Transkript wäre ein zweiter Vorschlag über denselben
// Sachverhalt.
if (diktat.stand !== KiStand.ENTWURF) {
return { erfolgreich: false, fehler: "Über dieses Diktat wurde bereits entschieden." };
}
const vermerken = async (angaben: { transkript?: string; fehler?: string; dauerMs: number }) =>
fuerOrganisation(organisationId, async (db) => {
const vorgang = await festhaltenIn(db, organisationId, {
art: KiArt.DIKTAT,
modell: `faster-whisper (${diktat.audioDatei.dateiname})`,
eingabe: `Aufnahme ${diktat.audioDatei.dateiname}, ${diktat.audioDatei.groesseBytes} Bytes`,
ausgabe: angaben.transkript ?? null,
fehler: angaben.fehler ?? null,
entitaet: "Diktat",
entitaetId: diktatId,
dauerMs: angaben.dauerMs,
erzeugtVonId: diktat.erzeugtVonId,
});
await db.diktat.update({
where: { id: diktatId },
data: {
transkript: angaben.transkript ?? null,
kiVorgangId: vorgang.id,
// Ein fehlgeschlagenes Diktat bleibt nicht als Entwurf stehen —
// sonst wartet jemand auf einen Text, der nie kommt.
stand: angaben.fehler ? KiStand.FEHLGESCHLAGEN : KiStand.ENTWURF,
},
});
});
if (!dienst.verfuegbar()) {
await vermerken({
fehler:
"Die Spracherkennung ist nicht eingerichtet (WHISPER_ENABLED). Die Aufnahme ist " +
"abgelegt und lässt sich anhören.",
dauerMs: 0,
});
return { erfolgreich: false, fehler: "Spracherkennung nicht eingerichtet." };
}
const beginn = Date.now();
let audio: Buffer;
try {
audio = await herunterladen(diktat.audioDatei.objektSchluessel);
} catch (fehler) {
const text = fehler instanceof Error ? fehler.message : String(fehler);
await vermerken({ fehler: `Die Aufnahme ließ sich nicht laden: ${text}`, dauerMs: Date.now() - beginn });
return { erfolgreich: false, fehler: text };
}
const ergebnis = await dienst.transkribieren(audio, diktat.audioDatei.dateiname, {
sprache: diktat.sprache,
});
if (!ergebnis.erfolgreich || !ergebnis.text) {
await vermerken({
fehler: ergebnis.fehler ?? "Unbekannter Fehler bei der Spracherkennung.",
dauerMs: ergebnis.dauerMs ?? Date.now() - beginn,
});
protokoll(`Diktat ${diktatId} nicht erkannt: ${ergebnis.fehler}`);
return { erfolgreich: false, fehler: ergebnis.fehler };
}
await vermerken({
transkript: ergebnis.text,
dauerMs: ergebnis.dauerMs ?? Date.now() - beginn,
});
// Die erkannte Dauer nachtragen, falls der Browser sie nicht mitgeschickt
// hat — bei manchen Aufnahmeformaten kennt er sie selbst nicht.
if (ergebnis.dauerSekunden) {
await fuerOrganisation(organisationId, (db) =>
db.diktat.update({
where: { id: diktatId },
data: { dauerSekunden: ergebnis.dauerSekunden },
}),
);
}
protokoll(`Diktat ${diktatId} erkannt: ${ergebnis.text.length} Zeichen`);
return { erfolgreich: true, zeichen: ergebnis.text.length };
}
/**
* Seiten nachziehen, deren Suchstand veraltet ist.
*
* Fängt zwei Fälle ab, die sonst niemand bemerkt: der Worker stand still, als
* jemand eine Seite gespeichert hat, oder das Einbettungsmodell war gerade
* nicht erreichbar. Läuft im nächtlichen Lauf mit.
*/
export async function veralteteSeitenEinbetten(
organisationId: string,
grenze = 20,
): Promise<{ nachgezogen: number }> {
const seiten = await fuerOrganisation(organisationId, (db) =>
db.wikiseite.findMany({
where: {
geloeschtAm: null,
veroeffentlicht: true,
OR: [{ eingebettetAm: null }, { eingebettetAm: { lt: db.wikiseite.fields.geaendertAm } }],
},
orderBy: { geaendertAm: "asc" },
take: grenze,
select: { id: true },
}),
);
let nachgezogen = 0;
for (const seite of seiten) {
const ergebnis = await seiteEinbetten(organisationId, seite.id);
if (!ergebnis.fehler) nachgezogen += 1;
// Ein Fehlschlag beendet den Lauf: ist das Modell nicht erreichbar, sind
// es die übrigen neunzehn auch nicht, und zwanzig Zeitüberschreitungen
// hintereinander blockieren den Worker.
else break;
}
if (nachgezogen > 0) protokoll(`${nachgezogen} Wiki-Seiten nachträglich eingebettet`);
return { nachgezogen };
}