Add comprehensive development intelligence system that tracks: - Development sessions with automatic start/stop - Full conversation history with semantic search - Tool usage and file operation analytics - Think time and engagement analysis - Git activity correlation - Learning pattern recognition - Productivity insights and metrics Features: - FastAPI backend with SQLite database - Modern web dashboard with interactive charts - Claude Code hook integration for automatic tracking - Comprehensive test suite with 100+ tests - Complete API documentation (OpenAPI/Swagger) - Privacy-first design with local data storage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""
|
|
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)}"
|
|
) |