Aus einer Wohnungsanzeige in Berlin wird eine sendefertige deutsche
Bewerbungsmail. Die maßgebliche Spec ist das Projektdokument; Verweise wie
„§11" beziehen sich darauf. Leg sie als docs/spec.md ab, damit sie
mitversioniert wird.
Stand: Phase 6 abgeschlossen — erste Ende-zu-Ende-Version. Die Bauphasen stehen in der Spec, §14.
npm install
npm install --prefix web
cp .env.example .env # GEMINI_API_KEY eintragen
npm run db:init
npm test
npm run dev # UI auf http://localhost:3000Die Oberfläche liegt als Next-App unter web/ (React, Tailwind, shadcn/ui)
und importiert den Node-Teil aus src/ direkt — kein HTTP zwischen UI und
Logik. npm run dev im Wurzelverzeichnis startet sie.
| Befehl | Zweck |
|---|---|
npm run db:init |
legt data/wohnungsbot.db an, idempotent |
npm run db:reset |
löscht die DB und legt sie neu an |
npm test |
Unit-Tests (node --test, kein Framework) |
npm run typecheck |
tsc --noEmit |
npm run llm:smoke |
ein Adapteraufruf gegen schemas/smoke.json |
npm run extract:fixtures |
Extraktion über alle Fixtures, mit Zusicherungen |
npm run draft:render |
T_VERWALTUNG aus gecachten Payloads, ohne Modell |
npm run research:firma -- "Name" |
Firmenrecherche mit Grounding (§8 Flow B) |
npm run grounding:check |
prüft, ob die Google-Suche tatsächlich feuert |
npm run gate:fixtures |
Betrugsprüfung und Klassifikation über alle Fixtures |
npm run pipeline -- <datei> |
die ganze Kette an einem Inserat (CLI) |
npm run dev |
die UI |
npm run seed:places |
füllt seed_company aus der Places API (§17) |
npm run prewarm:batch |
nächste 15 Firmen für Cowork, --stats zeigt die Queue |
npm run prewarm:import -- x.json |
Cowork-Ergebnis einlesen, --quarantine zeigt Offene |
Erster echter Aufruf am 20.08.2026 gegen gemini-3.5-flash-lite: 1089 ms,
203 In / 114 Out, $0,00034590, Schema sauber validiert.
Node ≥ 22.18. Die Spec (§16) nennt Node ≥ 20; die tatsächliche Untergrenze
liegt höher, weil TypeScript hier ohne Build-Schritt läuft — Node führt die
.ts-Dateien direkt aus (Type-Stripping, seit 22.18 ohne Flag). Damit
entfallen tsx, ein dist/-Verzeichnis und der Schritt „vergessen zu bauen".
Getestet auf Node 24.12.
config/pricing.json Preise. Bewusst außerhalb des Adaptercodes (§11).
db/schema.sql SQLite-Schema (§6), idempotent.
schemas/ Kanonische JSON Schemas. Einzige Quelle der Wahrheit.
prompts/extract.md Systemprompt der extract-Stufe.
fixtures/ Eingefrorene Inseratsseiten + Zusicherungen. Siehe docs/fixtures.md.
src/stages/extract.ts Stufe extract: page_text -> Payload (§5).
src/lib/capture.ts Was der Client schickt; URL-Ersatzschluessel.
src/lib/fixtures.ts Fixture-Harness.
src/lib/profile.ts Liest prompts/profil.md. Geht nie an ein Modell.
src/render/ Draft-Erzeugung ohne LLM: Template, Anrede, Betreff.
prompts/profil.md Bewerberprofil (Bonitätsblock).
prompts/t_verwaltung.md Template T_VERWALTUNG.
data/payloads/ Gecachte Extraktionen, Eingabe für draft:render.
src/db/verwaltung.ts Firmen-Cache (§3), Konfidenz-Ordnung.
src/db/seed.ts Warteschlange fürs Vorwärmen + Quarantäne.
src/lib/prewarm-import.ts Validierung der Cowork-Ausgabe (§17).
src/stages/research-firma.ts Firmenrecherche mit Grounding.
src/stages/gate.ts Betrugsprüfung + Klassifikation in einem Aufruf.
src/lib/classify.ts Harte Signale (§7 Stufe 1). Bewusst kein Regex-Klassifikator.
docs/cowork-prewarm-prompt.md Die Cowork-Aufgabe, mitversioniert.
src/config/env.ts .env-Zugriff an genau einer Stelle.
src/db/ Verbindung + Schema.
src/lib/normalize.ts Namensnormalisierung (§6). Überall dieselbe Funktion.
src/llm/types.ts generate / research. Nur LlmResult verlässt den Adapter.
src/llm/registry.ts Stufe -> provider:model aus .env.
src/llm/pricing.ts usage -> USD.
src/llm/validate.ts ajv gegen das kanonische Schema.
src/llm/log.ts Rohausgaben nach data/llm-log/*.jsonl.
src/llm/providers/ gemini-generate, gemini-schema, mock-generate.
Die Spec bittet ausdrücklich darum, Modell-IDs gegen die aktuelle Doku zu
prüfen. Ergebnis der Prüfung am 2026-08-20 gegen
ai.google.dev/gemini-api/docs/models, /pricing und /changelog, jeweils
englisch und deutsch.
Zur Modellliste: Die deutschsprachigen Doku-Seiten sind rund eine Woche im Rückstand.
gemini-3.7-flasherschien am 13.08.2026 und steht dort noch nicht — wohl aber in der englischen Modell- und Preisliste und im Changelog. Wer die IDs nachschlägt, sollte die englische Fassung nehmen.
1. gemini-3.1-pro existiert nicht als API-ID.
Die Spec verdrahtet sie für STAGE_DRAFT_PRIVAT und STAGE_RESEARCH_PERSON.
Die Modellliste kennt nur gemini-3.1-pro-preview; ein Pro-Modell der
3.1-Familie ohne -preview gibt es nicht. .env.example verwendet deshalb
gemini-3.1-pro-preview. Das ist ein Preview-Modell — vor Phase 10
(Hook-Eval) prüfen, ob es inzwischen eine stabile ID gibt.
2. Preise — zwei Korrekturen nach Gegenprüfung.
Geprüft gegen die englische und die deutsche Fassung der Preisseite. Die
lokalisierten Seiten hinken nach: gemini-3.7-flash (Start 13.08.2026) fehlt
in der deutschen Fassung am 20.08.2026 noch ganz. Die englische Fassung
gruppiert dafür 3.6 und 3.7 Flash in einer Zeile, was in die Irre führt.
Korrigiert gegenüber dem ersten Anlauf:
| Modell | falsch | richtig |
|---|---|---|
gemini-3.6-flash |
$0,75 / $3,75 | $1,50 / $7,50 — Einführungspreis mit dem Start von 3.7 ausgelaufen |
gemini-3.1-flash-lite |
$0,30 / $2,50 | $0,25 / $1,50 — günstiger als 3.5 Flash-Lite, nicht gleich teuer |
Unverändert bestätigt: 3.7 Flash $0,75/$3,75 (Einführungspreis bis 31.12.2026), 3.5 Flash $1,50/$9, 3.5 Flash-Lite $0,30/$2,50, 3.1 Pro Preview $2/$12 bis 200K Input und $4/$18 darüber, 2.5 Pro $1,25/$10, Grounding 5.000 Suchanfragen pro Monat frei über die gesamte 3.x-Familie, danach $14 pro 1.000. Flash-Modelle haben keine 200K-Stufe, die gibt es nur bei Pro.
Der Nachfolgepreis von 3.7 Flash ab 01.01.2027 bleibt "provisional": true —
die Preisseite nennt nur das Ende des Einführungspreises. Gestützt wird der
Wert jetzt dadurch, dass 3.6 Flash nach Ablauf seines Einführungspreises
genau bei $1,50/$7,50 liegt, also der Verdopplung aus Spec §11.
test/pricing.test.ts friert jeden dieser Sätze als Zusicherung ein.
Nicht modelliert: Audio-Input hat bei mehreren Modellen einen eigenen, höheren Satz. Dieses System schickt ausschließlich Text.
3. Node-Untergrenze auf 22.18 angehoben (siehe oben).
4. Das Schema-Keyword-Problem war größer als erwartet.
Gemini akzeptiert bei responseJsonSchema nur eine Teilmenge von JSON Schema:
$id $defs $ref $anchor type format title description enum items prefixItems minItems maxItems minimum maximum anyOf oneOf properties additionalProperties required. pattern, minLength, const, default, allOf und $schema
fehlen. src/llm/providers/gemini-schema.ts übersetzt deshalb und entfernt
alles Unbekannte, statt es durchzureichen (ein unbekanntes Keyword kann den
Request mit 400 abweisen). Zusätzlich werden Typ-Unionen (["string","null"])
zu anyOf umgeschrieben — der Payload-Vertrag (§5) besteht fast nur aus
nullable Feldern, und ob Gemini Typ-Arrays akzeptiert, ist nicht dokumentiert.
Das macht die Doppelvalidierung aus §11 zur Pflicht statt zur Vorsicht:
minLength kann Gemini nicht erzwingen, ajv schon. schemas/smoke.json
enthält genau deshalb ein minLength, und test/adapter-contract.test.ts
prüft, dass ajv es abfängt.
node --test statt Jest/Vitest. Kein Framework, kein Transpiler, keine
Konfiguration. 62 Tests laufen in ~0,3 s.
better-sqlite3, nicht node:sqlite. Das eingebaute node:sqlite wäre
abhängigkeitsfrei, existiert aber erst ab Node 22.5 und ist als experimentell
markiert. Für einen Dienst, der später dauerhaft Crons fährt, ist die stabile
Bibliothek die ruhigere Wahl. Ein Prebuild war vorhanden, kein Compiler nötig.
Ein Mock-Adapter ab Phase 1. src/llm/providers/mock-generate.ts läuft
durch denselben Validierungs- und Preispfad wie der echte Adapter. Damit
testet Phase 2 die Extraktionslogik gegen Fixtures, ohne Tokens zu verbrennen
und ohne Netz.
Fehlender Preis ist ein Startfehler. Die Spec verlangt einen Startfehler
bei unbekanntem Anbieter. Dieselbe Behandlung gilt hier für ein Modell ohne
Eintrag in pricing.json: sonst wäre costUsd still 0 und das Tagesbudget
aus §16 wirkungslos.
Rohausgaben als JSONL, nicht als Tabelle. §11 verlangt, raw immer
mitzuschreiben. data/llm-log/YYYY-MM-DD.jsonl ist Wegwerfmaterial; die
abrechnungsrelevanten Zahlen gehen nach listing_event. Abschaltbar über
LLM_LOG=0.
url setzt der Dienst, nicht das Modell. Der Client kennt die URL sicher;
das Modell müsste sie aus dem Seitentext raten. Das Schema verlangt sie
trotzdem als Feld (Vertragstreue zu §5), der Prompt sagt „immer null", und
finalizePayload trägt die echte ein.
Fehlende external_id ist kein Fehler. §5 nennt sie als Pflichtfeld, §4
sagt, dass manche Quellen keine haben. Auflösung: das Modell darf null
liefern — eine erfundene Objektnummer wäre der schlimmere Ausgang — und
finalizePayload bildet den Ersatzschlüssel aus der normalisierten URL
(url:kranz-immobilien.de/angebote/3-zimmer-wedding). Tracking-Parameter und
Slash am Ende fallen dabei weg, damit dieselbe Seite denselben Schlüssel
ergibt.
Fehlender Anbietername stürzt nicht ab, sondern meldet sich.
ExtractResult.issues trägt no_provider_name; ohne Namen gibt es keinen
Cache-Lookup und keinen Empfänger, das Inserat gehört nach T0 (§8 Flow B).
additionalProperties: false überall. Das ist der maschinelle Teil von
„keine erfundenen Felder": ein Feld, das nicht im Vertrag steht, lässt die
Validierung scheitern, statt still durchzurutschen.
Erwartungen statt Soll-Payloads. fixtures/<name>.expected.json hält nicht
den kompletten erwarteten Payload fest — der wäre nach jeder Prompt-Änderung
kaputt — sondern nur die Aussagen, die zählen. Die wichtigste Liste ist
must_be_null: die Felder, die nicht auf der Seite stehen. Dazu
must_not_appear_anywhere für Zahlen aus dem Abschnitt „Ähnliche Objekte",
der auf jedem Portal danebensteht und die häufigste Verwechslungsquelle ist.
Die Erwartungsdateien werden selbst geprüft.
test/fixtures-satisfiable.test.ts enthält je Fixture den von Hand gelesenen
Soll-Payload und stellt sicher, dass er die eigenen Zusicherungen erfüllt.
Sonst sähe ein Tippfehler in der Erwartung wie ein Modellfehler aus.
Fünf Fixtures, davon zwei echte IS24-Seiten (Nachmietergesuche von privat),
gemini-3.5-flash-lite, ~$0,0017 und ~1,9 s pro Inserat. Echte Portalseiten
sind mit ~3.700 Input-Tokens etwa doppelt so groß wie meine gebauten.
Richtig erkannt wurden Objektnummern, Adressen, alle Preise, die Kaution, die Portal-Kennzeichnung „von privat" und der Verzicht auf eine erfundene Straße, wo die Seite nur den Ortsteil nennt.
Ein Feld fiel systematisch aus: provider.self_description — in allen
Privat- und Nachmieterfällen null. Ausgerechnet das Feld, das nach §7 die
Einordnung Makler/Verwaltung/Privat trägt.
Ursache war die Formulierung im Prompt: Ich hatte das Feld als Satz beschrieben, in dem „der Anbieter beschreibt, wer er ist" — mit Beispielen im Firmenton („wir verwalten dieses Objekt seit 2011"). Das Modell hat daraufhin nur den Anbieterblock durchsucht. Tatsächlich steht der Satz bei Privatleuten im Titel oder im Beschreibungstext: „Nachmieter für wunderschöne 2-Zimmer-Altbauwohnung in Berlin-Pankow gesucht".
Behoben durch eine Rollentabelle im Prompt (Verwaltung / Makler / Nachmieter / Privat, je mit typischem Wortlaut) und den ausdrücklichen Hinweis, den ganzen Text zu lesen. Lehre fürs Weitere: Ein Feld, dessen Beschreibung eine Fundstelle impliziert, wird auch nur dort gesucht.
Zusätzlich: Ein Inserat schrieb „ab 01.09." ohne Jahr, das Modell gab
korrekt null zurück, statt zu raten — und damit ging der Wunschtermin
verloren, den T_NACHMIETER (§13) braucht. Der Input enthält jetzt das
heutige Datum, und der Prompt lässt das nächste Vorkommen einsetzen. Das ist
Kalenderrechnen mit einem genannten Datum, kein Raten.
Bekannte Lücke: Kein Fixture verlangt self_description: null. Ein Modell,
das dort immer etwas hineinschreibt, käme durch. Ein Inserat ganz ohne
Rollenaussage wäre dafür nötig — falls dir eines unterkommt, ist es wertvoll.
Anrede wird nie geraten. Persönlich angeredet wird nur, wenn „Frau" oder „Herr" wörtlich im Inserat steht. Aus „Tobias" oder „Andrea Kranz" das Geschlecht zu erschließen geht regelmäßig schief, und eine falsche Anrede kostet mehr, als die persönliche einbringt. Sonst „Sehr geehrte Damen und Herren".
Nur Merkmale, die der Wohnung gehören. Erst standen Aufzug, Keller und Stellplatz mit auf der Liste — „die 3-Zimmer-Wohnung mit Fahrstuhl" liest sich falsch, der Aufzug gehört zum Haus. Übrig bleiben Balkon, Terrasse, Loggia, Garten, Einbauküche, Gäste-WC. Findet sich keines, bleibt der Satz ohne Merkmal: kein Merkmal ist besser als ein schiefes.
Der URL-Ersatzschlüssel darf nicht in den Betreff. „Anfrage url:kranz-immobilien.de/angebote/3-zimmer-wedding – …" wäre unbrauchbar. Fehlt eine echte Objektnummer, entfällt sie ersatzlos, und die Adresse trägt die Betreffzeile allein.
Ein unaufgelöster Platzhalter ist ein Fehler, kein leerer String. Ein
{{beruf}} mitten in einer Mail an eine Hausverwaltung wäre peinlich, ein
stillschweigend leerer Satz auch. Der Renderer wirft.
Zwei Sperren statt stiller Fehler. Solange prompts/profil.md auf
beispiel steht, ist jeder Draft als nicht sendebereit markiert. Verlangt
ein Inserat einen WBS und das Profil hat keinen, ebenso. Beides landet in
Draft.blockers und später in der UI.
Die Wortgrenze steht bei 230 statt 100. Die 100-Wort-Fassung war die erste Umsetzung und wurde als „zu nackt" verworfen. Maßstab ist jetzt eine Bewerbungsmail, mit der Alexander in München tatsächlich eine Wohnung bekommen hat — rund 200 Wörter. Der Zugewinn steckt in dem, was Vertrauen herstellt: die aufgeschlüsselte Einkommensangabe statt einer behaupteten Summe, ein Link auf ein echtes Profil, die Erklärung der auswärtigen Adresse. Nichts davon passt in 100 Wörter. Die Grenze bleibt trotzdem hart, damit das Template nicht unbemerkt ausufert.
Der Zweitkontakt wiederholt alle Angaben. §13 will die Selbstvorstellung
weglassen. Der Empfänger müsste dann aber in einer alten Mail nachsehen —
Aufwand, den man einem überlaufenen Postfach nicht zumutet. Geändert hat sich
nur der erste Satz, der den Vorkontakt benennt. Der Lookup auf verwaltung_id
in listing_event kommt in Phase 6; der Renderer nimmt den Vorkontakt als
Parameter und bleibt damit rein und testbar.
Eine Berliner Bewerbung mit Münchner Absender wirft beim Empfänger eine Frage
auf. Besser, sie steht beantwortet in der Mail, als dass sie unbeantwortet im
Kopf bleibt. Der Satz steht als hinweis_adresse im Profil und ist frei
formulierbar; fehlt er, entfällt die Zeile ersatzlos.
§12 schreibt vor: „Server-rendered plus SSE, kein separater
Frontend-Build. Ein Port, lokal." Umgesetzt ist stattdessen eine Next-App
mit shadcn/ui unter web/. Bewusste Abweichung; der Grund ist die
Oberfläche, die §12 selbst verlangt — eine editierbare Firmen-Tabelle, ein
laufender Countdown, Zustands-Badges — und die von Hand gebaut mehr Code
wäre als der Build-Schritt kostet.
Nicht zwei Prozesse. Der erste Entwurf war Fastify-API plus Next-UI, die
über HTTP redeten. Das war Aufwand ohne Gewinn: Server Components lesen
SQLite direkt, Server Actions ersetzen die PATCH-Endpunkte, und doppelte
Typen entfallen. src/service/server.ts und scripts/serve.ts sind gelöscht;
handleCapture() blieb als testbare Funktion übrig, die der Route Handler
und die Tests gleichermaßen aufrufen.
Die Migration hat dreimal dasselbe Problem in verschiedener Verkleidung gezeigt — Pfade, die relativ zum Modul oder zum Arbeitsverzeichnis aufgelöst werden:
| Symptom | Ursache |
|---|---|
ERR_INVALID_ARG_TYPE: paths[0] undefined |
import.meta.dirname ist im gebündelten Code undefined (9 Module) |
| „STAGE_EXTRACT fehlt in .env" trotz vorhandener Datei | dotenv suchte web/.env statt ./.env |
| Firmen-Cache leer, obwohl gefüllt | Next legte eine zweite DB unter web/data/ an |
Alle drei behoben durch src/lib/paths.ts: sucht von process.cwd() aufwärts
nach db/schema.sql und liefert die Projektwurzel. Die CLI läuft im Repo,
Next in web/ — beide finden dieselben Dateien.
Der Nebeneffekt rechtfertigt die Änderung auch ohne Next: Vorher trug jedes
Modul seine eigene ../../-Tiefe. Wer eine Datei verschoben hätte, hätte
still einen Dateizugriff gebrochen.
Was nicht das Problem war: die .ts-Endungen in den Imports. Turbopack
löst sie auf, TypeScript braucht dafür eine Zeile
(allowImportingTsExtensions). Ich hatte den Aufwand erst auf 147 Zeilen
geschätzt, dann auf eine — beides falsch.
next/font/google lädt zur Build-Zeit von fonts.googleapis.com. Für ein
Werkzeug, das lokal läuft, ist eine Build-Zeit-Abhängigkeit von einem fremden
CDN ein Fehler, der zur ungünstigsten Zeit auffällt. Die drei Schriften
(Geist, Geist Mono, Noto Serif) liegen als .woff2 unter web/app/fonts/
und werden über next/font/local geladen. Gleiches Aussehen, Build ohne Netz.
Die erste Fassung war ein Admin-Dashboard: drei Kennzahl-Karten oben, darunter eine Tabelle mit sechs Metadaten-Spalten, der Draft als Monospace-Textfeld. Das Urteil war „man checkt nicht, wie man vorankommt" — und es war richtig.
Die Diagnose lohnt das Festhalten, weil sie nichts mit der Komponentenbibliothek zu tun hatte:
| Fehler | Warum er einer war |
|---|---|
| Kennzahlen als Erstes | Sie helfen beim Handeln nicht. Die Frage ist immer: geht diese Mail raus? |
| Tabelle mit 6 Spalten | Zweig, Risiko und Modus als drei gleich aussehende Pillen — man musste sie erst zusammenrechnen |
| Leerer Zustand mit Knopf zur nächsten Seite | Wenn nichts zu entscheiden ist, ist Einfügen die einzige Handlung. Also gehört das Feld dorthin |
| Draft als Textfeld | Ob eine Mail gut ist, entscheidet man durch Lesen, nicht durch Editieren |
| Ablauf endete unsichtbar | Es gibt keinen Versand — das muss dastehen, sonst wirkt es kaputt statt unfertig |
Alles text-xs |
Aus dem Preset übernommen statt überschrieben |
Jetzt: pro Inserat eine Karte mit einer Bereitschaftszeile
(components/readiness.tsx), die aus Zweig, Risiko, Empfänger und
Versandmodus einen Satz macht — „Anschreiben bereit",
„Betrugsverdacht — geht nicht raus", „Braucht deine ausdrückliche Freigabe".
Der Draft steht in einem Postfach-Rahmen mit An und Betreff. Ein Pfad
Erfasst → Geprüft → Freigegeben (Phase 7) → Gesendet (Phase 7) zeigt, wo
der Ablauf heute endet. Kosten und Kontingente in einer Fußzeile.
Die Firmen-Ansicht wurde eigens noch einmal verworfen. Sie war eine Tabelle
mit neun Spalten dauerhaft editierbarer Eingabefelder — und zeigte den
Normalisierungsschlüssel als Überschrift (habitare ivd immobilienmanagement und standortberatung inhaber christian kurtz), während
der echte Name klein und grau darunter stand. Die E-Mail-Spalten waren so
schmal, dass info@. dastand, und Konfidenz wie Aktionen lagen außerhalb des
Bildes. Ausgerechnet das, weswegen man die Seite öffnet, war unlesbar.
Jetzt gruppiert nach Bedarf: zurückgehaltene Importzeilen mit Übernehmen und
Verwerfen (§17 verlangt den Klick), dann „Brauchen einen Blick" — keine
Adresse, Konfidenz unter high, unbestimmte Art oder eine zurückgekommene
Mail —, dann „Erledigt" leiser. Adressen in voller Breite, Bearbeiten auf
Klick. Der interne Schlüssel steht klein im Bearbeitungsformular, wo er
hingehört.
Hell und dunkel funktionieren beide, umschaltbar im Kopf oder mit Taste D.
Voraussetzung dafür ist, ausschließlich die semantischen Farbmarken zu
benutzen (bg-card, text-muted-foreground, text-destructive) und nie
eine feste Farbe.
queued und sent fehlen im Zustandsautomaten der UI — es gibt keinen
Approve- und keinen Abbrechen-Knopf für ein laufendes Undo-Fenster, weil
nichts gesendet wird. Solange kein Versand existiert, wäre ein solcher Knopf
eine Lüge. Das ist Phase 7, zusammen mit dem Worker-Prozess für die Crons aus
§10 (Undo im Sekundentakt, Reply-Poll alle 15 Minuten, Nudge-Timer) — die
haben in Next keinen Platz.
§5 sagt: „Versioniere das Schema ab Tag eins. Es ändert sich in Woche zwei." Genau so kam es, und der Anlass ist lehrreich.
Beim Gate-Lauf über die echten Fixtures zitierte das Modell in seiner
Begründung: „bietet Einbauten gegen einen Abstand von 7.350 € an". In meiner
Fixture-Erwartung stand must_not_appear_anywhere: ["7350"] — ich hatte die
Zahl für eine Umzugskosten-Schätzung des Portals gehalten. Der Blick in den
Seitentext gab dem Gate recht:
„Die hochwertige Einbauküche kann ebenfalls übernommen werden. Eine Abstandszahlung in Höhe von 7350€ ist daher abzustimmen."
7.350 € auf eine Wohnung mit 685 € Kaltmiete — die wichtigste Zahl im ganzen
Inserat. Und der Payload hatte kein Feld dafür. §13 verlangt für
T_NACHMIETER ausdrücklich die „Übernahme" als variablen Teil des
Anschreibens; ohne Feld wäre das Anschreiben darüber stumm geblieben.
Neu in v2: listing.takeover_payment_eur und listing.takeover_note. Beides
mit dem ausdrücklichen Hinweis im Prompt, dieses Geld nicht mit der Kaution zu
verwechseln — es geht an den bisherigen Mieter, nicht an den Vermieter.
Zwei Lehren, beide im Code vermerkt: Eine Zahl, die ich nicht erklären kann, ist keine Zahl, die ich verbieten sollte. Und die Fixture-Erwartungen sind selbst Code — sie können falsch sein, und hier war eine falsch.
Der Lauf mit v2 fand dann noch eine zweite Übernahme, die ich selbst in ein Fixture geschrieben und nie modelliert hatte: 800 € für die Einbauküche im Kleinanzeigen-Inserat. Ein neues Feld deckt rückwirkend auf, was vorher niemandem auffiel.
Nach drei Runden Fehlschlägen, die alle keine echten Fehler waren, ist der Maßstab geschärft. Das System hat einen Menschen in der Schleife — jeder Draft wird gelesen, bevor er geht (§9). Eine Zusicherung ist deshalb nur dann gerechtfertigt, wenn ihr Bruch etwas anderes passieren lässt:
| geprüft | warum |
|---|---|
risk: high |
sperrt den Versand (§9) |
| Zweig | bestimmt das Template, und die Templates sind gegensätzlich (§13) |
must_not_send |
die eine Zusicherung, die Schaden verhindert |
| ein entscheidendes Betrugssignal | Stichprobe, dass die Begründung stimmt |
platform_private_flag: true |
speist ein hartes Signal (§7 Stufe 1) |
Nicht mehr geprüft:
- die vollständige Signalliste. Findet das Gate vier statt einem, ändert das nichts — gesendet wird ohnehin nicht.
platform_private_flag: falsegegennull. Für den Ablauf gleichwertig; nurtruelöst etwas aus.- einzelne
features. Welche das Modell aus einer langen Ausstattungsliste auswählt, ist ein Auswahlurteil. §13 setzt genau ein Merkmal ein, und das kommt aus einer eigenen Prioritätsliste im Renderer.
Nebenbei beobachtet: Der Prompt wuchs um zwei Regeln, und daraufhin fiel bei einem Fixture ein Merkmal aus der Liste, das vorher drin war. Längere Prompts verschieben die Aufmerksamkeit. Ein Grund mehr, nur das zuzusichern, was zählt — sonst rauscht die Suite bei jeder Prompt-Änderung.
Dreimal hintereinander war ein Fehlschlag meine Unterbestimmung, nicht ein Modellfehler:
| Fehlschlag | Ursache | Regel danach |
|---|---|---|
| falsche Objektnummer | zwei IDs auf der Seite, keine Regel welche | Portal-ID vor Anbieter-ID |
Balkon in features |
„Einen Balkon gibt es nicht" | verneinte Merkmale gehören nicht hinein |
platform_private_flag: null |
nur true und null definiert, „Gewerblich" nicht |
drei Zustände, false bei gewerblicher Kennzeichnung |
false ist dort keine Verlegenheitslösung: Es schließt die Privatperson aus,
und das ist ein anderer Wissensstand als „unbekannt".
Kein Regex-Klassifikator. §7 verbietet ihn ausdrücklich, und die
Begründung trägt: „Berger Immobilien GmbH" kann Makler oder Verwaltung sein,
und „Hausverwaltung Schmidt" bricht jede Token-Regel. In classify.ts stehen
deshalb nur Signale, die wirklich hart sind — ein Cache-Eintrag, eine
Portal-Kennzeichnung, eine namentliche Liste kommunaler Gesellschaften.
Alles Urteilhafte macht das Gate am Volltext.
Harte Signale kennen zwei Stärken. decisive: true beendet die
Klassifikation: ein portal_only-Eintrag, ein recherchierter firm_type,
eine Genossenschaft. decisive: false ist ein starkes Indiz, dem das Gate
widersprechen darf — aber nur mit branch_confidence: high. Beispiel: Das
Portal kennzeichnet „von privat", der Text sagt „Nachmieter gesucht". Beides
stimmt; der Zweig ist T_NACHMIETER, und das erkennt nur das Gate.
Signale als Codes, nicht als Freitext. §11 skizziert signals: ["..."].
Ein kontrolliertes Vokabular (prepayment_before_viewing, keys_by_mail, …)
ist testbar und in der UI konsistent; die Prosa steht in reasoning.
Zwei Signale sind allein schon high: Vorkasse vor Besichtigung und
Schlüsselversand. Für beide gibt es keine harmlose Erklärung. Umgekehrt
mahnt der Prompt zur Vorsicht bei niedrigen Mieten — Altbestand mit
Sanierungsbedarf und WBS-Bindung drücken den Preis in Berlin legitim.
Der Fallback ist T0. Scheitert das Gate, gibt es keine Betrugsprüfung —
dann kein Versand, Text zum Kopieren, ich bewerbe mich selbst. Ein
entscheidendes hartes Signal bleibt gültig, aber risk steht auf medium
statt low: ohne Prüfung wird nichts als harmlos durchgewinkt.
Das Behelfsstück in scripts/pipeline.ts ist weg. Dort riet vorher eine
guessBranch() den Zweig aus harten Signalen. Jetzt läuft das echte Gate.
Gebaut, getestet — und nach dem ersten echten Places-Lauf bewusst stillgelegt.
§1 sagt: „Der Wettbewerbsvorteil ist nicht Geschwindigkeit … Der Vorteil ist
der Kanal." Das Vorwärmen kauft nur Geschwindigkeit (3 s statt 20 s bis zur
Mail), und die ist bei einer E-Mail nichts wert. Begründung im Detail in
docs/vorwaermen.md.
Der entscheidende Punkt: §17 vergibt Priorität 1 für „schon einmal in einem echten Inserat aufgetaucht" — die Spec kennt das beste Signal also selbst. Genau diese Firmen landen ohnehin im Cache, weil jede Recherche gespeichert wird. Der Cache wärmt sich selbst, mit besserer Auswahl als die Places-Liste.
Was bleibt: seed_company als Warteschlange für gescheiterte Recherchen
(§8 Flow B, Schritt 4), und die 1.185 Berliner Firmen mit Website als
Website-Index für Phase 12 — den Crawl der eigenen Angebotsseiten. §15
erlaubt das ausdrücklich („Keine Massen-Mails an den Index" verbietet Mails,
nicht das Crawlen).
Die Doku und mehrere Quellen behaupten, Gemini 3 könne strukturierte Ausgabe
mit der Google-Suche kombinieren. Für generateContent stimmt das nicht.
Gemessen am 2026-08-20 mit npm run grounding:check:
| Konfiguration | Suchanfragen | Chunks |
|---|---|---|
googleSearch + responseJsonSchema |
0 | 0 |
googleSearch, freier Text |
3 | 3 |
googleSearch + JSON-Modus ohne Schema |
0 | 0 |
Nicht das strikte Schema ist das Problem, sondern jede JSON-Ausgabe. Und es gibt keinen Fehler: das Werkzeug fällt still weg.
Was dabei herauskommt, ist schlimmer als ein Absturz. Drei Läufe gegen
dieselbe Firma lieferten drei verschiedene E-Mail-Adressen — jeweils nach
dem Muster vorname.nachname@domain erfunden — samt Belegprosa über Seiten,
die nie geöffnet wurden („Das Impressum von … bestätigt", „Auf der Teamseite
werden … aufgeführt"). Mit confidence: "high".
Konsequenz 1: der Adapter macht zwei Aufrufe. Erst gegroundet in Prosa recherchieren, dann ohne Werkzeuge in das Schema übertragen. Der Strukturierungsschritt hat einen eigenen Systemprompt, der ihm ausdrücklich verbietet, Lücken aus eigenem Wissen zu füllen — sonst wäre die Trennung wertlos.
Konsequenz 2: keine Werkzeugspur ist ein Fehlschlag. Findet der Adapter
keine groundingMetadata, gibt er ok: false zurück, statt die
Erinnerungsantwort durchzureichen. Die Stufe leitet das nach T0 (§8 Flow B).
Abschaltbar über requireGrounding: false, aber nur für die Diagnose.
Konsequenz 3: keine geratenen URL-Pfade. Ein Zwischenstand hängte neun
vermutete Pfade an (/team, /unternehmen/team, /ueber-uns …), weil
urlContext nur URLs holt, die wörtlich im Prompt stehen. Das war eine
Sollbruchstelle: Ausgerechnet die kleinen Verwaltungen, für die es diesen
Cache gibt, haben /wir.html oder /index.php?id=7, und ein geratener Pfad
läuft still ins Leere. Übergeben wird jetzt nur die bekannte Adresse aus
dem Inserat; die Unterseiten findet das Modell über site:-Suchen — eine
Technik, die es in der Messung unaufgefordert selbst benutzt hat. Ist gar
keine Website bekannt, ist das Auffinden der Domain der erste Arbeitsschritt,
inklusive Rückfall auf Branchenverzeichnisse mit confidence: "low".
Konsequenz 4: die Recherche bekommt mehr Zeit, als §8 vorsieht — 90 s
statt 20 s, die Personenrecherche 180 s. Der Portal-Wettlauf rechtfertigt
Eile beim Senden, nicht beim Recherchieren: research_firma läuft einmal
pro Firma, das Ergebnis liegt danach dauerhaft im Cache, und eine fehlende
Adresse kostet den ganzen Direktkanal. Für Flow C argumentiert §8 selbst so
(„Budget in Minuten, nicht Sekunden"). Überschreibbar per
STAGE_TIMEOUT_RESEARCH_FIRMA in .env. Mehr Zeit allein hilft allerdings
nicht — der Prompt muss Gründlichkeit auch einfordern, deshalb der Abschnitt
„Gründlichkeit" in prompts/research_firma.md.
Konsequenz 5 für Phase 10: Die Personenrecherche steht vor demselben Problem, mit höherem Einsatz. Ein erfundener „Anknüpfungspunkt" in einer Mail an eine Privatperson ist der peinlichste denkbare Ausgang. Die Hook-Eval muss mitprüfen, ob überhaupt gesucht wurde — nicht nur, ob die Antwort plausibel klingt.
Der Places-Lauf ist gratis, nicht zweistellig. §17 rechnet mit einem
„zweistelligen" Betrag. Text Search hat ein monatliches Freikontingent
(1.000 bzw. 5.000 Anfragen je SKU); die 36 Anfragen aus
config/places-queries.json bleiben weit darunter.
Text Search liefert höchstens 60 Treffer je Anfrage. Deshalb wird nach
Bezirk und Suchbegriff aufgeteilt, statt einmal „Hausverwaltung Berlin" zu
fragen. Reicht die Ausbeute nicht, lassen sich in
config/places-queries.json Ortsteile oder PLZ ergänzen — dedupliziert wird
ohnehin über name_canonical.
Batch-Export und Import laufen als CLI, nicht als HTTP-Endpoint. §14
nennt für Phase 4 einen „Import-Endpoint", die fünfte harte Abhängigkeit sagt
aber: „Phasen 1–5 laufen über Fixtures und CLI. Der HTTP-Service kommt erst in
Phase 6." Die Logik liegt deshalb in src/lib/prewarm-import.ts und
src/db/seed.ts; Phase 6 hängt GET /prewarm/batch und
POST /prewarm/import davor, ohne dass sich etwas ändern muss.
Ein zweiter Recherche-Versuch kostet trotzdem. Schlägt der erste fehl und
greift der Adressversuch (§8 Flow B, Schritt 2), werden beide Aufrufe zu einem
LlmResult zusammengeführt — sonst unterschlüge die Tagesabrechnung (§16) den
ersten.
Schema v2: prewarm_quarantine kam dazu. §6 kennt die Tabelle nicht, §17
verlangt sie („Quarantänezeilen erscheinen in der Firmen-Ansicht zur
Sichtprüfung").
- Grounding-Freikontingent.
priceCallrechnet Suchanfragen konservativ immer ab. Die 5.000 freien Prompts pro Monat brauchen einen Zähler in der DB — gehört zu Phase 4, wenn derresearch-Adapter kommt. - Cache-Treffer-Preis. Gecachte Input-Tokens werden derzeit zum vollen Input-Satz abgerechnet. Die Schätzung liegt damit zu hoch statt zu niedrig.
- Thinking-Tokens. Werden als
candidatesTokenCount + thoughtsTokenCountabgerechnet, weil Gemini beide getrennt meldet. Der Adapter prüft das jetzt selbst gegentotalTokenCountund warnt einmal pro Prozess, wenn die Summen nicht aufgehen. Ein Aufruf mit tatsächlichen thinking-Tokens (also gegen 3.7 Flash oder 3.1 Pro, nicht Flash-Lite) steht noch aus.
npm install scheiterte auf diesem Rechner mit EPERM auf
/Users/almi/.npm/_cacache — der npm-Cache gehört root (bekannter Fehler
älterer npm-Versionen). Umgangen über ein .npmrc mit projektlokalem Cache
(.npm-cache/, beides gitignored). Dauerhaft behebt es:
sudo chown -R 501:20 "$HOME/.npm"Danach kann .npmrc weg. Ebenfalls offen: git init ließ sich aus meiner
Sandbox nicht ausführen, das Verzeichnis ist noch kein Repository.