Files
impactflow_discovery/app/routers/coaching.py
T
Joel Salmon c4fc1cccd7 Phase 4: task-to-goal integration (Vision side)
Build the boundary the ImpactFlow core time-tracker plugs into. A task maps to
a foundation — one of the six stable profile elements (love, strength, mission,
vocation, short_term, long_term) — so the tracker can ask "which goal does this
build toward?" and post the answer back to Vision.

- Models + migration 006: task_mapping (one row per logged time entry).
- app/services/foundations.py: the six foundations + a pure, testable
  work-pattern aggregator (rollup) and a plain-language summary.
- app/routers/integration.py (user-scoped; tracker calls as the user or via
  X-API-Key): GET /foundations, POST/GET /task-mappings,
  GET /work-patterns?days=N (per-foundation minutes/share/neglected).
- Reminder engine now pulls from real work patterns: CheckinCoach takes an
  optional work-pattern summary (last 14 days) and reflects where time has gone
  against the person's own words — an observation, never a verdict.
- Frontend: dashboard.html (time per foundation + neglected); linked from
  profile.html.

Documented the core-tracker integration contract in the README. Phase 4
completes the Vision module's roadmap on the Discovery side; the core tracker
integrates by calling these endpoints.

Tests: 86 passing (added pure-aggregator, integration-endpoint, and
work-pattern-into-check-in tests; run in-container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:24:58 -05:00

333 lines
11 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,
)
from app.services.foundations import work_pattern_text
from app.routers.integration import work_patterns_for
# Look-back window (days) for the work-pattern signal fed into a check-in.
_WORK_PATTERN_DAYS = 14
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)
# Phase 4: feed recent work patterns into the check-in so it can reflect
# where time has actually gone against the person's stated direction.
summary = await work_patterns_for(db, user_id, _WORK_PATTERN_DAYS)
body = await coach.generate(
_profile_dict(profile),
_prefs_out(prefs).model_dump(),
work_patterns=work_pattern_text(summary, _WORK_PATTERN_DAYS),
)
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
)