Zuletzt aktualisiert:

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/diarykein 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."
}

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:

  1. Estimate-Modus (book=false) liefert im EstimateResult zusätzlich image_ref (= meal-<job>.jpg, das hochgeladene Foto).
  2. Beim Buchen reicht der Client image_ref an POST /api/diary durch (neben name/kcal/Makros). Server bindet das Foto an den Eintrag: skaliertes diary-<entryId>.jpg (max 1280 px, JPEG q80), die große Quelle wird gelöscht.
  3. Die Buchungs-Response und GET /api/day/{user}/{date} (sowie alle Diary-Reads) tragen pro Eintrag photo_url (/images/diary-<id>.jpg) bzw. null. Die rohe photo-Spalte verlässt den Server nie.
  4. Bild ist session-gated (/images/* liegt hinter der Auth-Middleware) — native App lädt es mit dem Session-Cookie bzw. X-Internal-Token.
  5. 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

Web-Referenz-Implementierung (gleicher Vertrag, falls hilfreich): app/frontend/src/components/FoodSheet.sveltesendPhoto() schickt book=false, estDone() füllt die editierbaren Felder, „Eintragen" bucht via /api/diary.