"""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 )