Zuletzt aktualisiert:

Coach in der App — Backend-Vertrag (SILV-447, Stand 04.09.2026)

Quelle: Coach-Spec running-coaching/docs/silverscale-spec-coach-integration.md @ cb106df (§3, §4.2, §4.3, §6.2 N1–N6). Code: app/backend/coach_api.py (Routen + Kontextblöcke + Apply), app/backend/coach_app.py (Default-Katalog, Validierung), Tabellen coach_catalog, coach_request, coach_log (db.py). Tests: app/tests/contract/test_coach_app.py. Alle Pfade unter /api. Auth: App-Session wie überall; PUT /coach/buttons, POST /coach/internal/* nur mit X-Internal-Token (Daemon auf dem Host).

Abweichungen von der Spec sind mit ⚠ markiert.

1. Katalog — GET /coach/buttons, PUT /coach/buttons (intern)

{version, buttons[]} mit je {key, title, group, placements[], inputs[{name,type,options,required}], attachments[], photo_mode, writes, effort, answer_max_lines, context[], wellness_live}. context/wellness_live sind additiv (aus dem Skill-Frontmatter; context = dynamische Blöcke, die das Backend baut). Bis der Daemon den Katalog schreibt, gilt der Default aus Spec §2/§12 (coach_app.DEFAULT_BUTTONS, 18 Knöpfe, Version 2026-09-04.0-default). PUT validiert (group, placements, inputs[].type, writes, photo_mode, effort) → 400 mit Fehlerliste.

Eingaben je Knopf (Default): pre_run minutes_to_start (minutes, Pflicht) · hunger level (segment leicht|ziemlich|gross, Pflicht) · eating_out kind (text), at (time) · alcohol amount (text, Pflicht) · snack kcal_max (kcal), protein_min (protein_g) · rate_day date · week_review week (date) · shopping_hint days (text) · sonst ohne. ate_something braucht freetext ODER items ODER photos (400 sonst).

2. GET /coach/connection?user_id= (§3.6)

{available, write_grant, buttons_version, harness: ok|cold|down, usage_pct, model}. harness aus dem Daemon-Heartbeat (POST /coach/internal/heartbeat {pool_ready, usage_pct, model, version}, alle ~30 s): kein Heartbeat seit 90 s → down (available=false), Pool leer → cold. Im Testmode immer ok. ⚠ Der Pfad existierte schon für die MCP-Verbindung (Web-UI, ohne user_id); mit user_id antwortet er im App-Coach-Vertrag.

3. POST /coach/ask (§3.2) → {request_id, stream: "coach", harness}

Body wie Spec: {user_id, button, date?, time?, inputs{}, freetext?, items[{food_id,grams}| {dish_id,servings}], photos[{image_ref, mode, estimate?}], client?}. Prüfung: Nutzer 404, Knopf 400, Pflichteingaben/Segment-Optionen 400, date YYYY-MM-DD, time HH:MM (fehlt es heute → Server-Uhr). Dann: Kontext bauen (§4), coach_request schreiben (Status queued), Push an den Daemon (COACH_DAEMON_URL/ask, Timeout 3 s, Body {request_id, user_id, button, effort, model, answer_max_lines, writes, photo_mode, context, callback}) → harness: ok. Scheitert der Push: ai_jobs-Job coach_ask {request_id} (Fallback/Protokoll, nie der Normalweg), meta-Event {harness: cold, fallback: ai_jobs} im Stream, harness: cold in der Antwort. photos.mode=estimate: die App liefert das /ai/photo-Ergebnis als estimate mit (landet im Block „Eingabe"); raw reicht nur image_ref durch (Daemon, SILV-448).

4. Kontextblöcke (§4.2) — kompakt, direkt aus der DB

context je Knopf wählt: tag (heute), tag_morgen, plan_woche, sessions (7 Tage), woche (7 Tage Aufnahme/Budget/Delta/Protein), wellness (7 Tage HRV/RHR/Schlaf/Gewicht; SILV-450 N8: bei Knöpfen mit wellness_live prüft das Backend die Frische des Tagessatzes (HRV, Schlaf, RHR, Gewicht für date) und zieht bei Lücken live Intervals /wellness (Timeout 3 s), dann weiter mit dem gespeicherten Stand; live_pull ∈ {fresh, pulled, failed, skipped}, warning nennt die fehlenden Felder „HRV/Schlaf für heute noch nicht da (Garmin syncen?)"), waage (14 Tage; weight_context zieht immer live, gleicher Vertrag live_pull/warning), letzte_aktivitaet, kurve (KPI). Immer: eingabe (inputs, time, freetext, items mit Makros aus den Stammdaten, photos).

?compact=1 (N1) liefert dieselben Blöcke auch per REST: - GET /day/{user}/{date}?compact=1[&now=HH:MM&today=] → {date, now, budget_live, budget_plan, eaten, protein_target, eaten_macros{p,c,f}, activity_kcal, entries[{t,n,kcal,p,c,f,food_id?,dish_id?,g?}], session{id,type,name,slot,expected_kcal,status, user_edited}, planned[{id,t,label,kcal,p,c,f,origin,status,kind?,dish_id?,servings?,user_edited?, note?,session_changed_at?}], planned_kcal, kpi{low_kcal,low_at,hours_below_400,end_delta}, supplements[{time,name,amount}] (N7: Artikel mitfoods.is_supplement, nicht in eaten/Makros/entries), lint[{rule,text,plan_id?}] (N12: Tages- + Zeilen-Hinweise flach)}. Keine Nullfelder, keine Bilder, Makros gerundet. - GET /plan/week?user_id=&from=&compact=1 → {monday, today, days[{date, budget_plan, eaten, planned, protein_eaten, protein_planned, protein_target, session, two_day{dates,projected,budget, delta}, session_changed_at, entries[]}], hf_dishes[{dish_id,name,kcal,assigned}]}. Im Knopf-Kontext tragen nur heute/morgen entries (Planungs-Knöpfe: alle Tage). - Zwei Budgets (§4.3): budget_live = /day (Session erst ab Slot-Ende), budget_plan = Wochen-DTO (Session immer drin).

5. Stream — GET /coach/stream/{request_id} (SSE, §3.3)

data: {json} je Ereignis: meta {harness, usage_pct, model, …}, coach_token {text}, coach_done {response}, coach_error {code: quota|refusal|timeout|upstream, text}. Der Stream spielt gepufferte Ereignisse nach (Reconnect = Replay) und schließt nach coach_done/coach_error; 150 s ohne Ereignis → coach_error timeout. Nach Prozess-Neustart liefert er das persistierte Ergebnis als einzelnes coach_done/coach_error. Der Live-Kanal /events/stream bekommt nur {type: coach_result_ready, user_id, date, request_id} (nutzdatenfrei, Lock SILV-295).

Daemon-Rückweg: POST /coach/internal/{request_id}/events {events[]} (intern) — beliebig oft; coach_done prüft plan_changes strukturell (id eindeutig, op, plan_id bei update/delete, note außer bei delete) und wird bei Verstoß zu coach_error upstream. Doppelter Abschluss wird ignoriert.

6. GET /coach/result/{request_id}[?user_id=]

{request_id, user_id, button, date, time, status: queued|running|done|error, harness, response, error, applied{applied[],rejected[]}, created_at, done_at}; fremdes Profil 403.

7. POST /coach/apply (§3.5) → {applied[{id, plan_id}], rejected[{id, code, text}]}

{user_id, request_id, accept[]}. Kein Write-Grant (mcp_write_grant, wie MCP) → 403; fremdes Profil 403; Anfrage ohne Ergebnis 409; unbekannte ids 400; group-Partner fehlen → 422 {detail, missing[]}. Je Zeile die Coach-Guards (dieselbe Wahrheit wie mcp_server._own_writable_row): nicht gefunden 404 · fremd 403 · Vergangenheit 409 · gegessen/ersetzt 409 · user_edited 409 · Gericht-Zeile mit label/kcal/Makros 400 · HF-Gericht schon in der Woche 409 (create + date-Umzug). Alles in rejected, die gültigen Zeilen werden geschrieben (Plan-API POST/PATCH/DELETE /plan, Coach-Update setzt origin=coach, user_edited=0, session_changed_at=NULL, note ≤ 80). ⚠ Gericht-Zeile mit time: bis N11 (SILV-450) wird die Uhrzeit auf den Slot (meal) abgebildet. Idempotent: schon übernommene ids kommen erneut als applied zurück. Audit: plan_audit (actor coach:app) + coach_log (ask/result/apply mit facts, Modell, angenommen/abgelehnt). Live: plan_changed je berührtem Tag.

8. GET /food/history (N2)

?user_id&days=56&slot=&min_protein_g=&kcal_min=&kcal_max=&q=&limit=40 → {user_id, days, since, logged_days, items[{name, dish_id?, food_id?, count, last, kcal, protein_g, carbs_g, fat_g, grams?, slots[]}]} — gruppiert nach Name (case-insensitiv), Ø-Makros, wie MCP get_food_history.

9. Testmode (N6)

Seeds app/backend/coach_seed/{hunger,rate_day,plan_tomorrow}.json (= docs/coach-seed/ @ cb106df). POST /coach/ask mit einem dieser Knöpfe streamt die Seed-Antwort (meta → Token-Häppchen → done) ohne Daemon; andere Knöpfe landen im Fallback (harness: cold), das Ergebnis setzt der Test über POST /coach/internal/{id}/events. Seed-Plan-IDs zeigen auf nichts — apply prüft die Guards.

10. Offen / Folgetickets

SILV-448 Daemon — geliefert (Runbook coach-daemon-runbook.md, Benchmark coach-benchmark-fable-effort.md). SILV-450 — geliefert (N7 Supplemente, N8 Wellness-Frische + REST GET /api/sleep, GET /api/hrv, GET /api/wellness/status, POST /api/wellness/pull; N11 time für Gericht/Artikel — POST /api/plan + PATCH /api/plan/{id} nehmen time für jede Zeile, Slot folgt, DTO trägt time, Kurve/Zwei-Tage-Spange rechnen damit; N12 Plan-Linter — sieben Regeln lint[{rule,text,severity=hint}] je Tag und je Eintrag im /api/plan/week-DTO, nur heute + Zukunft, read-time; Regeln: letzte_feste_mahlzeit_vor_q, fett_vor_einheit, fett_boden (heute ab 18 Uhr), protein_verteilung, protein_luecke (heute ab 21 Uhr), vorabend_long_run, doppelte_abendzeilen). SILV-451 — geliefert (N15 Stufe 2: PUT /api/coach/catalog {version, items[{name, portion, kcal, protein_g, carbs_g, fat_g, food_id?, dish_id?, kind: baustein|standard}]} (intern, vom Daemon aus knowledge/mahlzeiten.md, 27 Einträge) + GET /api/coach/catalog; Linter-Regel baustein_unbekannt je Zeile: Coach-/freie Vorlagen-Zeile ohne food_id/dish_id, deren Label keinen Katalog-Namen nennt — nur mit gesetztem Katalog. N14: plan_changes[].create darf planned_session_id tragen. N13/P3 im energie-vertrag §5). Coach-Apply: Gericht-Zeilen nehmen time jetzt direkt (Slot abgeleitet) — die frühere Abweichung „time→Slot bis N11" ist damit aufgehoben.