Zuletzt aktualisiert:

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-APIClient encodet via convertToSnakeCase, also nutzen die Pydantic- Modelle dieselbe Konvention wie der ganze Backend-Rest (dish_id …). Responses bleiben camelCase (tempId, proteinG, decidedDishId): Schlüssel ohne Unterstrich passieren convertFromSnakeCase unverä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)

„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): signals wandert 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)
}

KI-Pfad (ai_jobs, ADR-13/37)

Neue Job-Art cook_suggest (Muster: bestehender job_ideas, aber mit den harten Fakten + Validierung). Ablauf:

  1. generating queued _queue_job("cook_suggest", ref_id=sessionId).
  2. 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.
  3. D5-Validierung serverseitig: jede KI-Zutat per Namens-Match auf food_id auflösen, Mengen plausibilisieren, stockBand aus 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.
  4. Worker POSTet das Ergebnis an einen internen Endpoint (X-Internal-Token), der generating → proposed schaltet, die Vorschläge persistiert und die LA pusht („N Vorschläge bereit").
  5. Job-Fehler: Session fällt zurück auf waiting (beide bleiben ready), Client kann erneut anstoßen (Error-Envelope recoverability: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 nach decided. Der Client pollt GET /api/dishes/{id} (oder beim Foregrounding) — steps ist erst leer, füllt sich nach dem Job-Lauf. Signal = steps non-empty (kein ai_pending-Flag). Bild on-demand. Idempotent (Guard auf decidedDishId).

F1 (Notif-Diät, Gerätetest 19.06.): cook_suggest + steps lö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 foods sind 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 Zutat grams + kcal/protein/carbs/fat (für die Menge) + servings. 2. _cs_validate lö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_finalize legt fehlende Foods an (source='ai', dedupe per Name) → Zutatenliste vollständig; dishes.nutrition bleibt NULLdish_macros (Detail) und preview-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_macros der echten Box → Karte == Gericht. Nebenwirkung: KI-erzeugte Zutaten erscheinen als source='ai'-Foods in den Stammdaten (gewollt, dedupe, filterbar).

TTL / Aufräumen

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). acceptdecided (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

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.