title: "Was koch ich draus? — Co-Plan-Backend-Vertrag (SILV-325 / SILV-304)" category: dev date: 2026-06-18
„Was koch ich draus?" — Co-Plan-Session + KI-Vorschläge (SILV-325)
Wer: StratoClaude (Backend) → MacClaude (native App, SILV-304).
Status: Vertrag gegenbestätigt (Bus, 18.06.) gegen Mac's autoritative
Client-Form (app/ios-native/.../CookSuggest/CookSuggestModels.swift +
CookSuggestAPI.swift, Commit 8f1e535). Diese Datei ist die Backend-Wahrheit;
die Swift-DTOs sind die Client-Wahrheit — sie sprechen dieselbe Form.
Anti-Drift-Charta (PFLICHT): Der Server erzeugt die Bedeutung. Er holt die harten Fakten je User (Restbudget, offene Makros, Eiweiß-Ziel, heutiges Training, Vorrat-Band, offene HF-Box), formuliert die „Heute-beachten"-Signale, validiert die KI-Zutaten (D5-Lehre), textet die Hinweise und persistiert das gewählte Gericht. Der Client sammelt nur Wünsche, zeigt an, treibt die Live Activity. Vorrat ist immer ein grobes Band („wohl da"/„knapp"/„fehlt") — NIE die Behauptung „du hast alles".
Naming / Abgrenzung
⚠️ Nicht verwechseln mit cook_sessions (/api/cook-sessions, der bestehende
Einzel-User-Koch-Durchlauf Schritt-für-Schritt). Das hier ist eine Plan-/
Vorschlags-Session zu zweit. Eigener Namensraum: Tabellen cook_suggest_*,
Pfade /api/cook-suggest/sessions.
Zustandsmaschine (Server ist autoritativ)
Enum verbatim wie CookSessionState (Swift):
collecting → waiting → generating → proposed → finalizing → decided
↘ (jederzeit) cancelled
| State | Bedeutung | Verlassen durch |
|---|---|---|
collecting |
Wizard-Phase; mind. ein Teilnehmer noch nicht ready |
letzter ready |
waiting |
Warteraum: mind. ein Teilnehmer ready, wartet auf den/die anderen |
alle ready |
generating |
KI baut die 3 Vorschläge (ein ai_jobs-Job, Art cook_suggest) |
Job-Ergebnis |
proposed |
3 Vorschläge liegen vor, Einigung (pick/confirm) läuft |
confirm-Einigung |
finalizing |
gewählt → Server baut Bild + Schritte + legt das Gericht an (async) | Finalize-Job fertig |
decided |
fertig: decidedDishId/decidedTitle stehen |
terminal |
cancelled |
abgebrochen / TTL abgelaufen / letzter ist gegangen | terminal |
State-Ableitung (Server berechnet sie aus den Teilnehmern, nie der Client):
solange nicht generating/proposed/… erreicht ist, gilt:
alle present-Teilnehmer ready → generating · sonst irgendwer ready → waiting ·
sonst → collecting. Solo (≤1 Teilnehmer): die „beide"-Gates fallen
automatisch durch — ein einzelner ready löst sofort generating aus, pick
löst sofort finalizing aus (kein confirm nötig).
Gating rechnet nur über present=true-Teilnehmer. Wer per leave raus ist,
blockt kein Gate mehr (Anti-Strand).
Endpoints
Alle Mutationen liefern den vollen Session-Zustand zurück (CookSuggestSession);
der Client rendert IMMER aus diesem einen Objekt. Mutationen ohne Retry.
Wire-Format (hart erarbeitet, SILV-325-Gerätetest): Request-Bodies sind
snake_case(temp_id,with_user_id,vorrat_mode,proceed_solo,*_note) — der iOS-APIClientencodet viaconvertToSnakeCase, also nutzen die Pydantic- Modelle dieselbe Konvention wie der ganze Backend-Rest (dish_id…). Responses bleibencamelCase(tempId,proteinG,decidedDishId): Schlüssel ohne Unterstrich passierenconvertFromSnakeCaseunverändert und matchen die Swift- Properties 1:1. Ein camelCase-Request-Modell verschluckt sonst still die Felder (Defaults greifen, 200 OK) bzw. wirft 422 bei Pflichtfeldern (pick {temp_id}).
| Methode | Pfad | Zweck |
|---|---|---|
POST |
/api/cook-suggest/sessions |
Session starten. Body {initiator, with_user_id?, date}. Idempotent je (initiator, withUserId, date) über offene Sessions (resume statt Dublette, analog cook_start). withUserId gesetzt = Co-op → Push + pushToStart-LA an den anderen („Plan mit X das Essen"). |
GET |
/api/cook-suggest/sessions/{id} |
Voller Zustand (Polling / nach LA-Deep-Link). |
PUT |
…/{id}/answers/{userId} |
Wizard Schritt 1–3. Body {cravings:[], vorrat_mode, consider_protein?, consider_budget?, craving_note?, ingredient_note?, heute_note?}. Setzt answered=true, speichert Prefs (inkl. der Freitext-Notizen je Schritt, Dennis 18.06.), rechnet die Signale dieses Users neu. Leere Notizen werden verworfen; der Prompt-Bau nimmt sie mit (Server bleibt Bedeutung). SILV-331 additiv: portion_size ∈ {snack,leicht,normal,deftig} (Größenfeld = kcal/Portion, getrennt von Geschmack-Chips + for_whom). |
POST |
…/{id}/participants/{userId}/ready |
Warteraum bestätigen (ready=true). Optional Body {proceed_solo:true} = „weiter ohne sie" (s. u.). Alle ready → generating. |
POST |
…/{id}/regenerate |
2. KI-Runde, schließt schon gezeigte tempIds aus. Setzt picks/confirms zurück → generating. |
POST |
…/{id}/pick/{userId} |
Body {temp_id}. Setzt pickedTempId des Users; löscht confirmed des anderen (neue pick = Gegenvorschlag). Solo → direkt finalizing. |
POST |
…/{id}/confirm/{userId} |
Body {temp_id}. Bestätigt den aktuellen Pick des anderen. Einigung → finalizing. |
POST |
…/{id}/leave/{userId} |
NUR die eigene Präsenz raus (present=false). Nie die des anderen. Letzter raus → cancelled. |
Idempotenz & Doppelbuchungs-Schutz (data-realist / waf-skeptic)
createresumed eine offene Session statt Dubletten zu zeugen (Doppel-Tap, Re-Entry, Deep-Link sind sicher).readyist idempotent je User. Der Übergang→ generatingist atomar: rasen beide gleichzeitig aufready, wird genau EINai_jobs-Job gequeued (Guard über den State-Wechsel, nicht über „hat schon Job"-Polling).pick/confirm— der Doppelbuchungs-GAU: „einer schlägt vor, der andere bestätigt".confirmträgt dastempId, das es bestätigt (Optimistic Concurrency). Stimmt es nicht mit dem aktuellenpickedTempIddes anderen überein (Partner hat zwischen Render und Tap umgeschwenkt), wird derconfirmals No-Op abgewiesen (409-Semantik → voller Zustand zurück, Client re-rendert den neuen Pick, der Nutzer bestätigt erneut). Ohne diesestempIdließe sich „ich bestätige die Lasagne, sie kocht jetzt Curry" nicht verhindern. → Client-Form-Nachzug (Mac):confirm(_:userId:tempId:)bekommt einentempId-Body (war ohne).finalizelegt genau EIN Gericht je Session an (Guard aufdecidedDishId). Retets/Doppelklick führen nicht zu zwei Gerichten.
„Weiter ohne sie" (Anti-Strand, Co-op)
leave/{userId} ist immer nur Selbst-Austritt — niemand kann den anderen aus
der Session werfen (WAF-GAU „Jaqueline strandet Dennis"). Will der Initiator
ohne einen noch nicht eingestiegenen Partner weiter, schickt er ready mit
{proceed_solo:true}: der Server lässt die noch-nicht-ready-present-Partner aus
dem Generate-Gate fallen (sie werden NICHT entfernt, können per GET weiter
zusehen / einsteigen, solange noch collecting/waiting) und startet die
Generierung mit den Wünschen der ready-Teilnehmer. Greift nur, wenn der Aufrufer
selbst ready ist.
DTOs (Server → Client) — verbatim wie die Swift-Decoder
CookSuggestSession
{
"id": "…", "state": "proposed", "date": "2026-06-18",
"initiatorUserId": 1,
"participants": [ /* CookParticipant */ ],
"proposals": [ /* CookProposal */ ], // leer außer in proposed/finalizing/decided
"decidedDishId": null, "decidedTitle": null
}
Form-Nachzug (Mac):
signalswandert von der Session auf den Teilnehmer (s. u.) — sonst liest ein pollendes Gerät die Signale des anderen.
CookParticipant
{
"userId": 2, "name": "Jaqueline",
"answered": true, "ready": false,
"pickedTempId": null, "confirmed": false,
"present": true,
"signals": { "protein": {"show":true,"label":"Du hast heute trainiert — eher proteinreich"},
"budget": {"show":false,"label":""} }, // NEU: pro Teilnehmer
"prefs": { "cravings":["deftig"], "vorratMode":"stock", "considerProtein":true,
"cravingNote":"scharf" } // C2: abgeschickte Wünsche; null bis answered
}
Jedes Gerät liest session.participant(myUserId).signals für seinen Schritt 3.
C2 (Warteraum): participant.prefs trägt die abgeschickten Wünsche je Teilnehmer
(null bis answered) → das andere Gerät zeigt „die Wünsche von X" im Warteraum.
„Leere Quelle = Schweigen": show=false ⇒ der Schritt wird ausgeblendet, nie
ein erfundener Hinweis.
CookProposal (+ CookSuggIngredient)
{
"tempId": "p1", "title": "Linsen-Bolognese", "subtitle": "passt zu deinem Eiweiß-Ziel",
"kcal": 540, "proteinG": 32,
"kcalEstimated": false, // SILV-331: true = Mehrheit ungematcht → Karte zeigt „~540 (geschätzt)"
"tag": "proteinreich", // | "meiste Zutaten wohl da" | "deine HF-Box"
"ingredients": [ {"name":"Rote Linsen","grams":120,"stockBand":"wohl da"},
{"name":"Passierte Tomaten","grams":200,"stockBand":"fehlt"} ],
"hfDishId": null // gesetzt = offene HF-Box (echtes Gericht)
}
stockBandist server-formuliert (grobes Band, nie „du hast alles").- Vollständige Makros (KH/Fett) bleiben server-seitig — der Client rendert
nur
kcal+proteinG; beidecidedbaut der Server das Gericht mit allen Makros. foodIdder Zutat bleibt server-intern (D5-Auflösung / Gericht-Anlage); optional-additiv später für Steckbrief-Deep-Link, nicht jetzt.- Genau 3 Vorschläge. Liegt eine offene (noch nicht gegessene) HF-Box vor,
ist einer der 3 diese Box (
hfDishIdgesetzt,tag:"deine HF-Box").
KI-Pfad (ai_jobs, ADR-13/37)
Neue Job-Art cook_suggest (Muster: bestehender job_ideas, aber mit den
harten Fakten + Validierung). Ablauf:
generatingqueued_queue_job("cook_suggest", ref_id=sessionId).- Host-Worker (
app/ai_jobs.py, minütlich): zieht harte Fakten je present-Teilnehmer über die interne API (Restbudget/offene Makros/Eiweiß-Ziel/ heutiges Training/Vorrat-Band/offene HF-Box), baut den Prompt aus Fakten + Wünschen → LLM (Gemini-Flash-Lite primär, CLI-Fallback) → 3 Vorschläge. - D5-Validierung serverseitig: jede KI-Zutat per Namens-Match auf
food_idauflösen, Mengen plausibilisieren,stockBandaus dem Vorrat formulieren, kcal/Makros aus aufgelösten Foods rechnen (KI-Zahlen nicht blind glauben). Halluzinierte/unauflösbare Zutaten werden bereinigt, nie 1:1 durchgereicht. - Worker POSTet das Ergebnis an einen internen Endpoint (
X-Internal-Token), dergenerating → proposedschaltet, die Vorschläge persistiert und die LA pusht („N Vorschläge bereit"). - Job-Fehler: Session fällt zurück auf
waiting(beide bleibenready), Client kann erneut anstoßen (Error-Enveloperecoverability:transient).
Snapshot-Treue: Die harten Fakten + Signale werden bei Generate eingefroren
(auf der Session gespeichert), damit regenerate (Runde 2) gegen denselben
Stand rechnet und der Plan nicht mitten in der Session driftet.
decided → Gericht
finalizing ist synchron + instant (im pick/confirm-Request, NICHT am
Minuten-Worker): der Server legt genau ein Gericht an (ai_pending=0 — der
Vorschlag-Titel IST der Name, kein Auto-Naming, das überschriebe ihn), setzt
decidedDishId/decidedTitle und gibt sofort decided zurück.
Schritte-Mechanismus (K3-Follow-up): Der
steps-Job schreibt die Schritte server-seitig ans Gericht (PATCH /api/dishes/{id} {steps}), bis ~60 s nachdecided. Der Client polltGET /api/dishes/{id}(oder beim Foregrounding) —stepsist erst leer, füllt sich nach dem Job-Lauf. Signal =stepsnon-empty (keinai_pending-Flag). Bild on-demand. Idempotent (Guard aufdecidedDishId).F1 (Notif-Diät, Gerätetest 19.06.):
cook_suggest+stepslösen keine „… abgeschlossen"-Broadcast-Notif aus (die LA signalisiert „Vorschläge bereit" selbst; man ist eh in der App). Einladung + Entschieden bleiben.F3 (KI erden): Der Prompt trägt Plausibilitäts-Leitplanken (EIN kohärentes Alltagsgericht/+Beilage, „würde im Kochbuch stehen?", Protein über stimmige Zutaten statt gestapelte Fertigprodukte, max 1 Dessert) — keine harten Ausschlüsse. Prompt-Änderung bewusst konservativ; A/B-Dry-Run auf Wunsch.
K3 — kcal-Wahrheit über VOLLSTÄNDIGE Zutaten (Anti-Drift, Gerätetest 18./19.06., definitiv): Der Widerspruch „Detail 350 kcal vs. Editor 87 kcal" kam von der unvollständigen Zutatenliste — bei „freie Wahl" erfindet die KI Zutaten (z. B. Müsli), die nicht in
foodssind und beim Anlegen rausfielen, während ein gespeicherter Nährwert die KI-Gesamt-kcal trug. Fix (kein Pflaster): 1. Der Prompt fordert die vollständige Zutatenliste + je Zutatgrams+kcal/protein/carbs/fat(für die Menge) +servings. 2._cs_validatelöst je Zutat ein Food auf (echte Per-100g-Werte) oder leitet Per-100g aus den KI-Per-Zutat-Werten ab. Karte-kcal/proteinG(je Portion) = Summe der Zutaten / servings (eine Quelle). 3._cs_finalizelegt fehlende Foods an (source='ai', dedupe per Name) → Zutatenliste vollständig;dishes.nutritionbleibt NULL →dish_macros(Detail) undpreview-macros(Editor) rechnen beide aus genau dieser Liste = identisch, und das Gericht enthält ehrlich, was draufsteht (Müsli!).Snack-Größe (Root 2): Der Prompt steuert die Portions-kcal je Lust-Chip hart (Kleiner Snack ~100–300, Leicht ~300–450, Mahlzeit ~400–700, Deftig höher) +
considerBudget→ Per-Portion-kcal ins Restbudget. HF-Box:dish_macrosder echten Box → Karte == Gericht. Nebenwirkung: KI-erzeugte Zutaten erscheinen alssource='ai'-Foods in den Stammdaten (gewollt, dedupe, filterbar).
TTL / Aufräumen
- Session-TTL: 6 h ohne Mutation ODER Tageswechsel der
date→cancelled(es ist eine „was kochen wir HEUTE"-Frage). Reap läuft lazy beicreate(_cs_reap_stale()— bounded, kein Cron nötig; der Maintenance-Sweep ist GELOCKT „nur melden, nie reparieren" und fasst keine DB an). leaveletzter present-Teilnehmer → sofortcancelled.
Live Activity
Eigener Vertrag: cook-suggest-live-activity-contract.md (Content-State-Struct,
context_key, Push-Trigger, pushToStart an den eingeladenen Partner).
Phase 2 (SILV-330): Preview-statt-Auto-Anlegen + Accept (GELOCKT 19.06.)
Warum: Das Auto-Anlegen bei pick/confirm (Phase 1) committete die KI-Zutaten
ungeprüft als Gericht — Quelle der K3-Schmerzen (unvollständige Liste, KI-Müll-Foods).
Phase 2 schiebt einen expliziten Übernehmen-Schritt dazwischen:
Auswahl → Vorschau → Zuordnen → Speichern. Der Nutzer ordnet die Zutaten echten
Foods zu (oder akzeptiert die KI-Schätzung), DANN entsteht das Gericht.
Form gegenbestätigt (Bus, 19.06.); design.md §8 (Commit 0083d67).
Zustand: finalizing = Preview (KEIN neuer Enum-Wert)
pick (solo) / confirm (co-op) → finalizing legt NICHT mehr auto an.
Stattdessen: Server startet zwei in-container-async-Generierungen (_or_text
Schritte + _or_image Bild — eigene db.connect()-Connection je Thread, kein
Minuten-Worker-Lag) für den gewählten Vorschlag, Ergebnisse landen in die Session
(kein Dish). accept → decided (Dish entsteht jetzt). Die zwei Readiness-Flags
treiben zwei Fortschrittsbalken; bei beiden ready zeigt der Client den Zuordnen/Speichern-Schritt.
Zurück aus der Preview (Server-Fähigkeit, UI = SILV-329): ein erneutes pick im
State finalizing verwirft die Preview (Steps/Bild/Flags reset) und re-startet sie
mit der neuen Wahl. (Der „Zurück zu Vorschlägen"-Knopf ist Folgeticket SILV-329.)
Session-DTO additiv: preview (null außerhalb finalizing/decided)
"preview": {
"tempId": "p1",
"steps": [ … ] | null, // null bis stepsReady
"imageRef": "tmp-…jpg" | null, // null bis imageReady; session-gated /images/ + AuthImage
"stepsReady": false,
"imageReady": false,
"ingredients": [ { "name", "grams", "kcal", "proteinG", "carbsG", "fatG",
"suggestedFoodId": Int? } ] // suggestedFoodId = naher foods-Match (Vorbelegung)
}
POST …/{id}/accept/{userId} — Body snake_case (iOS-Encoder!)
(userId = der Speichernde / im Co-op der Bestätigende — Pfad-Form konsistent zu
pick/confirm/leave/ready. Das Gericht ist geteilt, egal wer speichert.)
{ "portions": 2,
"ingredients": [
{ "name": "Skyr", "grams": 250, "food_id": 42 }, // auf echtes Food gemappt
{ "name": "Müsli", "grams": 60, "create_ai": true, // KI-Schätzung übernehmen
"kcal": 220, "protein_g": 6, "carbs_g": 38, "fat_g": 5 }
] }
→ jetzt Dish anlegen: servings = portions, source='eigen', nutrition = NULL
(→ Detail == Editor, aus Zutaten), gemappte food_id = echte Food-Werte, create_ai
= KI-Food anlegen (dedupe per Name, source='ai'), Preview-Bild + -Schritte aufs
Gericht übernehmen → decidedDishId, state='decided'. Idempotent (Guard auf
decidedDishId). Co-op: der Bestätigende (jeder present-Teilnehmer) triggert
Preview + Accept, Gericht geteilt.
answers additiv (snake_case) + Prompt
for_whom∈{me, couple, family}(Client-Labels „Nur für mich"/„Mit X"/„Für alle", Portionen 1/2/4) +portions(1/2/4). Neuer Craving-Wertkindgerecht(Chip auf Schritt 1, unabhängig vonfor_whom).portionszweifach:answers.portionsdimensioniert den Prompt;accept.portions= finaleservings(im Accept-Screen anpassbar) — accept gewinnt.- Prompt nutzt
for_whom/portions(Größe + kindgerecht) und liefert je Zutatgrams+kcal+Makros (wie der K3-Wurzel-Fix).
POST /api/dishes/preview-macros additiv (Live-Makros im Zuordnen)
Nimmt zusätzlich zu {food_id, grams} jetzt ad-hoc-Einträge {grams, kcal,
protein_g, carbs_g, fat_g} (ohne food_id) — so bleibt die Live-Summe beim Schieben
server-berechnet (gemappt = echte Food-Werte, create_ai = KI-Makros), kein
client-seitiges Nachrechnen (Anti-Drift). Client debounced die Calls.