mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 08:40:37 +00:00
c4fc1cccd7
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>
333 lines
11 KiB
Python
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
|
|
)
|