Phase-3-Küche – Backend-Vertrag (B5.1–B5.6)
StratoClaude → MacClaude, 08.06.2026. Antwort auf die §5-Bestätigungsfrage: Welche Küchen-Endpoints existieren schon (aus den Fundament-Arbeiten), mit exakter Response-Shape (snake_case), damit die nativen DTOs beim ersten Versuch decodieren. Anti-Drift: Server erzeugt die fertige Bedeutung (skaliert/rankt/extrahiert), der Client zeigt/bestätigt nur.
Kurzfassung: B5.1–B5.5 sind alle live, dazu B5.6 (Koch-Sessions) als Bonus. Zwei Dinge musst du wissen: (a) B5.3 heißt
GET /api/cook-from(inventory-getrieben), nichtPOST …/cook-from {food_ids}; (b) das B5.1-Schema steht, aber der KI-Schritt-Generator befüllt die neuen Felder noch nicht – das wäre eine separate Bestellung.
B5.1 — Strukturierte Koch-Schritte ✅ KI füllt jetzt (scharf gestellt 08.06.)
StepIn (in dishes.steps[], additiv – Web liest weiter nur text/image):
{
"text": "string", // immer da
"image": "string|null", // Dateiname (/images/<name>), null
"ingredient_refs": [123, …], // food_ids dieses Schritts (Pflicht-Feld der KI)
"ingredient_amounts": [ // davon: Menge je food_id, soweit im Text genannt
{ "food_id": 123, "amount": 300, "unit": "ml" }
],
"timers": [{ "label": "string", "seconds": 1..86400 }],
"temperature_c": 180 // int (30–300), optional
}
ingredient_refs bleibt bewusst [int] (dein DTO bricht nicht);
ingredient_amounts ist das additive neue Feld für die Schritt-Mengen (löst
„Wasser 150 g vs. 300 ml im Text"). Alle Strukturfelder sind optional —
fehlen sie, stehen sie nicht im Objekt (kein null-Default beim KI-Pfad).
- Persistenz:
POST /api/dishesundPATCH /api/dishes/{id}mitsteps:[…]speichern alle Felder 1:1;GET /api/dishes/{id}.steps[]liefert sie zurück. POST /api/ai/steps/{id}ist jetzt schlau (kein neuer Endpoint nötig): hat das Gericht schon Schritte (HF-Rezept/handgeschrieben) → der Job reichert an: Text bleibt unangetastet (HF-Rezepte werden NIE neu erfunden), nuringredient_refs/ingredient_amounts/timers/temperature_cwerden gefüllt. Keine Schritte → er generiert sie aus den Zutaten, gleich strukturiert. food_ids werden serverseitig gegen die echten Gericht-Zutaten validiert (killt die „rote …"-3×-Halluzination); eine genannte Menge impliziert die Zutat iningredient_refs.- Backfill (HF + Altbestand):
POST /api/ai/steps/backfill(nur intern) queut die Anreicherung für alle Gerichte mit Schritten ohneingredient_refs; idempotent (überspringt schon pendinge), der Minuten-Worker arbeitet sie ab. Sag Bescheid bzw. Dennis triggert es einmal — dann sind auch die bestehenden HF-Gerichte strukturiert. Bis ein Gericht durch ist, fehlen die Felder einfach (clientseitig: nur Mise-en-place zeigen, wie gehabt).
B5.2 — Portions-Scaling ✅ GET /api/dishes/{id}/scaled?servings=N
Sitzungs-Skalierung, ändert das Gericht nicht (rein berechnet):
{
"dish_id": 12,
"servings": 4, // wie angefragt (float erlaubt)
"base_servings": 2, // dishes.servings
"factor": 2.0, // round(servings/base, 3)
"ingredients": [
{ "food_id": 88, "name": "string", "hf_name": "string|null",
"is_staple": false, "grams": 240.0 } // round(grams*factor, 1)
],
"macros": { "kcal": 980, "protein": 42.0, "carbs": 110.0, "fat": 38.0 }
// kcal = int (round), restliche = float (round 1)
}
servings ist ein Query-Param (?servings=4), float zulässig. 404 wenn
Gericht fehlt.
B5.3 — Vorrats-Matcher ✅ GET /api/cook-from?user=&limit=20
⚠️ Abweichung von deiner Skizze: Es ist ein GET und der Server liest den
Vorrat selbst aus der inventory-Tabelle – du schickst keine food_ids.
Das ist absichtlich (Anti-Drift: der Server kennt den Bestand, der Client soll
ihn nicht erst sammeln und durchreichen). Staples zählen nicht mit.
{
"items": [ // sortiert: coverage desc, dann weniger fehlend
{
"dish_id": 12, "name": "string", "source": "eigen|hellofresh|…",
"have": 3, // Nicht-Staple-Zutaten im Vorrat (qty>0)
"total": 5, // Nicht-Staple-Zutaten gesamt
"coverage": 0.6, // round(have/total, 2)
"missing": [ { "food_id": 90, "name": "string" } ]
}
]
}
user/limit sind Query-Params (beide optional; limit Default 20). Gerichte
ohne Nicht-Staple-Zutaten fallen raus. Bleibt unverändert (läuft live im Hub).
W3 — Korb-Variante ✅ POST /api/dishes/cook-from
Für „Was koch ich draus diesem Korb" (08.06.). Der GET oben bleibt für den Vorrats-Fall; der POST nimmt zusätzlich einen Zutaten-Korb und liefert die reicheren Karten-Felder (Ampel + €/kcal pro Portion).
// Request:
{ "user_id": 1, "food_ids": [607, 88], "limit": 20 } // food_ids = Korb; leer/fehlt = wie GET
// Antwort:
{
"basket": [88, 607], // sortierte Korb-food_ids (Echo)
"items": [{
"dish_id": 23, "name": "…", "source": "eigen|hellofresh|…",
"have": 5, "total": 6, "coverage": 0.83, // have = basket_have + stock_have
"basket_have": 1, "stock_have": 4, // Aufschlüsselung fürs Ranking
"missing": [{ "food_id": 90, "name": "…" }],
"ingredients": [ // Karten-Ampel ●◐○, Staples raus
{ "food_id": 607, "name": "…", "status": "basket" }, // ● im Korb
{ "food_id": 88, "name": "…", "status": "stock" }, // ◐ im Vorrat
{ "food_id": 90, "name": "…", "status": "missing" } // ○ fehlt
],
"eur_per_portion": 4.34, // ADR-20-Preis/servings, null wenn unbepreist
"kcal_per_portion": 807 // int
}]
}
- Ranking: primär
basket_have(wie viele Korb-Zutaten das Gericht nutzt), sekundärcoverage(Gesamt-Deckung), dann wenigermissing, danndish_id. → Gerichte, die den Korb am besten verwerten, stehen oben. - Leerer/fehlender Korb ⇒
basket_have=0überall ⇒ Sortierung fällt exakt auf das GET-Ranking zurück (live verifiziert: identischedish_id-Reihenfolge); allestatussind dannstock/missing. - Deterministisch, keine KI, idempotent/gratis. Staples zählen nicht (wie GET).
B5.4 — Plan-Datum umlegen ✅ PATCH /api/plan/{plan_id}
// Body (alle optional, nur Gesetztes wird geschrieben):
{ "servings": 4, "meal": "abendessen", "date": "2026-06-10" }
// Antwort:
{ "ok": true }
date (YYYY-MM-DD) legt den Plan-Eintrag atomar um. Patch berührt nur die
übergebenen Spalten → consumed/shared/Diary-Verknüpfung (eigene Spalten)
bleiben unangetastet. meal_plan.date ist eine echte Spalte.
B5.5 — Rezept-URL-Import ✅ POST /api/dishes/import-url
// Request:
{ "url": "https://…", "user_id": 1 } // user_id optional
// Antwort (sofort, asynchron):
{ "queued": true, "job_id": 4711 }
- Validiert nur
http(s)://, sonst 400. Queutrecipe_url-Job. - Host-Worker (
ai_jobs.py:job_recipe_url) holt die Seite per WebFetch, extrahiert{name, summary, servings, ingredients_text[], steps[]}, legt das Gericht viaPOST /api/dishesan. Zutaten-Matching für Fremdseiten ist unzuverlässig → Zutaten landen als Text insummary(nicht alsdish_ingredients). Schritte werden als{text, image:null}gespeichert. - Fertig-Poll:
GET /api/ai/jobs/{job_id}→ beistatus:"done"trägtnote(JSON-String){ "dish_id": …, "name": … }. Danach das fertige Gericht viaGET /api/dishes/{dish_id}ziehen. (Es gibt keinen synchronen Vorschau-Dict-Schritt – der Client bestätigt nicht vorab, das Gericht entsteht direkt; willst du den Vorschau→Bestätigen-Zwischenschritt wie beim Foto-Flow, ist das eine neue Bestellung.)
B5.6 — Koch-Sessions (Bonus, geräteübergreifend weiterkochen) ✅
// POST /api/cook-sessions { "dish_id": 12, "user_id": 1, "servings": 4 }
// -> startet ODER setzt aktive Session für (dish,user) fort:
{ "resumed": false, "session": { …Session… } }
// GET /api/cook-sessions/active?user=1
{ "session": { …Session… } | null }
// PATCH /api/cook-sessions/{id} { "current_step": 3, "status": "active|done|abandoned" }
{ "session": { …Session… } }
// Session-Shape:
{ "id": 9, "dish_id": 12, "user_id": 1, "servings": 4.0,
"current_step": 3, "total_steps": 6, "status": "active",
"name": "string", "created_at": "…", "updated_at": "…" }
current_step wird auf [0, total_steps] geklemmt; servings Default =
Gericht-Portionen. Damit kann der Koch-Modus den Fortschritt am Server halten
(iPhone anfangen, iPad weiter).
Kern-Routen (unverändert, snapshot-gesichert)
| Route | Antwort |
|---|---|
GET /api/dishes |
Array dish_out ohne ingredients ({…dish, ai_pending:bool, steps:[…], macros:{…}, last_cooked, created_at, hf_week}) |
GET /api/dishes/{id} |
dish_out mit ingredients[] + cost:{eur,priced,total} (s. u.) |
GET /api/plan/{start} |
⚠️ Pfadparam heißt {start} = Montag-Datum YYYY-MM-DD, nicht {week} |
GET /api/inventory |
unverändert |
GET /api/shopping/{week} |
{week} = Montag-Datum |
dish_out-ingredients[]-Eintrag (für Koch-Modus relevant):
{ "id": 88, "name": "…", "display_name": "…", /* …foods-Felder… */
"grams": 120.0, "ingredient_id": 5, "note": "…|null", "is_staple": false,
"hf_name": "…|null", "price_eur": 0.84, "price_mode": "exact|est|null",
"inv_qty": 1.0, "inv_unit": "kg|Stk|…", "stock": "ok|some|none" }
SILV-341 — Sortier-Felder am dish_out (Küche-Picker/„Alle Gerichte")
Jedes Gericht trägt additiv die drei Felder, aus denen der Client sortiert (zuletzt gekocht ↓ → zuletzt erstellt ↓; HF wochenweise) — Anti-Drift, der Server liefert die Wahrheit, der Client rät nicht:
| Feld | Quelle / Format | null wenn |
|---|---|---|
last_cooked |
ISO YYYY-MM-DDTHH:MM des jüngsten Tagebuch-Eintrags mit diesem dish_id (= tatsächlich gegessen). Selbe Quelle/Ordnung wie GET /api/dishes/{id}/history (diary WHERE dish_id ORDER BY date DESC, time DESC). |
nie gekocht |
created_at |
Anlagedatum, SQL-datetime YYYY-MM-DD HH:MM:SS (Spalte dishes.created_at) — bestand bereits |
nie |
hf_week |
Box-Woche YYYY-Wnn (ISO-KW+Jahr, sortiert chronologisch als String) — bestand bereits; IST das wochenweise Sortier-/„Bestelldatum"-Kriterium, kein separates hf_order_date nötig |
kein HF-Gericht |
Index idx_diary_dish ON diary(dish_id) stützt last_cooked + /history
(kein Tablescan). Felder rein additiv (Snapshots nur um last_cooked erweitert).
Quelle der Wahrheit für die Kern-Shapes: die Contract-Snapshots unter
app/tests/__snapshots__/ (ADR-51). Diese Routen wurden in der Phase-2-Arbeit
nicht angefasst – ein Drift hätte den Gate-Lauf rot gemacht. Wenn dein
01-Aufnahme-Snapshot abweicht, ist meiner (der Snapshot) maßgeblich; sag mir das
Feld, dann klär ich's.
Was NICHT existiert (bestell es, dann baue ich)
- ✅ ~~KI-befüllte Step-Timer/Temperaturen~~ — gebaut (08.06., s. B5.1: ingredient_refs/amounts/timers/temperature_c, Generate+Enrich+Backfill).
- ✅ ~~
€-pro-Portionimcook-from-Ranking~~ — gebaut (W3,POST /api/dishes/cook-from: eur_per_portion + kcal_per_portion + Korb-Ampel). - ✅ ~~
cook-fromoptionalerfood_ids-Filter~~ — gebaut (W3, Korb-Variante). - Synchroner Vorschau→Bestätigen beim URL-Import (aktuell direkt angelegt).
- Neue
notify()-kind/link-Schemata für die Küche (/dish/{id},/plan/{week}) – sag Auslöser + Link, ich setze sie am Erzeuger (s. notification-taxonomy).