""" Waiting period tracking API endpoints. """ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from sqlalchemy.orm import selectinload from app.database.connection import get_db from app.models.waiting_period import WaitingPeriod from app.models.session import Session from app.api.schemas import WaitingStartRequest, WaitingEndRequest, WaitingPeriodResponse router = APIRouter() @router.post("/waiting/start", response_model=WaitingPeriodResponse, status_code=status.HTTP_201_CREATED) async def start_waiting_period( request: WaitingStartRequest, db: AsyncSession = Depends(get_db) ): """ Start a new waiting period. Called by the Notification hook when Claude is waiting for user input. """ try: # Verify session exists result = await db.execute( select(Session).where(Session.id == request.session_id) ) session = result.scalars().first() if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {request.session_id} not found" ) # Check if there's already an active waiting period for this session active_waiting = await db.execute( select(WaitingPeriod).where( WaitingPeriod.session_id == request.session_id, WaitingPeriod.end_time.is_(None) ) ) existing = active_waiting.scalars().first() if existing: # End the existing waiting period first existing.end_waiting() # Create new waiting period waiting_period = WaitingPeriod( session_id=request.session_id, start_time=request.timestamp, context_before=request.context_before ) db.add(waiting_period) await db.commit() await db.refresh(waiting_period) return WaitingPeriodResponse( id=waiting_period.id, session_id=waiting_period.session_id, start_time=waiting_period.start_time, end_time=waiting_period.end_time, duration_seconds=waiting_period.calculated_duration_seconds, engagement_score=waiting_period.engagement_score ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to start waiting period: {str(e)}" ) @router.post("/waiting/end", response_model=WaitingPeriodResponse) async def end_waiting_period( request: WaitingEndRequest, db: AsyncSession = Depends(get_db) ): """ End the current waiting period for a session. Called when the user submits new input or the Stop hook triggers. """ try: # Find the active waiting period for this session result = await db.execute( select(WaitingPeriod).where( WaitingPeriod.session_id == request.session_id, WaitingPeriod.end_time.is_(None) ) ) waiting_period = result.scalars().first() if not waiting_period: # If no active waiting period, that's okay - just return success return WaitingPeriodResponse( id=0, session_id=request.session_id, start_time=request.timestamp, end_time=request.timestamp, duration_seconds=0, engagement_score=1.0 ) # End the waiting period waiting_period.end_time = request.timestamp waiting_period.duration_seconds = request.duration_seconds or waiting_period.calculated_duration_seconds waiting_period.context_after = request.context_after # Classify the activity based on duration waiting_period.likely_activity = waiting_period.classify_activity() await db.commit() await db.refresh(waiting_period) return WaitingPeriodResponse( id=waiting_period.id, session_id=waiting_period.session_id, start_time=waiting_period.start_time, end_time=waiting_period.end_time, duration_seconds=waiting_period.duration_seconds, engagement_score=waiting_period.engagement_score ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to end waiting period: {str(e)}" ) @router.get("/waiting/periods") async def get_waiting_periods( session_id: Optional[int] = Query(None, description="Filter by session ID"), project_id: Optional[int] = Query(None, description="Filter by project ID"), limit: int = Query(50, description="Maximum number of results"), offset: int = Query(0, description="Number of results to skip"), db: AsyncSession = Depends(get_db) ): """Get waiting periods with optional filtering.""" try: query = select(WaitingPeriod).options( selectinload(WaitingPeriod.session).selectinload(Session.project) ) # Apply filters if session_id: query = query.where(WaitingPeriod.session_id == session_id) elif project_id: query = query.join(Session).where(Session.project_id == project_id) # Order by start time descending query = query.order_by(WaitingPeriod.start_time.desc()).offset(offset).limit(limit) result = await db.execute(query) waiting_periods = result.scalars().all() return [ { "id": period.id, "session_id": period.session_id, "project_name": period.session.project.name, "start_time": period.start_time, "end_time": period.end_time, "duration_seconds": period.calculated_duration_seconds, "duration_minutes": period.duration_minutes, "likely_activity": period.classify_activity(), "engagement_score": period.engagement_score, "context_before": period.context_before, "context_after": period.context_after, "is_quick_response": period.is_quick_response(), "is_thoughtful_pause": period.is_thoughtful_pause(), "is_research_break": period.is_research_break(), "is_extended_break": period.is_extended_break() } for period in waiting_periods ] except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get waiting periods: {str(e)}" ) @router.get("/waiting/stats/engagement") async def get_engagement_stats( session_id: Optional[int] = Query(None, description="Filter by session ID"), project_id: Optional[int] = Query(None, description="Filter by project ID"), days: int = Query(7, description="Number of days to include"), db: AsyncSession = Depends(get_db) ): """Get engagement statistics based on waiting periods.""" try: # Base query for waiting periods query = select(WaitingPeriod).where(WaitingPeriod.duration_seconds.isnot(None)) # Apply filters if session_id: query = query.where(WaitingPeriod.session_id == session_id) elif project_id: query = query.join(Session).where(Session.project_id == project_id) # Filter by date range if days > 0: from datetime import datetime, timedelta start_date = datetime.utcnow() - timedelta(days=days) query = query.where(WaitingPeriod.start_time >= start_date) result = await db.execute(query) waiting_periods = result.scalars().all() if not waiting_periods: return { "total_periods": 0, "average_engagement_score": 0.0, "average_think_time_seconds": 0.0, "activity_distribution": {}, "engagement_trends": [] } # Calculate statistics total_periods = len(waiting_periods) engagement_scores = [period.engagement_score for period in waiting_periods] durations = [period.duration_seconds for period in waiting_periods] average_engagement = sum(engagement_scores) / len(engagement_scores) average_think_time = sum(durations) / len(durations) # Activity distribution activity_counts = {} for period in waiting_periods: activity = period.classify_activity() activity_counts[activity] = activity_counts.get(activity, 0) + 1 activity_distribution = { activity: count / total_periods for activity, count in activity_counts.items() } # Engagement trends (daily averages) daily_engagement = {} for period in waiting_periods: date_key = period.start_time.date().isoformat() if date_key not in daily_engagement: daily_engagement[date_key] = [] daily_engagement[date_key].append(period.engagement_score) engagement_trends = [ { "date": date, "engagement_score": sum(scores) / len(scores) } for date, scores in sorted(daily_engagement.items()) ] return { "total_periods": total_periods, "average_engagement_score": round(average_engagement, 3), "average_think_time_seconds": round(average_think_time, 1), "activity_distribution": activity_distribution, "engagement_trends": engagement_trends, "response_time_breakdown": { "quick_responses": sum(1 for p in waiting_periods if p.is_quick_response()), "thoughtful_pauses": sum(1 for p in waiting_periods if p.is_thoughtful_pause()), "research_breaks": sum(1 for p in waiting_periods if p.is_research_break()), "extended_breaks": sum(1 for p in waiting_periods if p.is_extended_break()) } } except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get engagement stats: {str(e)}" )