Phase 3: coaching preferences + weekly check-in engine

Add coaching preferences (auto-derived from the profile, user-overridable) and
a periodic check-in engine that quotes the person's own words and asks whether
their direction still feels valid — mirror, not compass.

- Preferences are deterministic: a documented triad mapping (gut → direct/
  higher-friction, heart → warm/drift-sensitive, head → reflective/question-led)
  produces defaults for the six fields (coaching_frequency, coaching_style,
  misalignment_threshold, friction_tolerance, prefer_questions_over_directives,
  time_of_day_preference). PUT overrides; regenerate re-derives.
- CheckinCoach (app/services/coaching.py): Anthropic-backed; writes a check-in
  that quotes the person's goals back and asks if the direction still holds.
- Endpoints (app/routers/coaching.py): GET/PUT/regenerate preferences;
  GET/POST checkins; respond (records still_valid); admin POST /run is the
  weekly batch (due = cadence elapsed + locked profile), intended for a cron.
- Models + migration 005: coaching_preferences (per user) and coaching_checkin.
- Frontend: coaching.html (preferences form + check-in feed); linked from
  profile.html.

Tests: 68 passing (added deterministic-preference unit tests and coaching
endpoint/batch tests; run in-container). README updated for Phase 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Salmon
2026-06-16 21:10:54 -05:00
parent b4d8d17aed
commit 50453901b3
13 changed files with 1317 additions and 1 deletions
+196
View File
@@ -0,0 +1,196 @@
"""Tests for the Phase 3 coaching endpoints.
The Anthropic-backed CheckinCoach is replaced with a fake (monkeypatch on the
router) so these run offline. Auth uses the X-API-Key admin identity, which is
also what lets the admin-only batch /run be exercised.
"""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
API_KEY = {"X-API-Key": "test-api-key"}
async def _seed_profile(locked: bool = False, triad: str = "gut") -> str:
from app.auth import API_KEY_ADMIN_ID, ensure_api_key_admin
from app.database import AsyncSessionLocal
from app.models import DiscoveryConversation, DiscoveryProfile
async with AsyncSessionLocal() as db:
await ensure_api_key_admin(db)
conv = DiscoveryConversation(
id=str(uuid.uuid4()),
user_id=API_KEY_ADMIN_ID,
started_at=datetime.now(timezone.utc),
)
db.add(conv)
await db.commit()
profile = DiscoveryProfile(
id=str(uuid.uuid4()),
user_id=API_KEY_ADMIN_ID,
conversation_id=conv.id,
generated_at=datetime.now(timezone.utc),
triad=triad,
short_term_goals="run a pilot welding cohort",
long_term_goals="a statewide trades outfit",
overlap_narrative="you come alive protecting others",
locked=locked,
)
db.add(profile)
await db.commit()
return profile.id
class FakeCheckinCoach:
body = 'You said you want to "run a pilot welding cohort." Does that still feel true?'
def __init__(self, api_key=None, model=None):
pass
async def generate(self, profile, prefs):
return FakeCheckinCoach.body
@pytest.fixture(autouse=True)
async def fake_checkin(monkeypatch, app_client):
monkeypatch.setattr("app.routers.coaching.CheckinCoach", FakeCheckinCoach)
yield
async def test_get_preferences_autoderives_from_profile(app_client):
await _seed_profile(triad="gut")
r = await app_client.get(
"/discovery/coaching/preferences", headers=API_KEY
)
assert r.status_code == 200
p = r.json()
assert p["coaching_style"] == "direct" # gut default
assert p["auto_generated"] is True
async def test_get_preferences_without_profile_404(app_client):
r = await app_client.get(
"/discovery/coaching/preferences", headers=API_KEY
)
assert r.status_code == 404
async def test_put_preferences_overrides_and_clears_auto(app_client):
await _seed_profile(triad="gut")
await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
r = await app_client.put(
"/discovery/coaching/preferences",
headers=API_KEY,
json={"coaching_style": "warm", "coaching_frequency": "monthly"},
)
assert r.status_code == 200
p = r.json()
assert p["coaching_style"] == "warm"
assert p["coaching_frequency"] == "monthly"
assert p["auto_generated"] is False
async def test_put_invalid_value_rejected(app_client):
await _seed_profile()
await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
r = await app_client.put(
"/discovery/coaching/preferences",
headers=API_KEY,
json={"coaching_frequency": "hourly"},
)
assert r.status_code == 400
async def test_regenerate_restores_auto_defaults(app_client):
await _seed_profile(triad="gut")
await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
await app_client.put(
"/discovery/coaching/preferences",
headers=API_KEY,
json={"coaching_style": "warm"},
)
r = await app_client.post(
"/discovery/coaching/preferences/regenerate", headers=API_KEY
)
assert r.status_code == 200
p = r.json()
assert p["coaching_style"] == "direct" # back to gut default
assert p["auto_generated"] is True
async def test_create_and_respond_to_checkin(app_client):
await _seed_profile()
gen = await app_client.post(
"/discovery/coaching/checkins", headers=API_KEY
)
assert gen.status_code == 200
checkin = gen.json()
assert "run a pilot" in checkin["body"]
assert checkin["still_valid"] is None
listed = (await app_client.get(
"/discovery/coaching/checkins", headers=API_KEY
)).json()
assert len(listed) == 1
resp = await app_client.put(
f"/discovery/coaching/checkins/{checkin['id']}/respond",
headers=API_KEY,
json={"still_valid": False, "note": "my focus shifted"},
)
assert resp.status_code == 200
body = resp.json()
assert body["still_valid"] is False
assert body["response_note"] == "my focus shifted"
assert body["acknowledged_at"] is not None
async def test_run_batch_generates_for_due_locked_users(app_client):
await _seed_profile(locked=True, triad="gut")
# Auto-create preferences (weekly cadence for gut).
await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
# No prior check-in -> due -> one generated.
r1 = (await app_client.post("/discovery/coaching/run", headers=API_KEY)).json()
assert r1["considered"] == 1
assert r1["generated"] == 1
# Immediately again -> a recent check-in exists -> not due.
r2 = (await app_client.post("/discovery/coaching/run", headers=API_KEY)).json()
assert r2["generated"] == 0
async def test_run_batch_skips_off_cadence(app_client):
await _seed_profile(locked=True)
await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
await app_client.put(
"/discovery/coaching/preferences",
headers=API_KEY,
json={"coaching_frequency": "off"},
)
r = (await app_client.post("/discovery/coaching/run", headers=API_KEY)).json()
assert r["considered"] == 0
assert r["generated"] == 0
async def test_run_batch_skips_unlocked_profile(app_client):
await _seed_profile(locked=False) # not affirmed
await app_client.get("/discovery/coaching/preferences", headers=API_KEY)
r = (await app_client.post("/discovery/coaching/run", headers=API_KEY)).json()
assert r["considered"] == 0
async def test_due_logic_unit():
from app.routers.coaching import _is_due
now = datetime(2026, 6, 16, tzinfo=timezone.utc)
assert _is_due("weekly", None, now) is True
assert _is_due("weekly", now - timedelta(days=8), now) is True
assert _is_due("weekly", now - timedelta(days=3), now) is False
assert _is_due("off", None, now) is False
async def test_coaching_requires_auth(app_client):
r = await app_client.get("/discovery/coaching/preferences")
assert r.status_code == 401
+36
View File
@@ -0,0 +1,36 @@
"""Unit tests for the deterministic coaching-preference generator."""
from app.services.coaching import ALLOWED, generate_preferences
def test_gut_defaults_are_direct_and_high_friction():
p = generate_preferences({"triad": "gut"})
assert p["coaching_style"] == "direct"
assert p["friction_tolerance"] == "high"
assert p["prefer_questions_over_directives"] is False
def test_head_defaults_are_reflective_and_questions():
p = generate_preferences({"triad": "head"})
assert p["coaching_style"] == "reflective"
assert p["coaching_frequency"] == "biweekly"
assert p["prefer_questions_over_directives"] is True
def test_heart_defaults_are_warm_low_threshold():
p = generate_preferences({"triad": "heart"})
assert p["coaching_style"] == "warm"
assert p["misalignment_threshold"] == "low"
def test_unknown_triad_uses_gentle_fallback():
p = generate_preferences({"triad": None})
assert p["coaching_style"] == "warm"
assert p["prefer_questions_over_directives"] is True
def test_all_generated_values_are_within_allowed_sets():
for triad in ("gut", "heart", "head", None, "weird"):
p = generate_preferences({"triad": triad})
for field, allowed in ALLOWED.items():
assert p[field] in allowed, (triad, field, p[field])
assert isinstance(p["prefer_questions_over_directives"], bool)
+14
View File
@@ -48,6 +48,20 @@ def test_profile_page_links_to_reflection():
assert "/static/reflect.html" in html
def test_coaching_page_uses_coaching_endpoints():
"""Phase 3 coaching page drives preferences + check-ins via authedFetch."""
html = Path("app/static/coaching.html").read_text(encoding="utf-8")
assert "/discovery/coaching/preferences" in html
assert "/discovery/coaching/checkins" in html
assert "authedFetch" in html
assert "user_id" not in html
def test_profile_page_links_to_coaching():
html = Path("app/static/profile.html").read_text(encoding="utf-8")
assert "/static/coaching.html" in html
def test_auth_helper_sends_credentials_and_refreshes():
js = Path("app/static/auth.js").read_text(encoding="utf-8")