
Betriebsplattform
Eine dreiteilige Plattform für ein Taxiunternehmen in Schöppingen: ein KI-Callcenter, das ans Telefon geht, eine Webanwendung für Buchungen und Betrieb sowie eine mobile App, die noch in Planung ist. Die drei Komponenten haben unterschiedliche Reifegrade; gemeinsam sind ihnen ein Buchungsvertrag und eine Datenbank.
Tarifwerte und Telefonnummern fehlen in dieser Fallstudie bewusst: Ihre Entsprechungen im Repository sind nicht freigegebene Platzhalterwerte; sie für echt zu halten, hieße falsche Informationen zu verbreiten.
Das Telefon ist die eigentliche Nachfragelinie dieses Betriebs. Ein verpasster Anruf ist nicht nur verlorene Arbeit; weil es keine Aufzeichnung davon gibt, weiß am nächsten Tag niemand, wer angerufen hat. Das Schwierige an einem Taxi-Callcenter ist jedoch nicht das Verstehen der Sprache, sondern: Die Buchung, die am Ende des Gesprächs entsteht, muss derselbe Datensatz sein wie eine im Web angelegte Buchung. Schreiben zwei Systeme in zwei Datenbanken, entsteht ein Synchronisationsproblem, und die Stelle, an der dieses Problem ungelöst bleibt, ist immer dieselbe — zwei Fahrzeuge werden zum gleichen Termin geschickt. Der zweite Punkt ist rechtlich: Eine in Deutschland veröffentlichte Seite, die eine nicht vorhandene Funktion verspricht (mobile App, Live-Ortung), ist ein Fall für das Wettbewerbsrecht. Ein solcher Fehler knallt nicht wie ein Codefehler — der Build läuft, die Tests sind grün, nur die Seite lügt.
Vier Parteien, vier unterschiedliche Randbedingungen. Der Anrufer: unter Umständen älter, installiert keine App, gibt die Adresse sprechend durch und erwartet natürliches Deutsch. Die Web-Kundschaft: möchte aus dem eigenen Konto eine frühere Fahrt wiederholen und ein Adressbuch verwalten. Das Büro: sieht Buchungen, Fahrer, Fahrzeuge, Schichten, Serienfahrten, Firmenabrechnung, Unterlagen für Krankentransporte und Werkstattvorgänge in einem Panel. Der Fahrer: schaut vom Telefon, soll nur die eigene Schicht und die eigenen Fahrten sehen — und nichts vom Rest.
Einzelner Entwickler, drei Repositories. Ziehen der Systemgrenzen, der von zwei Komponenten geteilte Buchungsvertrag, Postgres-Schema und Berechtigungsmodell, Anpassung der Callcenter-Seite an den Betrieb (Fahrzeug- und Leistungsarten, Tarifabruf, Buchungsschreibung), die drei Oberflächen der Webanwendung und die Bereitstellung.
Die drei Komponenten werden unabhängig veröffentlicht und teilen genau zwei Dinge: den JSON-Schema-Vertrag `booking.v1` und ein Supabase-Postgres-Schema. Das Callcenter läuft auf Python 3.13 mit FastAPI/Granian; Telefonleitung, Spracherkennung und -synthese, Modellaufrufe und Anrufzustand liegen auf der Azure-Seite (Communication Services, Speech, OpenAI, Cosmos DB, AI Search, Container Apps). Während des Gesprächs sammelt der Bot das Erfasste in einem "Claim"-Objekt; beim Abschluss wandelt er diesen Claim in einen `booking.v1`-Körper um und schreibt ihn über die Supabase-RPC `create_booking`. Die Web-Seite ist Next.js 16 App Router; der Schreibpfad ist Server Action → Zod → RPC, der Lesepfad Server Component → Query-Schicht. Die Berechtigung liegt auf drei Ebenen: Routenschutz in `proxy.ts`, RLS-Policies und Rollen-Gates innerhalb von SECURITY-DEFINER-Funktionen. Die Anwendung ist in drei Oberflächen geteilt — öffentliche Website und Kundenkonto, Verwaltungspanel, Fahrerportal — läuft aber auf einer Datenbank und einem Rollenmodell. Der Tarif ist in keiner Komponente fest verdrahtet: Er wird über die RPC `get_active_tariff` gelesen, der Bot hält ihn fünfzehn Minuten im Cache und fällt bei einem Lesefehler auf den letzten gültigen Wert zurück.
Die Alternative wäre gewesen, dass das Callcenter einen eigenen Datensatz führt und ihn per Queue oder Webhook an das Web übergibt. In diesem Entwurf existiert die Buchung an zwei Orten, und die Antwort auf "welche ist richtig" hängt an einer Abgleichregel. Stattdessen schreibt das Callcenter direkt in dasselbe Schema, über dieselbe RPC, die auch das Web nutzt. Eine telefonisch und eine über die Website angelegte Buchung sind dieselbe Zeile; im Büropanel unterscheidet sie nur das Quellfeld.
KompromissDie Python-Seite ist nun an das Datenbankschema gebunden: Ändert sich die RPC-Signatur, muss das Callcenter mitziehen — und diese Kopplung fängt kein Compiler ab, sie zeigt sich zur Laufzeit. Zudem trägt das Callcenter einen Service-Role-Schlüssel, also einen Schlüssel, der RLS umgeht, in der eigenen Infrastruktur. Die Vertragsdatei mindert dieses Risiko, beseitigt es aber nicht.
In einem Sprachablauf ist der Schritt "bestätigen" kein einmaliges Ereignis: Das Modell kann das Werkzeug erneut aufrufen, eine Verbindung kann abbrechen, während der Anruf weiterläuft, ein Mitarbeiter kann denselben Anruf erneut verarbeiten. Da der Schlüssel aus der Anruf-Identität abgeleitet ist, erzeugt ein zweiter Schreibversuch desselben Anrufs keine neue Buchung.
KompromissWeil der Schlüssel pro Anruf eindeutig ist, lässt sich innerhalb desselben Anrufs nicht direkt eine zweite Buchung anlegen — etwa wenn die Kundschaft sagt "und für die Rückfahrt bitte auch". Dieser Fall muss gesondert behandelt werden. Die Garantie schützt den einen Schreibpfad, nicht die Geschäftsregel.
Der Preis erscheint an drei Stellen: in der Schätzung, die der Bot ausspricht, auf der Preisseite und in der Rechnung des Büros. Laufen die drei auseinander, erreicht der Fehler die Kundschaft und lässt sich nicht zurücknehmen. Der Tarif liegt in einer Tabelle, alle drei Oberflächen lesen dieselbe RPC. Auf Bot-Seite gibt es einen Fünfzehn-Minuten-Cache; schlägt ein Lesevorgang fehl, wird mit dem letzten gültigen Wert weitergearbeitet statt still auf null zu fallen.
KompromissEine im Verwaltungspanel vorgenommene Tarifänderung erreicht ein laufendes Gespräch nicht sofort — bis der Cache erneuert ist, nennt der Bot den alten Wert. Und "auf den letzten gültigen Wert zurückfallen" heißt: Ist die Datenbank lange nicht erreichbar, nennt der Bot selbstbewusst einen veralteten Preis. Das ist bewusst akzeptiert.
Eine nicht vorhandene Funktion zu versprechen, ist in Deutschland ein wettbewerbsrechtlicher Punkt, und dieser Fehler löst keinen technischen Alarm aus. Heute gibt es weder eine mobile App noch eine Live-Ortung; ein Satz, der eines von beidem beschreibt, lässt einen Test scheitern. Derselbe Guard fängt auch einen Zählfehler ab: In der Überschrift steht die Anzahl der Punkte als Wort, sodass eine neue Leistung die Überschrift stillschweigend falsch machte.
KompromissDie erwarteten Werte stehen von Hand im Test; aus dem Quellmodul abgeleitet wäre der Test eine Tautologie und würde mit dem Inhalt unbemerkt mitwandern. Der Preis dafür: Auch eine legitime Inhaltsänderung bricht den Test — jede Textänderung erfordert eine manuelle Anpassung.
Bei einer Sprachassistenz ist Sprachunterstützung keine Frage von Übersetzungsdateien: Jede Sprache bedeutet eine eigene Stimme, eigene Aussprachehinweise, ein eigenes Ortsnamen-Vokabular und eigene Testlast. Da das Einsatzgebiet einsprachig ist, wurden die türkischen und englischen Pfade entfernt; Bot und Website blieben einsprachig.
KompromissWer kein Deutsch spricht, kann heute telefonisch nicht bedient werden; es bleibt nur die Übergabe an einen Menschen. Wird eine Sprache wieder aufgenommen, müssen nicht nur die Texte, sondern auch Stimme und Aussprachehinweise neu aufgebaut werden.
Python auf der Callcenter-Seite ist keine Sprachvorliebe, sondern eine Ökosystem-Entscheidung: Telefonleitung, streamende Spracherkennung und -synthese, Modellaufrufe und die Persistenz des Anrufzustands kommen als fertige Bausteine desselben Cloud-Anbieters, und deren ausgereifte Clients liegen auf der Python-Seite. Auch die Wahl von Region und Bereitstellungstyp ist nicht technisch, sondern rechtlich: Sie ist so aufgesetzt, dass Daten in der deutschen Region bleiben und die Modellbereitstellung nicht darüber hinausreicht. Auf der Web-Seite hält Next.js 16 App Router drei Oberflächen in einer Codebasis und belässt die Berechtigung auf dem Server. Supabase wurde nicht wegen der fertigen Oberfläche gewählt, sondern weil RLS und SECURITY-DEFINER-Funktionen zusammen nutzbar sind: Die Berechtigungsregel liegt neben den Daten statt in der Anwendung, und die Python-Seite unterliegt derselben Regel. Zod dient als ein Schema für Formulareingabe und Server-Action-Eingabe; `booking.v1` ist der gemeinsame Vertrag zweier Sprachen — auf der TypeScript-Seite als Typ, auf der Python-Seite als Validierung gelesen.
Die Berechtigung hat drei Ebenen: Routenschutz, RLS-Policies und Rollen-Gates innerhalb von SECURITY-DEFINER-Funktionen. Das Schema trägt 39 Tabellen, 43 Migrationen, 70 RPCs und 113 RLS-Policies. Die teuerste Lehre dieser Schicht kam aus einer Messung: Die rollenlesende Funktion gab für ein Konto ohne Rolle NULL zurück, und weil die Rollen-Prädikate dieses NULL in einen einfachen Vergleich fassten, lieferten alle drei NULL. In plpgsql ist die Bedingung `IF NOT (NULL)` gleich NULL, der Zweig läuft nie — das Berechtigungs-Gate von 45 Verwaltungs-RPCs wurde also ohne jede Rechteerhöhung passiert. Auch die Erreichbarkeit war nicht theoretisch: Die einzige Nutzerklasse ohne Rolle war ein Anbieterkonto, das das Onboarding nicht abgeschlossen hatte. Die Prädikate wurden NULL-sicher gemacht; das RLS-Verhalten änderte sich nicht, weil USING und WITH CHECK nur TRUE durchlassen. Der zweite Befund lag im Fahrerportal: Das Feld, das einen Fahrerdatensatz an den Sitzungsbenutzer bindet, wurde im Repository nirgends geschrieben — das Portal wäre live leer gelaufen. Der Service-Role-Schlüssel wird nur serverseitig gelesen; der Deploy-Pfad prüft zusätzlich seine Existenz, und eine Drift-Prüfung läuft gegen den Fall, dass der Persona-Text außerhalb der Versionskontrolle landet.
Verwaltungs- und Fahreroberfläche dieser Plattform sind geschlossen; da keine gespeicherte, mit Datum versehene Lighthouse-Ausgabe vorliegt, steht hier kein Seitengeschwindigkeits-Score. Messbar ist der Anrufpfad: In einem Sprachablauf ist Latenz mitten im Gespräch hörbar, deshalb sind die Schritte, die während des Gesprächs laufen — Preis- und Verfügbarkeitsabfragen — gecacht: der Tarif fünfzehn Minuten, die Distanzberechnung für die Dauer des Anrufs. Der Cache auf Modulebene ist gesperrt und doppelt geprüft, sodass zwei gleichzeitige Anrufe nicht mehr als eine Abfrage erzeugen. Auf der Web-Seite wird die Preisseite stündlich per ISR erneuert, Verwaltungs- und Fahreroberfläche sind vollständig dynamisch.
Das Berechtigungs-Gate würde ich heute nicht mehr so über plpgsql-Funktionen verteilen. Weil die Regel an 45 Stellen wiederholt wird, öffnete ein einziges Prädikat, das in dreiwertige Logik fiel, alle Tore auf einmal; wäre die Regel an einer Stelle und in einer Form definiert, die kein NULL erzeugen kann, wäre diese Fehlerklasse unmöglich gewesen. Dieselbe Lehre tauchte in zwei weiteren Projekten dieses Portfolios auf: Bei Bergaz Operations liegt die Nur-Lese-Garantie in einem Query-Schild innerhalb der Anwendung, und jeder neue Abfragepfad, der am Schild vorbeigeht, durchlöchert sie; bei Bergaz Lebensmittel habe ich die Zahlungs-Idempotenz erst in der Anwendungsschicht gebaut und dann in eine Datenbank-Constraint verschieben müssen. Das wiederkehrende Ergebnis: Je näher eine Garantie an den Daten liegt, desto schwerer lässt sie sich umgehen. Der zweite Punkt ist die Messung: Keiner der beiden ungeplanten Befunde stand auf der Aufgabenliste — beide kamen aus dem Abfragen des Live-Systems. Zu fragen "was gibt ein unberechtigter Aufruf zurück", statt anzunehmen, dass ein Gate funktioniert, fand zwei Lücken, die kein Test dieses Projekts erfasst hatte. Der dritte Punkt ist Mobile: Führt man eine dreiteilige Plattform allein, rutscht die dritte Komponente immer ans Ende der Schlange. Heute würde ich für Mobile von Anfang an einen engeren Umfang schreiben — nur die Fahrerseite statt einer vollständigen App — denn ein enger Umfang kommt in die Schlange, ein breiter nicht.
Sagen Sie mir, was Sie bauen wollen; ich sage Ihnen vorab, wie lange es dauert und wo man anfängt.