hale-api / app /api /plans.py
Raunak211006's picture
Deploy HALE API v3.1
cc38116 verified
Raw
History Blame Contribute Delete
30.2 kB
# 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)