Portionswahl & Portionsnamen — Client-Vertrag (SILV-202/215/216/217)
Hintergrund: Auf dem Heute-Screen loggt ein Tap die Standard-Portion (1×); per
Long-Press präzisiert der Nutzer die Menge — „heute mal 2 Brötchen". Backend-
Bestellungen MacClaude→StratoClaude (Agent-Bus, 13.06.). Dennis-Lock: grams
bleibt die kcal-Wahrheit, alle Portionsfelder sind rein additive
Anzeige-Metadaten. kcal/Makros rechnet der Server weiterhin ausschließlich aus
grams (bzw. servings bei Gerichten) — die Portionswahl rührt sie nie an.
Tickets: SILV-216 (portion_count), SILV-218 (portion_g-Durchreichung), SILV-219 (Portionsnamen-Katalog + Mehrzahl).
Was der Server speichert
Neue nullable Spalte diary.portion_count REAL (Migration, kein Backfill —
die Rückrechnung alter Einträge aus grams/portion_g macht der Client,
SILV-215). Fließkommazahl: ganze Zahl + Teilstück, z. B. 2.5 = „2 ½ Brötchen".
NULL = keine Mengenwahl getroffen. foods.portion_name bleibt ein
Singular-String (kein FK, keine Migration) und matcht case-insensitiv gegen
den Katalog.
Eingang (Schreiben)
| Endpoint | portion_count |
|---|---|
POST /api/diary |
optional float (>0, ≤1000), zusätzlich zu grams |
POST /api/diary/quick |
optional float (>0, ≤1000) |
PATCH /api/diary/{id} |
partial: Feld weglassen = unverändert · float>0 = setzen · explizites null = entfernen |
Die PATCH-Semantik nutzt
model_fields_set— nur ein im Body vorhandenesportion_countwird angefasst.portion_count: 0ist ungültig (422).
Ausgang (Lesen) — jeder Diary-Eintrag trägt vier Portionsfelder
Über den zentralen Serialisierungs-Chokepoint (_hide_internal) trägt jede
zurückgegebene Tagebuch-Zeile additiv:
{
// … bestehende Felder (name, grams, kcal, …) …
"portion_count": 2.5, // float? — gewählte Menge, sonst null
"portion_g": 25, // float? — foods.portion_g des Artikels, sonst null
"portion_name": "Scheibe", // string? — Einheit (Singular), sonst null
"portion_name_plural": "Scheiben"// string? — kuratierte Mehrzahl, sonst null
}
Quellen: GET /api/day/{user}/{date} (jeder entries[]), POST /api/diary/quick
(entry), PATCH /api/diary/{id} (entry), DELETE /api/diary/{id} (deleted)
und POST /api/diary/restore (entry).
portion_g (SILV-218/224)
Gramm pro EINER Einheit. Quelle: Food-Eintrag → foods.portion_g
(SILV-218); Freitext-/Foto-Eintrag (kein food_id) → die auf der diary-Zeile
gespeicherte portion_g (SILV-224). null bei Gericht/ohne Wert. Nicht an
portion_count gekoppelt: gerade Gramm-only-Alteinträge (kein
portion_count) brauchen es, damit der Client die Portionsmenge selbst
zurückrechnet (grams / portion_g, ehrlich mit „~").
portion_name (SILV-216, mit SILV-217 zusammengeführt)
- Artikel mit gepflegtem
portion_name(z. B. „Scheibe", „Becher-Drittel") → genau dieser Singular as-is — auch ohneportion_count(damit zurückgerechnete Mengen benannt werden können). - Sonst (Freitext / Artikel ohne
portion_name): Server-Generik"Portion"/"Portionen"(Singular bei genau1), aber nur wenn einportion_countgewählt wurde (die Generik braucht eine Anzahl für Singular/Plural). Ohne Menge und ohne food-Namen →null.
portion_name_plural (SILV-217)
Kuratierte Mehrzahl aus dem Katalog (s. u.) zum foods.portion_name,
case-insensitiv gematcht. null, wenn der Name nicht im Katalog steht (oder
kein food-portion_name) → der Client fällt auf die Generik zurück. Der Server
pluralisiert nie frei (deutsche Plurale sind irregulär); der Client wählt nur
Singular vs. Plural per Anzahl (Anti-Drift) — das gilt sowohl für die gewählte
portion_count als auch für die aus grams/portion_g zurückgerechnete Menge.
Render-Beispiel (Client)
count == 1 -> "{count} {portion_name}"
count != 1, plural da -> "{count} {portion_name_plural}"
count != 1, kein plural-> Generik ("Portionen") bzw. portion_name as-is
„2 ½ Scheiben · ~50 g"
Portionsnamen-Katalog (SILV-219)
Kuratierte Singular→Mehrzahl-Tabelle portion_names (geseedet, UNIQUE auf
singular case-insensitiv). Quelle der Wahrheit für die Pluralisierung, fürs
Stammdaten-Auswahlfeld.
GET /api/portion-names→[{ "id": int, "singular": str, "plural": str }], alphabetisch (NOCASE). Seed u. a.: Scheibe/Scheiben, Brötchen/Brötchen, Glas/Gläser, Dose/Dosen, Stück/Stück, Becher/Becher, Tasse/Tassen, Riegel/Riegel, Löffel/Löffel, Flasche/Flaschen, Packung/Packungen, Banane/Bananen, Ei/Eier, Scoop/Scoops, Portion/Portionen …POST /api/portion-names{ singular, plural }→ legt an, gibt den Eintrag zurück. Idempotent aufsingular(case-insensitiv): existiert er schon, kommt der bestehende Eintrag unverändert zurück (kein Duplikat, kein Überschreiben).
Live-Pluralisierung in den Food-Endpoints (SILV-219-Addendum)
Damit der Stepper/Picker beim Hinzufügen/Bearbeiten live korrekt pluralisiert
(kein „2 Scheibe"), trägt auch jedes Food-Objekt zusätzlich
portion_name_plural: string? (= Katalog-Mehrzahl zu foods.portion_name,
null = nicht im Katalog): GET /api/foods, GET /api/foods/{id},
GET /api/foods/usual. Rein additiv. foods.portion_name selbst bleibt Singular;
der Stammdaten-PATCH setzt ihn wie bisher.
KI-Portionsschätzung (SILV-221)
POST /api/ai/portion/{food_id} (Host-Worker ai_jobs.py, gemini-3.1-flash-lite)
trennt jetzt Einheit und Anzahl statt einen zusammengesetzten Namen zu liefern
(früher portion_name="3 Scheiben" → Client zeigte „1 3 Scheiben"). Das
Result-note-JSON trägt:
{
"portion_g": 10, // Gramm pro EINER Einheit (nicht der ganzen Portion)
"portion_name": "Scheibe", // Einheit in EINZAHL, ohne Zahl davor
"portion_count": 3, // typische Anzahl (Vorbelegung Mengen-Stepper), float?
"portion_name_plural": "Scheiben", // Katalog-Mehrzahl, sonst null
"summary": "…" // optional
}
persist=True schreibt nur portion_g (pro Einheit) + portion_name (Einzahl) an
die Stammdaten — portion_count ist eine Log-Vorbelegung, kein Stammdatum.
portion_name_plural leitet der Worker aus dem Katalog ab (null = nicht im
Katalog). Prompt-Änderung am 13.06. per A/B-Dry-Run gegen den Alt-Prompt belegt +
Dennis-freigegeben (Rollout-Blocker-Fix), dann aktiviert.
Freitext-/Foto-Schätzung (SILV-224)
POST /api/ai/estimate (Freitext) und POST /api/ai/photo (Foto) liefern im
note-JSON dieselben Portionsfelder (portion_g pro Einheit, portion_name
Einzahl, portion_count, portion_name_plural). kcal + Makros bleiben GESAMT
für die geschätzte Menge (= portion_g × portion_count Gramm) — der Client leitet
die Rate (pro Gramm) ab und skaliert kcal und Makros linear, wenn der Nutzer
die Anzahl im Stepper ändert. Ohne natürliche Stück-Einheit: portion_count=1,
portion_name='Portion', portion_g=Gesamtgewicht. Diese Einträge sind
Freitext/Foto (kein Artikel) → die Portionsinfo wird beim Buchen auf der
diary-Zeile persistiert (s. o.), nicht in den Artikel-Stammdaten. Prompt per
A/B-Dry-Run belegt + Dennis-freigegeben, dann aktiviert.
Restore-Roundtrip
portion_count ist Teil des DELETE-deleted-Objekts und in _DIARY_COLS →
POST /api/diary/restore stellt die Menge 1:1 wieder her und gibt den Eintrag
(mit allen vier Feldern) als entry zurück. portion_g/portion_name/
portion_name_plural werden bei jeder Ausgabe frisch aus dem Artikel/Katalog
abgeleitet (kein Snapshot nötig).
KI-Herkunft-Badge source (SILV-223)
Damit Tagebuch/Heute einen KI-Sparkle-Chip an mit KI-Hilfe entstandene Einträge
hängen können, trägt jeder Diary-Eintrag additiv ein source-Feld
(string-Enum). Reine Anzeige — keine neue Logik, kcal/Makros unberührt.
Warum client-deklariert (und NICHT serverseitig abgeleitet): der Native-Client
nutzt die KI-Endpoints im Schätz-Modus (/api/ai/photo?book=false,
/api/ai/estimate, /api/ai/portion?persist=false) — sie buchen nicht selbst,
sondern liefern nur ein Estimate, das der Client bestätigen lässt und selbst über
POST /api/diary bucht. Am Insert-Endpoint ist die KI-Herkunft also nicht mehr
erkennbar; der Client (der weiß, ob er aus einem KI-Estimate kommt) deklariert sie.
Werte (server-autoritativ, Anti-Drift): source ∈ {manual, foto, freitext,
portion}. Unbekannt/leer/fehlt → normalisiert auf manual (ehrlich „kein KI",
auch für Altdaten: Migration diary.source TEXT NOT NULL DEFAULT 'manual').
| Endpoint | source (Eingang) |
|---|---|
POST /api/diary |
optional string (≤20), sonst manual |
POST /api/diary/quick |
optional string (≤20), sonst manual |
PATCH /api/diary/{id} |
akzeptiert kein source (Herkunft bleibt fix) |
Ausgang: über _hide_internal trägt jede Diary-Zeile source: string (nie
null — Fallback manual): GET /api/day (entries[]), POST /api/diary/quick
(entry), PATCH /api/diary/{id} (entry, unverändert), DELETE (deleted),
POST /api/diary/restore (entry). source ist in _DIARY_COLS → übersteht den
Löschen→Undo-Roundtrip 1:1. Mahlzeit-Bündel (/api/meals/{id}/log) tragen manual.
Contract-Tests: app/tests/contract/test_native_capture.py (test_source_*).
Bewusst NICHT betroffen
GET /api/diary/rangeliefert Tages-Summaries (kcal_eaten, entry_count, …), keine Einzel-Einträge → kein Anknüpfpunkt (no-op, mit MacClaude per Bus geklärt; das Archiv-Detail läuft über/api/day).- Mahlzeit-Bündel (
POST /api/meals/{id}/log) tragen keinportion_count(NULL);portion_g/portion_name/portion_name_pluralwerden wie überall aus dem Artikel abgeleitet. - kcal/Makros bleiben in jedem Pfad server-autoritativ aus
grams/servings.
Contract-Tests: app/tests/contract/test_tagebuch.py (test_portion_*).