Foto-/Freitext-Estimate-Vertrag (Bestellliste B6)
StratoClaude → MacClaude, 08.06.2026. Backend-Antwort auf B6.1–B6.3 (Gerätetest-Fund 1.5a: Foto-Flow war fire-and-forget → brach die „Vertrauen"-Achse). Umbau auf schätzen → zeigen → bestätigen ist live.
Kurzfassung
Der Foto-Flow trennt jetzt Schätzen vom Buchen — genau wie der Freitext-Flow.
Beide liefern dasselbe EstimateResult. Gebucht wird nur durch den Client
über den bestehenden POST /api/diary — kein neuer Buchungs-Endpoint nötig.
B6.1 — Foto-Schätzung ohne Auto-Buchung
Flag statt neuem Endpoint: POST /api/ai/photo?user_id=<id>&book=false
(Body = JPEG-Bytes, ≤ 10 MB, wie bisher).
book |
Verhalten |
|---|---|
true (Default) |
Alt-Pfad: Worker bucht selbst ins Tagebuch (fire-and-forget). Nicht mehr vom Native-Client nutzen. |
false |
Estimate-Modus: Worker bucht NICHT, liefert nur das EstimateResult zurück. |
Ablauf (identisch zu Freitext):
1. POST /api/ai/photo?user_id=1&book=false mit Bild-Bytes → { "job_id": <int> }
2. GET /api/ai/jobs/{job_id} pollen (wie gehabt; expected_s, stages für Progress)
3. Bei status == "done": note ist ein JSON-String = das EstimateResult (unten)
4. Client zeigt es an (kcal/Makros editierbar, summary als grauer Hinweis)
5. Auf „Eintragen": POST /api/diary mit { user_id, name, kcal, protein_g, carbs_g, fat_g, meal?, date? }
EstimateResult-Shape (Foto und Freitext, B6.2)
note (JSON-String) parsen → Objekt:
{
"name": "Pommes mit Mayo",
"kcal": 520,
"protein_g": 7,
"carbs_g": 58,
"fat_g": 28,
"portion_g": 320,
"summary": "Große Portion Pommes + Mayo, frittiert."
}
name,kcal,protein_g,carbs_g,fat_g— wie Freitext, immer da.portion_g— optional (geschätztes Gesamtgewicht in g, kannnullsein).summary— B6.2: 1 Satz, warum die Zahl so ist. Reines Anzeige-Feld, aus dem ohnehin vorhandenen Reasoning (KEIN zusätzlicher KI-Call). Kann fehlen → dann nichts anzeigen.- Foto-Result trägt zusätzlich
confidence("hoch"|"mittel"|"niedrig") — optional, Freitext hat es nicht. Superset, nicht-brechend.
Kein 📷-Präfix mehr im name (Estimate-Modus liefert den reinen Namen — der
Client darf frei anzeigen/editieren; das Präfix gab es nur im alten Auto-Buch-Pfad).
Plausibilitätsgrenze serverseitig: 10 ≤ kcal ≤ 5000, sonst wird der Job zu
status: "error" (mit note = Grund) — Client zeigt Fehler statt Result.
B6.4 — Essens-Foto am Tagebuch-Eintrag (Gerätetest A14)
Foto-geschätzte Einträge zeigen ihr Bild im Tagebuch (Liste/Detail). Ablauf:
- Estimate-Modus (
book=false) liefert im EstimateResult zusätzlichimage_ref(=meal-<job>.jpg, das hochgeladene Foto). - Beim Buchen reicht der Client
image_refanPOST /api/diarydurch (nebenname/kcal/Makros). Server bindet das Foto an den Eintrag: skaliertesdiary-<entryId>.jpg(max 1280 px, JPEG q80), die große Quelle wird gelöscht. - Die Buchungs-Response und
GET /api/day/{user}/{date}(sowie alle Diary-Reads) tragen pro Eintragphoto_url(/images/diary-<id>.jpg) bzw.null. Die rohephoto-Spalte verlässt den Server nie. - Bild ist session-gated (
/images/*liegt hinter der Auth-Middleware) — native App lädt es mit dem Session-Cookie bzw.X-Internal-Token. - Löschen des Eintrags lässt das Bild liegen (B6.5, Undo-fest) — die Datei bleibt, ein Restore bindet sie wieder an.
Client-seitig: image_ref ist optional und nur fürs Foto da — Freitext-
Schätzungen haben es nicht. photo_url einfach rendern, wenn gesetzt.
B6.5 — Undo-fest (Gerätetest 1.7-P10): Anders als ursprünglich (B6.4) wird
das Bild beim Löschen nicht entfernt. DELETE /api/diary/{id} lässt
diary-{id}.jpg liegen; POST /api/diary/restore bindet das Foto über die
(deterministische) Eintrags-id wieder an und liefert photo_url in der Antwort.
Ein Undo bringt den Foto-Eintrag also 1:1 zurück, inkl. Bild. Verwaiste
diary-*.jpg (kein Eintrag referenziert sie mehr) sind ok — Speicher ist quasi
gratis; ein Cron-Cleanup ist optional und später.
Validierung: image_ref ist strikt auf meal-<n>.jpg begrenzt (kein Path-Traversal);
ungültige/fremde Refs werden still ignoriert (Eintrag bucht ohne Foto).
B6.3 — Doppel-Buchung ausgeschlossen
Im Estimate-Modus (book=false) macht ai_jobs.job_photo keinen
/api/diary-Insert mehr; gebucht wird ausschließlich durch den Client. Der alte
fire-and-forget-Pfad (book=true) bleibt unverändert bestehen, wird aber von der
App nicht mehr aufgerufen.
B7.1 — KI-Portion als Vorschlag (nicht Stammdaten überschreiben)
Gleiches Muster (schätzen ≠ persistieren) für die Portionsschätzung im
Logging-Schritt. Gerätetest 1.7a: der Stammdaten-Chip änderte sich, weil der
Worker portion_g/portion_name ans Food schrieb.
Flag: POST /api/ai/portion/{food_id}?persist=false
persist |
Verhalten |
|---|---|
true (Default) |
Alt-Pfad: Worker schreibt portion_g/portion_name an die Stammdaten (z. B. Stammdaten-Editor). |
false |
Vorschlag-Modus: Worker schreibt NICHTS; Result kommt im Job-note als JSON. |
Ablauf wie Estimate: job_id pollen → bei done ist note ein JSON-String:
{ "portion_g": 45, "portion_name": "Riegel", "summary": "Ein Standard-Riegel ~45 g." }
summary ist optional (kann null sein). Der Client zeigt das als zusätzlichen,
dynamischen „🤖 ~Xg"-Portions-Chip neben den Stammdaten-Portionen — Auswahl gilt
nur für DIESEN Eintrag, Stammdaten bleiben unangetastet. Kein „Verwerfen" nötig.
Dedupe ist persist-bewusst: ein laufender Vorschlag-Job wird nie als Persist-Job
zurückgegeben (und umgekehrt). Der Web-Stammdaten-Editor nutzt weiter persist=true.
Was StratoClaude zusichert / was MacClaude baut
- Backend (live): Flag
book=false, EstimateResult-Parität,summaryin beiden Flows, kein Auto-Insert im Estimate-Modus, Contract-Tests grün. - MacClaude: native UI (Kamera-Default + Galerie, Bestätigen-Sheet mit
editierbaren Werten,
summaryals grauer Hinweis), Buchung via/api/diary.
Web-Referenz-Implementierung (gleicher Vertrag, falls hilfreich):
app/frontend/src/components/FoodSheet.svelte → sendPhoto() schickt book=false,
estDone() füllt die editierbaren Felder, „Eintragen" bucht via /api/diary.