Zuletzt aktualisiert:

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 vorhandenes portion_count wird angefasst. portion_count: 0 ist 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)

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.

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_COLSPOST /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

Contract-Tests: app/tests/contract/test_tagebuch.py (test_portion_*).