# app/api/plans.py """ Daily plan, task completion, skip, history, and behavior endpoints. Production-hardened v2.1.0. Endpoints: GET /api/plan — get today's adaptive plan POST /api/complete-task — mark task complete (XP + streak + RL update) POST /api/skip-task — explicit skip (negative RL signal) POST /api/update-state — update behavioral state GET /api/behavior — get current RL behavior signal GET /api/lesson/{topic} — AI-generated lesson (cached) GET /api/stats — user progress analytics GET /api/stats/{goal_id} — per-goal analytics GET /api/plan/history — past plans (last N days) """ import logging from datetime import date, datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func from sqlalchemy.orm import Session from app.core.database import get_db from app.core.security import get_current_user from app.models.goal import Goal from app.models.progress import Progress from app.models.user import User from app.schemas.goals import ( CompleteTaskRequest, DailyPlanResponse, DayCompleteResponse, PlanHistoryResponse, PlanHistoryEntry, SkipTaskRequest, UpdateStateRequest, ) from app.services.notifications import get_pending_notifications, goal_completion_signal from app.services.planner import ( award_xp, generate_daily_plan, generate_unified_daily_plan, reschedule_skipped, update_streak, ) from app.services.day_closer import close_day_for_user, get_day_status from app.services.rl_engine import get_stable_signal, refresh_signal, update_reward, reset_daily_state, _compute_historical_features from app.services.llm_service import generate_lesson_text logger = logging.getLogger("hale.api.plans") router = APIRouter(prefix="/api", tags=["Plans & Progress"]) # ────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────── def _get_primary_goal(goal_id: Optional[int], user: User, db: Session) -> Goal: """Fetch the user's primary active goal or raise 404.""" if goal_id: goal = db.query(Goal).filter( Goal.id == goal_id, Goal.user_id == user.user_id, Goal.is_active == True, ).first() else: goal = ( db.query(Goal) .filter( Goal.user_id == user.user_id, Goal.is_active == True, Goal.is_completed == False, ) .order_by(Goal.priority.asc()) .first() ) if not goal: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No active goal found. Create a goal first via POST /api/goals/", ) return goal def _get_extra_goals(primary_id: int, user: User, db: Session, limit: int = 2) -> list[Goal]: """Fetch secondary active goals for multi-goal plan interleaving.""" return ( db.query(Goal) .filter( Goal.user_id == user.user_id, Goal.is_active == True, Goal.is_completed == False, Goal.id != primary_id, ) .order_by(Goal.priority.asc()) .limit(limit) .all() ) # ────────────────────────────────────────────── # GET /api/plan # ────────────────────────────────────────────── @router.get("/plan", response_model=DailyPlanResponse, summary="Get today's adaptive daily plan (all active goals)") def get_plan( goal_id: Optional[int] = Query(None, description="Specific goal ID (default: all active goals)"), multi_goal: bool = Query(True, description="Interleave tasks from all active goals (default: True)"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> DailyPlanResponse: if goal_id: # Single-goal mode (explicit) goal = _get_primary_goal(goal_id, current_user, db) reschedule_skipped(db, current_user, goal) plan = generate_daily_plan(db, current_user, goal) else: # Unified multi-goal mode (default) goals = ( db.query(Goal) .filter(Goal.user_id == current_user.user_id, Goal.is_active == True, Goal.is_completed == False) .order_by(Goal.priority.asc()) .all() ) if not goals: # BUG FIX: Return graceful empty plan instead of 404 when user has no active goals. # This allows the frontend to show an onboarding prompt rather than an error screen. from app.services.rl_engine import get_stable_signal import datetime as _dt behavior = get_stable_signal(current_user, db=db) empty_plan = { "date": _dt.date.today().isoformat(), "tasks": [], "tasks_detail": [], "behavior": behavior, "module": "", "topic": "", "coach_message": "Create your first goal to get started! Head to the Goals section.", "streak": current_user.current_streak or 0, "total_minutes": 0, "goals_count": 0, "next_day_preview": None, "progress_context": {"is_falling_behind": False, "is_overdue": False, "days_extended": 0}, "notifications": [], } return DailyPlanResponse(**empty_plan) for g in goals: reschedule_skipped(db, current_user, g) plan = generate_unified_daily_plan(db, current_user, goals) notifs = get_pending_notifications(current_user, db) plan["notifications"] = notifs return DailyPlanResponse(**plan) # ────────────────────────────────────────────── # POST /api/complete-task # ────────────────────────────────────────────── @router.post("/complete-task", summary="Mark today's task as completed") def complete_task( payload: CompleteTaskRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): # Build filter — optionally narrowed by goal_id for multi-goal support query = db.query(Progress).filter( Progress.user_id == current_user.user_id, Progress.date == payload.date, ) if payload.goal_id is not None: query = query.filter(Progress.goal_id == payload.goal_id) # Disambiguate if there are multiple tasks for the date and goal_id was omitted if payload.goal_id is None and query.count() > 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Multiple plans found for {payload.date}. Please specify goal_id.", ) row = query.first() if not row: detail = f"No plan found for {payload.date}" if payload.goal_id: detail += f" (goal_id={payload.goal_id})" detail += ". Call GET /api/plan first." raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=detail, ) # Guard: duplicate completion if row.completed: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Task for {payload.date} is already completed.", ) # Update progress row row.completed = True row.completed_at = datetime.now(timezone.utc) if payload.actual_duration_min is not None: row.actual_duration_min = payload.actual_duration_min if payload.satisfaction is not None: row.satisfaction = payload.satisfaction if payload.notes: row.notes = ((row.notes or "") + f"\n{payload.notes}").strip() # Capture pre-update values for milestone detection xp_before = current_user.xp or 0 level_before = current_user.level or 1 # Update streak streak = update_streak(db, current_user) # Award XP xp_result = award_xp(db, current_user) # Feature 2: Satisfaction Imputation base_reward = 1.0 if payload.satisfaction is not None: base_reward = 0.6 + 0.4 * payload.satisfaction else: # Impute missing satisfaction using 7-day moving average hist = _compute_historical_features(current_user, db) imputed_sat = hist.get("avg_satisfaction_7d", 0.6) base_reward = 0.6 + 0.4 * imputed_sat logger.info("Imputed satisfaction %.2f for %s", imputed_sat, current_user.user_id) # Feature 5: Passive Friction Sensing # Penalize reward if user paused a lot or took much longer than estimated. # This teaches the RL engine the task was harder than anticipated. if payload.pause_count is not None and payload.pause_count > 2: friction_penalty = min(0.3, payload.pause_count * 0.04) base_reward = max(0.1, base_reward - friction_penalty) logger.info("Friction penalty %.2f applied for %s (pauses=%d)", friction_penalty, current_user.user_id, payload.pause_count) if (payload.actual_duration_min is not None and payload.estimated_duration_min is not None and payload.estimated_duration_min > 0): overrun_ratio = payload.actual_duration_min / payload.estimated_duration_min if overrun_ratio > 1.8: # Took 80%+ longer than estimated time_friction = min(0.25, (overrun_ratio - 1.0) * 0.15) base_reward = max(0.1, base_reward - time_friction) logger.info("Time overrun penalty %.2f applied for %s (ratio=%.1fx)", time_friction, current_user.user_id, overrun_ratio) action_taken = row.action_label or "Normal" update_reward(db, current_user, action_taken, base_reward, goal_id=row.goal_id) # Feature 7: Passive Fatigue Accumulation duration = payload.actual_duration_min or 30 fatigue_delta = (duration / 60.0) * 0.10 # Base 0.1 fatigue per hour if action_taken == "Deep Work": fatigue_delta *= 1.5 elif action_taken in ("Recovery", "Rest"): fatigue_delta = -(duration / 60.0) * 0.15 # Recovery restores fatigue current_user.fatigue = max(0.0, min(1.0, (current_user.fatigue or 0.0) + fatigue_delta)) logger.info("Passive fatigue delta %.3f applied to %s (now %.2f)", fatigue_delta, current_user.user_id, current_user.fatigue) # BUG FIX: Eagerly recalculate completion_pct immediately after marking a task complete # so GET /api/goals/{id} reflects accurate progress without requiring a plan re-fetch. if row.goal_id: _goal_for_pct = db.query(Goal).filter(Goal.id == row.goal_id).first() if _goal_for_pct: from app.services.planner import _build_schedule, _load_plan_json _schedule = _build_schedule(_load_plan_json(_goal_for_pct, db, current_user)) _total = len(_schedule) if _total > 0: from app.models.progress import Progress as _Prog _done = db.query(_Prog).filter( _Prog.user_id == current_user.user_id, _Prog.goal_id == row.goal_id, _Prog.completed == True, ).count() _goal_for_pct.completion_pct = min(100.0, round((_done / _total) * 100, 1)) if _goal_for_pct.completion_pct >= 100.0 and not _goal_for_pct.is_completed: _goal_for_pct.is_completed = True import datetime as _dt2 _goal_for_pct.completed_at = _dt2.datetime.now(_dt2.timezone.utc) db.commit() # Check if this completion finished a goal goal_notif = None if row.goal_id: goal = db.query(Goal).filter(Goal.id == row.goal_id).first() if goal and goal.is_completed: goal_notif = goal_completion_signal(goal) # Check if ALL of today's tasks are now complete today_status = get_day_status(db, current_user.user_id) all_today_done = today_status.get("all_completed", False) # If all done, reset daily state so next GET /api/plan serves fresh next-day content if all_today_done: reset_daily_state(db, current_user) # Collect milestone notifications notifs = get_pending_notifications( current_user, db, xp_before=xp_before, level_before=level_before, ) if goal_notif: notifs.append(goal_notif) return { "message": "Task completed! 🎉", "streak": streak, "all_completed": all_today_done, "notifications": notifs, **xp_result, } # ────────────────────────────────────────────── # POST /api/skip-task # ────────────────────────────────────────────── @router.post("/skip-task", summary="Explicitly skip today's task (sends negative RL signal)") def skip_task( payload: SkipTaskRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): row = ( db.query(Progress) .filter( Progress.user_id == current_user.user_id, Progress.date == payload.date, ) .first() ) if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No plan found for {payload.date}.", ) if row.completed: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Cannot skip an already-completed task.", ) # Record skip reason skip_note = "[SKIPPED]" if payload.reason: skip_note += f" Reason: {payload.reason}" row.notes = ((row.notes or "") + f"\n{skip_note}").strip() # Negative RL signal (0.0 reward = skip) action_taken = row.action_label or "Normal" update_reward(db, current_user, action_taken, 0.0) db.commit() logger.info("Task skipped for %s on %s", current_user.user_id, payload.date) return {"message": "Task skipped. We'll adjust your next plan accordingly.", "date": payload.date} # ────────────────────────────────────────────── # POST /api/update-state # ────────────────────────────────────────────── @router.post("/update-state", summary="Update user behavioral state (mood, fatigue, stress, sleep)") def update_state( payload: UpdateStateRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): if payload.mood is not None: current_user.mood = payload.mood if payload.fatigue is not None: current_user.fatigue = payload.fatigue if payload.stress is not None: current_user.stress = payload.stress if payload.sleep_quality is not None: current_user.sleep_quality = payload.sleep_quality if payload.sick is not None: current_user.sick = payload.sick db.flush() # flush state changes before RL re-samples behavior = refresh_signal(current_user, db=db) # force fresh RL sample + persist db.commit() return {"message": "State updated", "behavior": behavior} # ────────────────────────────────────────────── # GET /api/behavior # ────────────────────────────────────────────── @router.get("/behavior", summary="Get current RL behavior signal") def get_behavior( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): return get_stable_signal(current_user, db=db) # ────────────────────────────────────────────── # GET /api/timetable # ────────────────────────────────────────────── @router.get("/timetable", summary="Get today's generated timetable") def get_timetable( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): from app.services.planner import generate_daily_plan, generate_timetable from app.models.goal import Goal # 1. Try to get the active goal goal = db.query(Goal).filter( Goal.user_id == current_user.user_id, Goal.is_active == True, Goal.is_completed == False, ).order_by(Goal.priority.asc(), Goal.created_at.desc()).first() if not goal: return {"timetable": []} # 2. Get today's plan try: plan = generate_daily_plan(db, current_user, goal) except Exception as e: logger.error("Failed to generate plan for timetable: %s", e) return {"timetable": []} # 3. Generate timetable from the plan timetable, nudge = generate_timetable(current_user, db, plan) return { "timetable": timetable, "date": plan.get("date"), "lifestyle": current_user.schedule_json.get("lifestyle", "student") if current_user.schedule_json else "student", "circadian_nudge": nudge } # ────────────────────────────────────────────── # POST /api/day/complete (manual "Done for Today") # ────────────────────────────────────────────── @router.post("/day/complete", response_model=DayCompleteResponse, summary="Mark all of today's tasks as done (Done for Today)") def complete_day( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): result = close_day_for_user(db, current_user.user_id, reason="manual") streak = update_streak(db, current_user) xp = award_xp(db, current_user, base_xp=15 * result.get("closed", 1)) return DayCompleteResponse( closed=result.get("closed", 0), message=result.get("message", "Day closed."), date=result.get("date", ""), streak=streak, next_day_preview=result.get("next_day_preview"), **xp, ) # ────────────────────────────────────────────── # GET /api/plan/next (tomorrow's plan preview) # ────────────────────────────────────────────── @router.get("/plan/next", summary="Preview tomorrow's plan (RL signal + topics)") def get_next_plan( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): from app.services.rl_engine import get_next_day_signal import datetime as dt tomorrow = (dt.date.today() + dt.timedelta(days=1)).isoformat() rl_preview = get_next_day_signal(current_user, db=db) # Check what goals will be active tomorrow goals = ( db.query(Goal) .filter(Goal.user_id == current_user.user_id, Goal.is_active == True, Goal.is_completed == False) .order_by(Goal.priority.asc()) .all() ) goal_previews = [] for goal in goals: goal_previews.append({ "goal_id": goal.id, "goal_text": goal.goal_text[:60], "priority": goal.priority, "completion_pct": goal.completion_pct or 0.0, }) return { "date": tomorrow, "rl_preview": rl_preview, "goals": goal_previews, "message": f"Tomorrow's mode: {rl_preview['action_label']} at {rl_preview['intensity']:.0%} intensity", } # ────────────────────────────────────────────── # GET /api/day/status # ────────────────────────────────────────────── @router.get("/day/status", summary="Get today's plan completion status") def day_status( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): return get_day_status(db, current_user.user_id) # ────────────────────────────────────────────── # GET /api/lesson/{topic} # ────────────────────────────────────────────── @router.get("/lesson/{topic}", summary="Generate (or retrieve cached) AI lesson content") def get_lesson( topic: str, current_user: User = Depends(get_current_user), ): if not topic or len(topic.strip()) < 2: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Topic must be at least 2 characters.", ) topic = topic.strip()[:200] # cap length # generate_lesson_text() is Redis-backed (7-day TTL) — no local cache needed content, is_cached = generate_lesson_text(topic) if not content: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Lesson generation temporarily unavailable.", ) return {"topic": topic, "content": content, "cached": is_cached} # ────────────────────────────────────────────── # GET /api/stats # ────────────────────────────────────────────── @router.get("/stats", summary="Get overall user progress statistics") def get_stats( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): total_completed = db.query(Progress).filter( Progress.user_id == current_user.user_id, Progress.completed == True, ).count() total_incomplete = db.query(Progress).filter( Progress.user_id == current_user.user_id, Progress.completed == False, ).count() total_goals_completed = db.query(Goal).filter( Goal.user_id == current_user.user_id, Goal.is_completed == True, ).count() total_goals = db.query(Goal).filter( Goal.user_id == current_user.user_id, ).count() avg_minutes = db.query(func.avg(Progress.actual_duration_min)).filter( Progress.user_id == current_user.user_id, Progress.completed == True, Progress.actual_duration_min.isnot(None), ).scalar() or 0.0 recent = ( db.query(Progress) .filter(Progress.user_id == current_user.user_id, Progress.completed == True) .order_by(Progress.completed_at.desc()) .limit(10) .all() ) recent_activity = [ { "date": r.date, "topic": r.topic, "module": r.module, "duration_min": r.actual_duration_min, "action_label": r.action_label, "satisfaction": r.satisfaction, } for r in recent ] mood_entries = ( db.query(Progress.date, Progress.mood, Progress.fatigue) .filter(Progress.user_id == current_user.user_id) .order_by(Progress.date.desc()) .limit(14) .all() ) mood_trend = [ {"date": m.date, "mood": m.mood, "fatigue": m.fatigue} for m in mood_entries ] completion_rate = ( round(total_completed / max(total_completed + total_incomplete, 1) * 100, 1) ) from app.services.rl_engine import _calculate_weekly_strain, _compute_historical_features strain_ratio, is_deload = _calculate_weekly_strain(current_user, db) hist = _compute_historical_features(current_user, db) return { "total_tasks_completed": total_completed, "total_goals_completed": total_goals_completed, "total_goals": total_goals, "current_streak": current_user.current_streak or 0, "longest_streak": current_user.longest_streak or 0, "xp": current_user.xp or 0, "level": current_user.level or 1, "completion_rate": completion_rate, "avg_daily_minutes": round(float(avg_minutes), 1), "mood_trend": mood_trend, "recent_activity": recent_activity, "recovery_health": { "strain_ratio": strain_ratio, "is_deload_state": is_deload }, "rl_analysis": { "burnout_warning": hist.get("burnout_warning", False), "overwork_signal": hist.get("overwork_signal", 0.0), "weekend_warrior_flag": hist.get("weekend_rate", 0) > hist.get("weekday_rate", 0) + 0.05, "max_goal_completion": hist.get("max_goal_completion", 0.0), "ema_fatigue": hist.get("ema_fatigue", 0.0), } } # ────────────────────────────────────────────── # GET /api/stats/{goal_id} # ────────────────────────────────────────────── @router.get("/stats/{goal_id}", summary="Get progress statistics for a specific goal") def get_stats_for_goal( goal_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): goal = db.query(Goal).filter( Goal.id == goal_id, Goal.user_id == current_user.user_id, ).first() if not goal: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Goal not found") sessions = ( db.query(Progress) .filter(Progress.user_id == current_user.user_id, Progress.goal_id == goal_id) .all() ) completed = [s for s in sessions if s.completed] incomplete = [s for s in sessions if not s.completed] total_min = sum(s.actual_duration_min or 0 for s in completed) avg_sat = ( sum(s.satisfaction for s in completed if s.satisfaction is not None) / max(1, sum(1 for s in completed if s.satisfaction is not None)) if any(s.satisfaction is not None for s in completed) else None ) action_dist: dict = {} for s in sessions: lbl = s.action_label or "Unknown" action_dist[lbl] = action_dist.get(lbl, 0) + 1 return { "goal_id": goal.id, "goal_text": goal.goal_text, "completion_pct": goal.completion_pct or 0.0, "is_completed": goal.is_completed, "total_sessions": len(sessions), "completed_sessions": len(completed), "skipped_sessions": len(incomplete), "total_minutes_spent": total_min, "avg_satisfaction": round(avg_sat, 3) if avg_sat is not None else None, "action_distribution": action_dist, } # ────────────────────────────────────────────── # GET /api/plan/history # ────────────────────────────────────────────── @router.get("/history", response_model=PlanHistoryResponse, summary="View past daily plans (alias for /api/plan/history)") @router.get("/plan/history", response_model=PlanHistoryResponse, summary="View past daily plans") def get_plan_history( days: int = Query(7, ge=1, le=90, description="Number of past days to retrieve (min 1, max 90)"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> PlanHistoryResponse: from datetime import timedelta cutoff = (date.today() - timedelta(days=days)).isoformat() rows = ( db.query(Progress) .filter( Progress.user_id == current_user.user_id, Progress.date >= cutoff, ) .order_by(Progress.date.desc()) .all() ) entries = [ PlanHistoryEntry( date = r.date, module = r.module or "", topic = r.topic or "", completed = r.completed, action_label = r.action_label or "Unknown", duration_min = r.actual_duration_min, satisfaction = r.satisfaction, ) for r in rows ] return PlanHistoryResponse(days=days, entries=entries)