Files
impactflow_discovery/app/routers/coaching.py
T
Joel Salmon 50453901b3 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>
2026-06-16 21:10:54 -05:00

323 lines
10 KiB
Python

"""Phase 3 coaching routes: preferences (auto-generated + overridable) and the
weekly check-in engine.
All routes are user-scoped via the dual-auth dependency; the batch ``/run``
trigger is admin-only (a cron calls it weekly).
"""
import os
import uuid
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.auth import get_current_user, require_admin
from app.database import get_db
from app.models import (
CoachingCheckin,
CoachingPreferences,
DiscoveryProfile,
User,
)
from app.services.coaching import (
ALLOWED,
CheckinCoach,
CheckinError,
generate_preferences,
)
router = APIRouter(prefix="/discovery/coaching", tags=["coaching"])
# How long between check-ins for each cadence; "off" means never.
_FREQUENCY_DAYS = {"weekly": 7, "biweekly": 14, "monthly": 30, "off": None}
def _now() -> datetime:
return datetime.now(timezone.utc)
async def _latest_profile(
db: AsyncSession, user_id: str
) -> DiscoveryProfile | None:
stmt = (
select(DiscoveryProfile)
.where(DiscoveryProfile.user_id == user_id)
.order_by(DiscoveryProfile.generated_at.desc())
)
return (await db.execute(stmt)).scalars().first()
async def _get_preferences(
db: AsyncSession, user_id: str
) -> CoachingPreferences | None:
stmt = select(CoachingPreferences).where(
CoachingPreferences.user_id == user_id
)
return (await db.execute(stmt)).scalars().first()
def _profile_dict(profile: DiscoveryProfile) -> dict:
return {
"triad": profile.triad,
"love_summary": profile.love_summary,
"strength_summary": profile.strength_summary,
"mission_summary": profile.mission_summary,
"vocation_summary": profile.vocation_summary,
"overlap_narrative": profile.overlap_narrative,
"short_term_goals": profile.short_term_goals,
"long_term_goals": profile.long_term_goals,
}
def _prefs_out(p: CoachingPreferences) -> schemas.CoachingPreferencesOut:
return schemas.CoachingPreferencesOut(
coaching_frequency=p.coaching_frequency,
coaching_style=p.coaching_style,
misalignment_threshold=p.misalignment_threshold,
friction_tolerance=p.friction_tolerance,
prefer_questions_over_directives=p.prefer_questions_over_directives,
time_of_day_preference=p.time_of_day_preference,
auto_generated=p.auto_generated,
updated_at=p.updated_at,
)
def _checkin_out(c: CoachingCheckin) -> schemas.CheckinOut:
return schemas.CheckinOut(
id=c.id,
body=c.body,
created_at=c.created_at,
still_valid=c.still_valid,
response_note=c.response_note,
acknowledged_at=c.acknowledged_at,
)
async def _ensure_preferences(
db: AsyncSession, user: User
) -> CoachingPreferences:
"""Return the user's preferences, deriving defaults from their latest
profile on first access. 404 if they have no profile yet."""
prefs = await _get_preferences(db, user.id)
if prefs is not None:
return prefs
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(
status_code=404,
detail="No profile yet; complete self-discovery first.",
)
defaults = generate_preferences(_profile_dict(profile))
now = _now()
prefs = CoachingPreferences(
id=str(uuid.uuid4()),
user_id=user.id,
profile_id=profile.id,
auto_generated=True,
created_at=now,
updated_at=now,
**defaults,
)
db.add(prefs)
await db.commit()
await db.refresh(prefs)
return prefs
@router.get("/preferences", response_model=schemas.CoachingPreferencesOut)
async def get_preferences(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
return _prefs_out(await _ensure_preferences(db, user))
@router.put("/preferences", response_model=schemas.CoachingPreferencesOut)
async def update_preferences(
payload: schemas.CoachingPreferencesUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
prefs = await _ensure_preferences(db, user)
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
for field, value in updates.items():
if field in ALLOWED and value not in ALLOWED[field]:
raise HTTPException(
status_code=400,
detail=f"Invalid {field}: {value!r}. "
f"Allowed: {sorted(ALLOWED[field])}",
)
setattr(prefs, field, value)
prefs.auto_generated = False
prefs.updated_at = _now()
await db.commit()
await db.refresh(prefs)
return _prefs_out(prefs)
@router.post(
"/preferences/regenerate",
response_model=schemas.CoachingPreferencesOut,
)
async def regenerate_preferences(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Re-derive defaults from the latest profile, discarding overrides."""
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
prefs = await _ensure_preferences(db, user)
for field, value in generate_preferences(_profile_dict(profile)).items():
setattr(prefs, field, value)
prefs.profile_id = profile.id
prefs.auto_generated = True
prefs.updated_at = _now()
await db.commit()
await db.refresh(prefs)
return _prefs_out(prefs)
@router.get("/checkins", response_model=list[schemas.CheckinOut])
async def list_checkins(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
stmt = (
select(CoachingCheckin)
.where(CoachingCheckin.user_id == user.id)
.order_by(CoachingCheckin.created_at.desc())
)
rows = (await db.execute(stmt)).scalars().all()
return [_checkin_out(c) for c in rows]
async def _generate_checkin(
db: AsyncSession, user_id: str, profile: DiscoveryProfile,
prefs: CoachingPreferences,
) -> CoachingCheckin:
"""Generate and persist one check-in. Caller commits."""
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
coach = CheckinCoach(api_key=api_key, model=model)
body = await coach.generate(
_profile_dict(profile), _prefs_out(prefs).model_dump()
)
checkin = CoachingCheckin(
id=str(uuid.uuid4()),
user_id=user_id,
profile_id=profile.id,
body=body,
created_at=_now(),
)
db.add(checkin)
return checkin
@router.post("/checkins", response_model=schemas.CheckinOut)
async def create_checkin(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Generate a check-in now for the current user (on demand)."""
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
prefs = await _ensure_preferences(db, user)
try:
checkin = await _generate_checkin(db, user.id, profile, prefs)
except CheckinError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
await db.commit()
await db.refresh(checkin)
return _checkin_out(checkin)
@router.put(
"/checkins/{checkin_id}/respond", response_model=schemas.CheckinOut
)
async def respond_to_checkin(
checkin_id: str,
payload: schemas.CheckinRespondRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Record the person's self-assessment: is their direction still valid?"""
checkin = await db.get(CoachingCheckin, checkin_id)
if checkin is None or (
checkin.user_id != user.id and user.role != "admin"
):
raise HTTPException(status_code=404, detail="Check-in not found")
checkin.still_valid = payload.still_valid
checkin.response_note = payload.note or None
checkin.acknowledged_at = _now()
await db.commit()
await db.refresh(checkin)
return _checkin_out(checkin)
async def _last_checkin_at(
db: AsyncSession, user_id: str
) -> datetime | None:
stmt = (
select(CoachingCheckin.created_at)
.where(CoachingCheckin.user_id == user_id)
.order_by(CoachingCheckin.created_at.desc())
)
return (await db.execute(stmt)).scalars().first()
def _is_due(frequency: str, last_at: datetime | None, now: datetime) -> bool:
days = _FREQUENCY_DAYS.get(frequency)
if days is None: # "off" or unknown cadence
return False
if last_at is None:
return True
if last_at.tzinfo is None:
last_at = last_at.replace(tzinfo=timezone.utc)
return now - last_at >= timedelta(days=days)
@router.post("/run", response_model=schemas.RunCheckinsResponse)
async def run_due_checkins(
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
):
"""The weekly batch job: generate a check-in for every eligible user whose
cadence is due. Eligible = has coaching preferences (cadence != off) and an
affirmed (locked) profile. Intended to be invoked by a weekly cron."""
now = _now()
prefs_rows = (
await db.execute(select(CoachingPreferences))
).scalars().all()
considered = 0
generated = 0
for prefs in prefs_rows:
if _FREQUENCY_DAYS.get(prefs.coaching_frequency) is None:
continue
profile = await _latest_profile(db, prefs.user_id)
if profile is None or not profile.locked:
continue
considered += 1
last_at = await _last_checkin_at(db, prefs.user_id)
if not _is_due(prefs.coaching_frequency, last_at, now):
continue
try:
await _generate_checkin(db, prefs.user_id, profile, prefs)
generated += 1
except CheckinError:
# Skip this user this run; a transient model error shouldn't abort
# the whole batch.
continue
await db.commit()
return schemas.RunCheckinsResponse(
considered=considered, generated=generated
)