Zum Hauptinhalt
Zurück zu Projekten
Taxilos logo

Taxilos

Betriebsplattform

Kurz gesagt

Vorher
Das Telefon des Taxiunternehmens war seine eigentliche Auftragsleitung; ein verpasster Anruf hinterließ keine Spur, sodass am nächsten Tag niemand wusste, wer angerufen hatte.
Nachher
Ein deutschsprachiger KI-Assistent nimmt Anrufe an und legt die Buchung selbst an; das Büro verwaltet Buchungen, Fahrer und Fahrzeuge in einem Panel, und jeder Fahrer sieht nur seine eigenen Fahrten.
Was sich geändert hat
Buchungen per Telefon und Web landen im selben Datensatz, ein Anruf kann keine zwei Buchungen anlegen, und der Preis kommt überall aus einem einzigen Tarif; die mobile App ist noch in Planung.
Architekturdiagramm von Taxilos Taxilos Üç bileşen · tek rezervasyon kontratı · tek veritabanı Bileşenler bağımsız yayınlanır, ortak olan booking.v1 şeması ve tek Postgres şemasıdır. AI Çağrı Merkezi Python 3.13 · FastAPI / Granian Azure ACS · OpenAI · Speech · Cosmos AI Search · Container Apps kullanımda telefonu karşılar, rezervasyonu yazar Web Next.js 16 · Supabase Postgres Üç yüzey: site · yönetim · şoför portalı RLS + SECURITY DEFINER RPC yayında rezervasyon, operasyon, muhasebe Mobil Expo hedefleniyor Kapsam ve ekran akışı yazıldı Uygulama kodu henüz yok planlama faz 2 — sıraya alındı booking.v1 — platformdan bağımsız rezervasyon kontratı JSON Schema · durum ve hizmet tipi enum'ları · hata zarfı · idempotency anahtarı Python ve TypeScript aynı şemayı okur Supabase Postgres — tek şema 39 tablo · 43 migration · 70 RPC · 113 RLS politikası create_booking çağrı ve web aynı RPC'yi çağırır get_active_tariff tarife tek kaynak, bot 15 dk cache rol predicate'leri NULL-güvenli yetki kapısı Telefonla açılan rezervasyon ile web'den açılan rezervasyon aynı satırdır; ikinci bir senkronizasyon katmanı yoktur. Aynı çağrı iki kez işlenirse idempotency anahtarı ikinci kaydı engeller.
Drei Komponenten werden unabhängig ausgeliefert: das aktive KI-Callcenter auf Python/Azure, die aktive Next.js-Webanwendung und die noch in Planung befindliche Expo-App. Alle drei binden an den plattformunabhängigen Vertrag booking.v1 und ein einziges Supabase-Postgres-Schema; eine telefonisch und eine im Web angelegte Buchung sind dieselbe Zeile, eine zweite Synchronisationsschicht gibt es nicht.

Eine Plattform für ein Taxiunternehmen in Schöppingen: Ein KI-Assistent nimmt Anrufe an, Buchungen und Betrieb laufen in einer Web-App, eine mobile App ist in Planung. Jede Buchung, ob per Telefon oder Web, landet im selben System.

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.

Verwendete Technologien

  • Next.js 16
  • TypeScript
  • Supabase Postgres
  • Python 3.13
  • FastAPI
  • Azure Communication Services
  • Azure OpenAI
  • Azure Speech
  • Azure Cosmos DB
  • Zod

Hauptmerkmale

  • Deutschsprachige Sprachassistenz, die ans Telefon geht und die Buchung selbst anlegt
  • Telefon und Web schreiben denselben Buchungsdatensatz — keine Synchronisationsschicht
  • Plattformunabhängiger booking.v1-Vertrag: Python und TypeScript lesen dasselbe Schema
  • Drei getrennte Oberflächen — Website und Kundenkonto, Verwaltung, Fahrerportal
  • Tarif aus einer einzigen Quelle; der Bot trägt keine eigene Konstante
  • Guards für Inhaltsaussagen — eine Seite, die eine nicht vorhandene Funktion verspricht, lässt einen Test scheitern

Entwicklungszeit

5 Monate (laufend)

Problem

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.

Nutzer

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.

Meine Rolle

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.

Architektur

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.

Zentrale Entscheidungen

  1. Zwei Komponenten schreiben in eine Datenbank — keine Integrationsschicht

    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.

  2. Der Idempotenzschlüssel der Buchung stammt aus der Anruf-Identität

    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.

  3. Tarif und Preise liegen in der Datenbank, nicht im Code

    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.

  4. Aussagen in den Website-Texten sind durch Tests geschützt

    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.

  5. Eine Sprache — Deutsch. Mehrsprachigkeit wurde entfernt

    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.

Technologiewahl

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.

Sicherheit

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.

Performance

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.

Ergebnisse

Aktive Entwicklung
2026-04 → 2026-09 · Web 160 Commits · Callcenter 45 Commits
TypeScript-Quelldateien
451 (Web)
Python-Quelldateien
70 (Callcenter)
Testdateien
77 (Web)
Schema
39 Tabellen · 43 Migrationen · 70 RPCs · 113 RLS-Policies
Routen
79 Seiten — Website und Kundenkonto, Verwaltungspanel, Fahrerportal
Reifegrad der Komponenten
Callcenter im Einsatz · Web live · Mobile in Planung, noch kein Code geschrieben
Architekturgarantie
Telefon und Web rufen dieselbe RPC auf; der Idempotenzschlüssel verhindert, dass derselbe Anruf eine zweite Buchung erzeugt
Architekturgarantie
Rollen-Prädikate sind NULL-sicher — ein Konto ohne Rolle kommt an den Verwaltungs-RPCs nicht vorbei

Was ich heute anders machen würde

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.

Internes System

Finden wir gemeinsam den richtigen Einstieg

Sagen Sie mir, was Sie bauen möchten; ich sage Ihnen vorab, wie lange es dauert und was der sinnvolle erste Schritt ist.