mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 07:10:37 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user