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.