Files
FireAdministration/src/components/ki/diktat.tsx
T
Florian WildeandClaude Opus 5 f7ccd9a908 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>
2026-09-04 11:06:03 +02:00

291 lines
9.5 KiB
TypeScript

"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>
);
}