"""Phase 4 integration routes: the boundary the ImpactFlow core time-tracker plugs into. When a user logs time in the core tracker, the tracker asks "which goal does this build toward?" — fetching the options from ``GET /foundations`` — and posts the answer to ``POST /task-mappings``. ``GET /work-patterns`` rolls those up to feed the coaching reminder engine and the goal dashboard. All routes are user-scoped via the dual-auth dependency; the tracker calls as the user (forwarded session/JWT) or, service-to-service, with ``X-API-Key``. """ import os import uuid from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app import schemas from app.auth import get_current_user from app.database import get_db from app.models import DiscoveryProfile, TaskMapping, User from app.services.foundations import ( FOUNDATION_TO_FIELD, FOUNDATIONS, rollup, ) from app.services.tagging import FoundationTagger, TaggingError router = APIRouter(prefix="/discovery/integration", tags=["integration"]) 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() @router.get("/foundations", response_model=list[schemas.FoundationOut]) async def list_foundations( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """The mappable foundations for the user's latest profile, with the person's own text — what the tracker shows as "which goal does this build toward?".""" profile = await _latest_profile(db, user.id) if profile is None: raise HTTPException(status_code=404, detail="No profile for this user") return [ schemas.FoundationOut( key=key, label=label, text=getattr(profile, FOUNDATION_TO_FIELD[key], None), ) for key, label in FOUNDATIONS.items() ] @router.post("/task-mappings", response_model=schemas.TaskMappingOut) async def create_task_mapping( payload: schemas.TaskMappingCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Record one logged unit of work, mapped to the foundation it builds toward. Called by the core tracker when time is logged.""" if payload.foundation not in FOUNDATIONS: raise HTTPException( status_code=400, detail=f"Invalid foundation: {payload.foundation!r}. " f"Allowed: {sorted(FOUNDATIONS)}", ) if payload.minutes < 0: raise HTTPException(status_code=400, detail="minutes must be >= 0") mapping = TaskMapping( id=str(uuid.uuid4()), user_id=user.id, external_task_id=payload.external_task_id, task_label=payload.task_label or None, foundation=payload.foundation, minutes=payload.minutes, occurred_at=payload.occurred_at or _now(), created_at=_now(), ) db.add(mapping) await db.commit() await db.refresh(mapping) return schemas.TaskMappingOut.model_validate(mapping, from_attributes=True) @router.get("/task-mappings", response_model=list[schemas.TaskMappingOut]) async def list_task_mappings( days: int = Query(30, ge=1, le=365), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): cutoff = _now() - timedelta(days=days) stmt = ( select(TaskMapping) .where(TaskMapping.user_id == user.id) .where(TaskMapping.occurred_at >= cutoff) .order_by(TaskMapping.occurred_at.desc()) ) rows = (await db.execute(stmt)).scalars().all() return [ schemas.TaskMappingOut.model_validate(r, from_attributes=True) for r in rows ] async def work_patterns_for( db: AsyncSession, user_id: str, days: int ) -> dict: """Roll up the user's task mappings over the window. Shared with the coaching check-in engine.""" cutoff = _now() - timedelta(days=days) stmt = ( select(TaskMapping) .where(TaskMapping.user_id == user_id) .where(TaskMapping.occurred_at >= cutoff) ) rows = (await db.execute(stmt)).scalars().all() entries = [ { "foundation": r.foundation, "minutes": r.minutes, "occurred_at": r.occurred_at, } for r in rows ] return rollup(entries) @router.post( "/suggest-foundation", response_model=schemas.SuggestFoundationOut ) async def suggest_foundation( payload: schemas.SuggestFoundationRequest, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Phase 5 smart tagging: suggest which foundation a task builds toward. The person confirms by posting the task mapping. Suggestion only — nothing is stored here.""" label = payload.task_label.strip() if not label: raise HTTPException(status_code=400, detail="task_label is required") profile = await _latest_profile(db, user.id) if profile is None: raise HTTPException(status_code=404, detail="No profile for this user") foundations_text = { key: getattr(profile, field, None) for key, field in FOUNDATION_TO_FIELD.items() } api_key = os.getenv("ANTHROPIC_API_KEY") model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6") try: tagger = FoundationTagger(api_key=api_key, model=model) result = await tagger.suggest(label, foundations_text) except TaggingError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return schemas.SuggestFoundationOut( foundation=result["foundation"], label=FOUNDATIONS[result["foundation"]], rationale=result["rationale"], confidence=result["confidence"], ) @router.get("/work-patterns", response_model=schemas.WorkPatternsOut) async def get_work_patterns( days: int = Query(30, ge=1, le=365), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """Per-foundation work patterns over the window (powers the dashboard and the coaching reminder engine).""" summary = await work_patterns_for(db, user.id, days) return schemas.WorkPatternsOut(window_days=days, **summary)