Initial commit: Claude Code Project Tracker
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>
This commit is contained in:
commit
44ed9936b7
48 changed files with 9707 additions and 0 deletions
3
app/api/__init__.py
Normal file
3
app/api/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
API modules for the Claude Code Project Tracker.
|
||||
"""
|
||||
304
app/api/activities.py
Normal file
304
app/api/activities.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""
|
||||
Activity 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.activity import Activity
|
||||
from app.models.session import Session
|
||||
from app.api.schemas import ActivityRequest, ActivityResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/activity", response_model=ActivityResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def record_activity(
|
||||
request: ActivityRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Record a development activity (tool usage, file operation, etc.).
|
||||
|
||||
This endpoint is called by Claude Code PostToolUse hooks.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
# Create activity record
|
||||
activity = Activity(
|
||||
session_id=request.session_id,
|
||||
conversation_id=request.conversation_id,
|
||||
timestamp=request.timestamp,
|
||||
tool_name=request.tool_name,
|
||||
action=request.action,
|
||||
file_path=request.file_path,
|
||||
metadata=request.metadata,
|
||||
success=request.success,
|
||||
error_message=request.error_message,
|
||||
lines_added=request.lines_added,
|
||||
lines_removed=request.lines_removed
|
||||
)
|
||||
|
||||
db.add(activity)
|
||||
|
||||
# Update session activity count
|
||||
session.add_activity()
|
||||
|
||||
# Add file to session's touched files if applicable
|
||||
if request.file_path:
|
||||
session.add_file_touched(request.file_path)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(activity)
|
||||
|
||||
return ActivityResponse(
|
||||
id=activity.id,
|
||||
session_id=activity.session_id,
|
||||
tool_name=activity.tool_name,
|
||||
action=activity.action,
|
||||
timestamp=activity.timestamp,
|
||||
success=activity.success
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to record activity: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activities")
|
||||
async def get_activities(
|
||||
session_id: Optional[int] = Query(None, description="Filter by session ID"),
|
||||
tool_name: Optional[str] = Query(None, description="Filter by tool name"),
|
||||
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 activities with optional filtering."""
|
||||
try:
|
||||
query = select(Activity).options(
|
||||
selectinload(Activity.session).selectinload(Session.project)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if session_id:
|
||||
query = query.where(Activity.session_id == session_id)
|
||||
|
||||
if tool_name:
|
||||
query = query.where(Activity.tool_name == tool_name)
|
||||
|
||||
# Order by timestamp descending
|
||||
query = query.order_by(Activity.timestamp.desc()).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
activities = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": activity.id,
|
||||
"session_id": activity.session_id,
|
||||
"project_name": activity.session.project.name,
|
||||
"timestamp": activity.timestamp,
|
||||
"tool_name": activity.tool_name,
|
||||
"action": activity.action,
|
||||
"file_path": activity.file_path,
|
||||
"success": activity.success,
|
||||
"programming_language": activity.get_programming_language(),
|
||||
"lines_changed": activity.total_lines_changed,
|
||||
"metadata": activity.metadata
|
||||
}
|
||||
for activity in activities
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get activities: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}")
|
||||
async def get_activity(
|
||||
activity_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get detailed information about a specific activity."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Activity)
|
||||
.options(
|
||||
selectinload(Activity.session).selectinload(Session.project),
|
||||
selectinload(Activity.conversation)
|
||||
)
|
||||
.where(Activity.id == activity_id)
|
||||
)
|
||||
activity = result.scalars().first()
|
||||
|
||||
if not activity:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Activity {activity_id} not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": activity.id,
|
||||
"session_id": activity.session_id,
|
||||
"conversation_id": activity.conversation_id,
|
||||
"project_name": activity.session.project.name,
|
||||
"timestamp": activity.timestamp,
|
||||
"tool_name": activity.tool_name,
|
||||
"action": activity.action,
|
||||
"file_path": activity.file_path,
|
||||
"metadata": activity.metadata,
|
||||
"success": activity.success,
|
||||
"error_message": activity.error_message,
|
||||
"lines_added": activity.lines_added,
|
||||
"lines_removed": activity.lines_removed,
|
||||
"total_lines_changed": activity.total_lines_changed,
|
||||
"net_lines_changed": activity.net_lines_changed,
|
||||
"file_extension": activity.get_file_extension(),
|
||||
"programming_language": activity.get_programming_language(),
|
||||
"is_file_operation": activity.is_file_operation,
|
||||
"is_code_execution": activity.is_code_execution,
|
||||
"is_search_operation": activity.is_search_operation,
|
||||
"command_executed": activity.get_command_executed(),
|
||||
"search_pattern": activity.get_search_pattern(),
|
||||
"task_type": activity.get_task_type()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get activity: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activities/stats/tools")
|
||||
async def get_tool_usage_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(30, description="Number of days to include"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get tool usage statistics."""
|
||||
try:
|
||||
# Base query for tool usage counts
|
||||
query = select(
|
||||
Activity.tool_name,
|
||||
func.count(Activity.id).label('usage_count'),
|
||||
func.count(func.distinct(Activity.session_id)).label('sessions_used'),
|
||||
func.avg(Activity.lines_added + Activity.lines_removed).label('avg_lines_changed'),
|
||||
func.sum(Activity.lines_added + Activity.lines_removed).label('total_lines_changed')
|
||||
).group_by(Activity.tool_name)
|
||||
|
||||
# Apply filters
|
||||
if session_id:
|
||||
query = query.where(Activity.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(Activity.timestamp >= start_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
stats = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"tool_name": stat.tool_name,
|
||||
"usage_count": stat.usage_count,
|
||||
"sessions_used": stat.sessions_used,
|
||||
"avg_lines_changed": float(stat.avg_lines_changed or 0),
|
||||
"total_lines_changed": stat.total_lines_changed or 0
|
||||
}
|
||||
for stat in stats
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get tool usage stats: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activities/stats/languages")
|
||||
async def get_language_usage_stats(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
days: int = Query(30, description="Number of days to include"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get programming language usage statistics."""
|
||||
try:
|
||||
# Get activities with file operations
|
||||
query = select(Activity).where(
|
||||
Activity.file_path.isnot(None),
|
||||
Activity.tool_name.in_(["Edit", "Write", "Read"])
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if project_id:
|
||||
query = query.join(Session).where(Session.project_id == project_id)
|
||||
|
||||
if days > 0:
|
||||
from datetime import datetime, timedelta
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
query = query.where(Activity.timestamp >= start_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
activities = result.scalars().all()
|
||||
|
||||
# Count by programming language
|
||||
language_stats = {}
|
||||
for activity in activities:
|
||||
lang = activity.get_programming_language()
|
||||
if lang:
|
||||
if lang not in language_stats:
|
||||
language_stats[lang] = {
|
||||
"language": lang,
|
||||
"file_count": 0,
|
||||
"activity_count": 0,
|
||||
"lines_added": 0,
|
||||
"lines_removed": 0
|
||||
}
|
||||
|
||||
language_stats[lang]["activity_count"] += 1
|
||||
language_stats[lang]["lines_added"] += activity.lines_added or 0
|
||||
language_stats[lang]["lines_removed"] += activity.lines_removed or 0
|
||||
|
||||
# Count unique files per language
|
||||
for activity in activities:
|
||||
lang = activity.get_programming_language()
|
||||
if lang and lang in language_stats:
|
||||
# This is a rough approximation - in reality we'd need to track unique files
|
||||
language_stats[lang]["file_count"] += 1
|
||||
|
||||
return list(language_stats.values())
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get language usage stats: {str(e)}"
|
||||
)
|
||||
483
app/api/analytics.py
Normal file
483
app/api/analytics.py
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"""
|
||||
Analytics API endpoints for productivity insights and metrics.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.connection import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.session import Session
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.activity import Activity
|
||||
from app.models.waiting_period import WaitingPeriod
|
||||
from app.models.git_operation import GitOperation
|
||||
from app.api.schemas import ProductivityMetrics
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def calculate_engagement_score(waiting_periods: List[WaitingPeriod]) -> float:
|
||||
"""Calculate overall engagement score from waiting periods."""
|
||||
if not waiting_periods:
|
||||
return 75.0 # Default neutral score
|
||||
|
||||
scores = [wp.engagement_score for wp in waiting_periods]
|
||||
avg_score = sum(scores) / len(scores)
|
||||
return round(avg_score * 100, 1) # Convert to 0-100 scale
|
||||
|
||||
|
||||
async def get_productivity_trends(db: AsyncSession, sessions: List[Session], days: int) -> List[Dict[str, Any]]:
|
||||
"""Calculate daily productivity trends."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Group sessions by date
|
||||
daily_data = {}
|
||||
for session in sessions:
|
||||
date_key = session.start_time.date().isoformat()
|
||||
if date_key not in daily_data:
|
||||
daily_data[date_key] = {
|
||||
"sessions": 0,
|
||||
"total_time": 0,
|
||||
"activities": 0,
|
||||
"conversations": 0
|
||||
}
|
||||
|
||||
daily_data[date_key]["sessions"] += 1
|
||||
daily_data[date_key]["total_time"] += session.calculated_duration_minutes or 0
|
||||
daily_data[date_key]["activities"] += session.activity_count
|
||||
daily_data[date_key]["conversations"] += session.conversation_count
|
||||
|
||||
# Calculate productivity scores (0-100 based on relative activity)
|
||||
if daily_data:
|
||||
max_activities = max(day["activities"] for day in daily_data.values()) or 1
|
||||
max_time = max(day["total_time"] for day in daily_data.values()) or 1
|
||||
|
||||
trends = []
|
||||
for date, data in sorted(daily_data.items()):
|
||||
# Weighted score: 60% activities, 40% time
|
||||
activity_score = (data["activities"] / max_activities) * 60
|
||||
time_score = (data["total_time"] / max_time) * 40
|
||||
productivity_score = activity_score + time_score
|
||||
|
||||
trends.append({
|
||||
"date": date,
|
||||
"score": round(productivity_score, 1)
|
||||
})
|
||||
|
||||
return trends
|
||||
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/analytics/productivity", response_model=ProductivityMetrics)
|
||||
async def get_productivity_metrics(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
days: int = Query(30, description="Number of days to analyze"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get comprehensive productivity analytics and insights.
|
||||
|
||||
Analyzes engagement, tool usage, and productivity patterns.
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Date filter
|
||||
start_date = datetime.utcnow() - timedelta(days=days) if days > 0 else None
|
||||
|
||||
# Base query for sessions
|
||||
session_query = select(Session).options(
|
||||
selectinload(Session.project),
|
||||
selectinload(Session.activities),
|
||||
selectinload(Session.conversations),
|
||||
selectinload(Session.waiting_periods)
|
||||
)
|
||||
|
||||
if project_id:
|
||||
session_query = session_query.where(Session.project_id == project_id)
|
||||
|
||||
if start_date:
|
||||
session_query = session_query.where(Session.start_time >= start_date)
|
||||
|
||||
session_result = await db.execute(session_query)
|
||||
sessions = session_result.scalars().all()
|
||||
|
||||
if not sessions:
|
||||
return ProductivityMetrics(
|
||||
engagement_score=0.0,
|
||||
average_session_length=0.0,
|
||||
think_time_average=0.0,
|
||||
files_per_session=0.0,
|
||||
tools_most_used=[],
|
||||
productivity_trends=[]
|
||||
)
|
||||
|
||||
# Calculate basic metrics
|
||||
total_sessions = len(sessions)
|
||||
total_time = sum(s.calculated_duration_minutes or 0 for s in sessions)
|
||||
average_session_length = total_time / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
# Collect all waiting periods for engagement analysis
|
||||
all_waiting_periods = []
|
||||
for session in sessions:
|
||||
all_waiting_periods.extend(session.waiting_periods)
|
||||
|
||||
# Calculate think time average
|
||||
valid_wait_times = [wp.calculated_duration_seconds for wp in all_waiting_periods
|
||||
if wp.calculated_duration_seconds is not None]
|
||||
think_time_average = sum(valid_wait_times) / len(valid_wait_times) if valid_wait_times else 0
|
||||
|
||||
# Calculate engagement score
|
||||
engagement_score = await calculate_engagement_score(all_waiting_periods)
|
||||
|
||||
# Calculate files per session
|
||||
total_files = sum(len(s.files_touched or []) for s in sessions)
|
||||
files_per_session = total_files / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
# Tool usage analysis
|
||||
tool_usage = {}
|
||||
for session in sessions:
|
||||
for activity in session.activities:
|
||||
tool = activity.tool_name
|
||||
if tool not in tool_usage:
|
||||
tool_usage[tool] = 0
|
||||
tool_usage[tool] += 1
|
||||
|
||||
tools_most_used = [
|
||||
{"tool": tool, "count": count}
|
||||
for tool, count in sorted(tool_usage.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
]
|
||||
|
||||
# Get productivity trends
|
||||
productivity_trends = await get_productivity_trends(db, sessions, days)
|
||||
|
||||
return ProductivityMetrics(
|
||||
engagement_score=engagement_score,
|
||||
average_session_length=round(average_session_length, 1),
|
||||
think_time_average=round(think_time_average, 1),
|
||||
files_per_session=round(files_per_session, 1),
|
||||
tools_most_used=tools_most_used,
|
||||
productivity_trends=productivity_trends
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get productivity metrics: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/patterns")
|
||||
async def get_development_patterns(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
days: int = Query(30, description="Number of days to analyze"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Analyze development patterns and workflow insights."""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
start_date = datetime.utcnow() - timedelta(days=days) if days > 0 else None
|
||||
|
||||
# Get sessions with related data
|
||||
session_query = select(Session).options(
|
||||
selectinload(Session.activities),
|
||||
selectinload(Session.conversations),
|
||||
selectinload(Session.waiting_periods),
|
||||
selectinload(Session.git_operations)
|
||||
)
|
||||
|
||||
if project_id:
|
||||
session_query = session_query.where(Session.project_id == project_id)
|
||||
|
||||
if start_date:
|
||||
session_query = session_query.where(Session.start_time >= start_date)
|
||||
|
||||
session_result = await db.execute(session_query)
|
||||
sessions = session_result.scalars().all()
|
||||
|
||||
if not sessions:
|
||||
return {"message": "No data available for the specified period"}
|
||||
|
||||
# Working hours analysis
|
||||
hour_distribution = {}
|
||||
for session in sessions:
|
||||
hour = session.start_time.hour
|
||||
hour_distribution[hour] = hour_distribution.get(hour, 0) + 1
|
||||
|
||||
# Session type patterns
|
||||
session_type_distribution = {}
|
||||
for session in sessions:
|
||||
session_type = session.session_type
|
||||
session_type_distribution[session_type] = session_type_distribution.get(session_type, 0) + 1
|
||||
|
||||
# Git workflow patterns
|
||||
git_patterns = {"commits_per_session": 0, "commit_frequency": {}}
|
||||
total_commits = 0
|
||||
commit_days = set()
|
||||
|
||||
for session in sessions:
|
||||
session_commits = sum(1 for op in session.git_operations if op.is_commit)
|
||||
total_commits += session_commits
|
||||
|
||||
for op in session.git_operations:
|
||||
if op.is_commit:
|
||||
commit_days.add(op.timestamp.date())
|
||||
|
||||
git_patterns["commits_per_session"] = round(total_commits / len(sessions), 2) if sessions else 0
|
||||
git_patterns["commit_frequency"] = round(len(commit_days) / days, 2) if days > 0 else 0
|
||||
|
||||
# Problem-solving patterns
|
||||
problem_solving = {"debug_sessions": 0, "learning_sessions": 0, "implementation_sessions": 0}
|
||||
|
||||
for session in sessions:
|
||||
# Analyze conversation content to infer session type
|
||||
debug_keywords = ["error", "debug", "bug", "fix", "problem", "issue"]
|
||||
learn_keywords = ["how", "what", "explain", "understand", "learn", "tutorial"]
|
||||
impl_keywords = ["implement", "create", "build", "add", "feature"]
|
||||
|
||||
session_content = " ".join([
|
||||
conv.user_prompt or "" for conv in session.conversations
|
||||
]).lower()
|
||||
|
||||
if any(keyword in session_content for keyword in debug_keywords):
|
||||
problem_solving["debug_sessions"] += 1
|
||||
elif any(keyword in session_content for keyword in learn_keywords):
|
||||
problem_solving["learning_sessions"] += 1
|
||||
elif any(keyword in session_content for keyword in impl_keywords):
|
||||
problem_solving["implementation_sessions"] += 1
|
||||
|
||||
# Tool workflow patterns
|
||||
common_sequences = {}
|
||||
for session in sessions:
|
||||
activities = sorted(session.activities, key=lambda a: a.timestamp)
|
||||
if len(activities) >= 2:
|
||||
for i in range(len(activities) - 1):
|
||||
sequence = f"{activities[i].tool_name} → {activities[i+1].tool_name}"
|
||||
common_sequences[sequence] = common_sequences.get(sequence, 0) + 1
|
||||
|
||||
# Get top 5 tool sequences
|
||||
top_sequences = sorted(common_sequences.items(), key=lambda x: x[1], reverse=True)[:5]
|
||||
|
||||
return {
|
||||
"analysis_period_days": days,
|
||||
"total_sessions_analyzed": len(sessions),
|
||||
"working_hours": {
|
||||
"distribution": hour_distribution,
|
||||
"peak_hours": sorted(hour_distribution.items(), key=lambda x: x[1], reverse=True)[:3],
|
||||
"most_active_hour": max(hour_distribution.items(), key=lambda x: x[1])[0] if hour_distribution else None
|
||||
},
|
||||
"session_patterns": {
|
||||
"type_distribution": session_type_distribution,
|
||||
"average_duration_minutes": round(sum(s.calculated_duration_minutes or 0 for s in sessions) / len(sessions), 1)
|
||||
},
|
||||
"git_workflow": git_patterns,
|
||||
"problem_solving_patterns": problem_solving,
|
||||
"tool_workflows": {
|
||||
"common_sequences": [{"sequence": seq, "count": count} for seq, count in top_sequences],
|
||||
"total_unique_sequences": len(common_sequences)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to analyze development patterns: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/learning")
|
||||
async def get_learning_insights(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
days: int = Query(30, description="Number of days to analyze"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Analyze learning patterns and knowledge development."""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
start_date = datetime.utcnow() - timedelta(days=days) if days > 0 else None
|
||||
|
||||
# Get conversations for learning analysis
|
||||
conv_query = select(Conversation).options(selectinload(Conversation.session))
|
||||
|
||||
if project_id:
|
||||
conv_query = conv_query.join(Session).where(Session.project_id == project_id)
|
||||
|
||||
if start_date:
|
||||
conv_query = conv_query.where(Conversation.timestamp >= start_date)
|
||||
|
||||
conv_result = await db.execute(conv_query)
|
||||
conversations = conv_result.scalars().all()
|
||||
|
||||
if not conversations:
|
||||
return {"message": "No conversation data available for learning analysis"}
|
||||
|
||||
# Topic frequency analysis
|
||||
learning_keywords = {
|
||||
"authentication": ["auth", "login", "password", "token", "session"],
|
||||
"database": ["database", "sql", "query", "table", "migration"],
|
||||
"api": ["api", "rest", "endpoint", "request", "response"],
|
||||
"testing": ["test", "pytest", "unittest", "mock", "fixture"],
|
||||
"deployment": ["deploy", "docker", "aws", "server", "production"],
|
||||
"debugging": ["debug", "error", "exception", "traceback", "log"],
|
||||
"optimization": ["optimize", "performance", "speed", "memory", "cache"],
|
||||
"security": ["security", "vulnerability", "encrypt", "hash", "ssl"]
|
||||
}
|
||||
|
||||
topic_frequency = {topic: 0 for topic in learning_keywords.keys()}
|
||||
|
||||
for conv in conversations:
|
||||
if conv.user_prompt:
|
||||
prompt_lower = conv.user_prompt.lower()
|
||||
for topic, keywords in learning_keywords.items():
|
||||
if any(keyword in prompt_lower for keyword in keywords):
|
||||
topic_frequency[topic] += 1
|
||||
|
||||
# Question complexity analysis
|
||||
complexity_indicators = {
|
||||
"beginner": ["how to", "what is", "how do i", "basic", "simple"],
|
||||
"intermediate": ["best practice", "optimize", "improve", "better way"],
|
||||
"advanced": ["architecture", "pattern", "scalability", "design", "system"]
|
||||
}
|
||||
|
||||
complexity_distribution = {level: 0 for level in complexity_indicators.keys()}
|
||||
|
||||
for conv in conversations:
|
||||
if conv.user_prompt:
|
||||
prompt_lower = conv.user_prompt.lower()
|
||||
for level, indicators in complexity_indicators.items():
|
||||
if any(indicator in prompt_lower for indicator in indicators):
|
||||
complexity_distribution[level] += 1
|
||||
break
|
||||
|
||||
# Learning progression analysis
|
||||
weekly_topics = {}
|
||||
for conv in conversations:
|
||||
if conv.user_prompt:
|
||||
week = conv.timestamp.strftime("%Y-W%U")
|
||||
if week not in weekly_topics:
|
||||
weekly_topics[week] = set()
|
||||
|
||||
prompt_lower = conv.user_prompt.lower()
|
||||
for topic, keywords in learning_keywords.items():
|
||||
if any(keyword in prompt_lower for keyword in keywords):
|
||||
weekly_topics[week].add(topic)
|
||||
|
||||
# Calculate learning velocity (new topics per week)
|
||||
learning_velocity = []
|
||||
for week, topics in sorted(weekly_topics.items()):
|
||||
learning_velocity.append({
|
||||
"week": week,
|
||||
"new_topics": len(topics),
|
||||
"topics": list(topics)
|
||||
})
|
||||
|
||||
# Repetition patterns (topics asked about multiple times)
|
||||
repeated_topics = {topic: count for topic, count in topic_frequency.items() if count > 1}
|
||||
|
||||
return {
|
||||
"analysis_period_days": days,
|
||||
"total_conversations_analyzed": len(conversations),
|
||||
"learning_topics": {
|
||||
"frequency": topic_frequency,
|
||||
"most_discussed": max(topic_frequency.items(), key=lambda x: x[1]) if topic_frequency else None,
|
||||
"repeated_topics": repeated_topics
|
||||
},
|
||||
"question_complexity": {
|
||||
"distribution": complexity_distribution,
|
||||
"progression_indicator": "advancing" if complexity_distribution["advanced"] > complexity_distribution["beginner"] else "learning_basics"
|
||||
},
|
||||
"learning_velocity": learning_velocity,
|
||||
"insights": {
|
||||
"diverse_learning": len([t for t, c in topic_frequency.items() if c > 0]),
|
||||
"deep_dives": len(repeated_topics),
|
||||
"active_learning_weeks": len(weekly_topics),
|
||||
"avg_topics_per_week": round(sum(len(topics) for topics in weekly_topics.values()) / len(weekly_topics), 1) if weekly_topics else 0
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to analyze learning insights: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/summary")
|
||||
async def get_analytics_summary(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get a high-level analytics summary dashboard."""
|
||||
try:
|
||||
# Get overall statistics
|
||||
base_query = select(Session)
|
||||
if project_id:
|
||||
base_query = base_query.where(Session.project_id == project_id)
|
||||
|
||||
session_result = await db.execute(base_query)
|
||||
all_sessions = session_result.scalars().all()
|
||||
|
||||
if not all_sessions:
|
||||
return {"message": "No data available"}
|
||||
|
||||
# Basic metrics
|
||||
total_sessions = len(all_sessions)
|
||||
total_time_hours = sum(s.calculated_duration_minutes or 0 for s in all_sessions) / 60
|
||||
avg_session_minutes = (sum(s.calculated_duration_minutes or 0 for s in all_sessions) / total_sessions) if total_sessions else 0
|
||||
|
||||
# Date range
|
||||
start_date = min(s.start_time for s in all_sessions)
|
||||
end_date = max(s.start_time for s in all_sessions)
|
||||
total_days = (end_date - start_date).days + 1
|
||||
|
||||
# Activity summary
|
||||
total_activities = sum(s.activity_count for s in all_sessions)
|
||||
total_conversations = sum(s.conversation_count for s in all_sessions)
|
||||
|
||||
# Recent activity (last 7 days)
|
||||
from datetime import datetime, timedelta
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
recent_sessions = [s for s in all_sessions if s.start_time >= week_ago]
|
||||
|
||||
# Project diversity (if not filtered by project)
|
||||
project_count = 1 if project_id else len(set(s.project_id for s in all_sessions))
|
||||
|
||||
return {
|
||||
"overview": {
|
||||
"total_sessions": total_sessions,
|
||||
"total_time_hours": round(total_time_hours, 1),
|
||||
"average_session_minutes": round(avg_session_minutes, 1),
|
||||
"total_activities": total_activities,
|
||||
"total_conversations": total_conversations,
|
||||
"projects_tracked": project_count,
|
||||
"tracking_period_days": total_days
|
||||
},
|
||||
"recent_activity": {
|
||||
"sessions_last_7_days": len(recent_sessions),
|
||||
"time_last_7_days_hours": round(sum(s.calculated_duration_minutes or 0 for s in recent_sessions) / 60, 1),
|
||||
"daily_average_last_week": round(len(recent_sessions) / 7, 1)
|
||||
},
|
||||
"productivity_indicators": {
|
||||
"activities_per_session": round(total_activities / total_sessions, 1) if total_sessions else 0,
|
||||
"conversations_per_session": round(total_conversations / total_sessions, 1) if total_sessions else 0,
|
||||
"productivity_score": min(100, round((total_activities / total_sessions) * 5, 1)) if total_sessions else 0 # Rough scoring
|
||||
},
|
||||
"time_distribution": {
|
||||
"daily_average_hours": round(total_time_hours / total_days, 1),
|
||||
"longest_session_minutes": max(s.calculated_duration_minutes or 0 for s in all_sessions),
|
||||
"shortest_session_minutes": min(s.calculated_duration_minutes or 0 for s in all_sessions if s.calculated_duration_minutes)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get analytics summary: {str(e)}"
|
||||
)
|
||||
220
app/api/conversations.py
Normal file
220
app/api/conversations.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""
|
||||
Conversation 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, or_, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.connection import get_db
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.session import Session
|
||||
from app.api.schemas import ConversationRequest, ConversationResponse, ConversationSearchResult
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/conversation", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def log_conversation(
|
||||
request: ConversationRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Log a conversation exchange between user and Claude.
|
||||
|
||||
This endpoint is called by Claude Code hooks to capture dialogue.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
# Create conversation entry
|
||||
conversation = Conversation(
|
||||
session_id=request.session_id,
|
||||
timestamp=request.timestamp,
|
||||
user_prompt=request.user_prompt,
|
||||
claude_response=request.claude_response,
|
||||
tools_used=request.tools_used,
|
||||
files_affected=request.files_affected,
|
||||
context=request.context,
|
||||
tokens_input=request.tokens_input,
|
||||
tokens_output=request.tokens_output,
|
||||
exchange_type=request.exchange_type
|
||||
)
|
||||
|
||||
db.add(conversation)
|
||||
|
||||
# Update session conversation count
|
||||
session.add_conversation()
|
||||
|
||||
# Add files to session's touched files list
|
||||
if request.files_affected:
|
||||
for file_path in request.files_affected:
|
||||
session.add_file_touched(file_path)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(conversation)
|
||||
|
||||
return ConversationResponse(
|
||||
id=conversation.id,
|
||||
session_id=conversation.session_id,
|
||||
timestamp=conversation.timestamp,
|
||||
exchange_type=conversation.exchange_type,
|
||||
content_length=conversation.content_length
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to log conversation: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations/search", response_model=List[ConversationSearchResult])
|
||||
async def search_conversations(
|
||||
query: str = Query(..., description="Search query"),
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
limit: int = Query(20, description="Maximum number of results"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search through conversation history.
|
||||
|
||||
Performs text search across user prompts and Claude responses.
|
||||
"""
|
||||
try:
|
||||
# Build search query
|
||||
search_query = select(Conversation).options(
|
||||
selectinload(Conversation.session).selectinload(Session.project)
|
||||
)
|
||||
|
||||
# Add text search conditions
|
||||
search_conditions = []
|
||||
search_terms = query.lower().split()
|
||||
|
||||
for term in search_terms:
|
||||
term_condition = or_(
|
||||
func.lower(Conversation.user_prompt).contains(term),
|
||||
func.lower(Conversation.claude_response).contains(term)
|
||||
)
|
||||
search_conditions.append(term_condition)
|
||||
|
||||
if search_conditions:
|
||||
search_query = search_query.where(or_(*search_conditions))
|
||||
|
||||
# Add project filter if specified
|
||||
if project_id:
|
||||
search_query = search_query.join(Session).where(Session.project_id == project_id)
|
||||
|
||||
# Order by timestamp descending and limit results
|
||||
search_query = search_query.order_by(Conversation.timestamp.desc()).limit(limit)
|
||||
|
||||
result = await db.execute(search_query)
|
||||
conversations = result.scalars().all()
|
||||
|
||||
# Build search results with relevance scoring
|
||||
results = []
|
||||
for conversation in conversations:
|
||||
# Simple relevance scoring based on term matches
|
||||
relevance_score = 0.0
|
||||
content = (conversation.user_prompt or "") + " " + (conversation.claude_response or "")
|
||||
content_lower = content.lower()
|
||||
|
||||
for term in search_terms:
|
||||
relevance_score += content_lower.count(term) / len(search_terms)
|
||||
|
||||
# Normalize score (rough approximation)
|
||||
relevance_score = min(relevance_score / 10, 1.0)
|
||||
|
||||
# Extract context snippets
|
||||
context_snippets = []
|
||||
for term in search_terms:
|
||||
if term in content_lower:
|
||||
start_idx = content_lower.find(term)
|
||||
start = max(0, start_idx - 50)
|
||||
end = min(len(content), start_idx + len(term) + 50)
|
||||
snippet = content[start:end].strip()
|
||||
if snippet and snippet not in context_snippets:
|
||||
context_snippets.append(snippet)
|
||||
|
||||
results.append(ConversationSearchResult(
|
||||
id=conversation.id,
|
||||
project_name=conversation.session.project.name,
|
||||
timestamp=conversation.timestamp,
|
||||
user_prompt=conversation.user_prompt,
|
||||
claude_response=conversation.claude_response,
|
||||
relevance_score=relevance_score,
|
||||
context=context_snippets[:3] # Limit to 3 snippets
|
||||
))
|
||||
|
||||
# Sort by relevance score descending
|
||||
results.sort(key=lambda x: x.relevance_score, reverse=True)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to search conversations: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
async def get_conversation(
|
||||
conversation_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get detailed information about a specific conversation."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Conversation)
|
||||
.options(selectinload(Conversation.session).selectinload(Session.project))
|
||||
.where(Conversation.id == conversation_id)
|
||||
)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Conversation {conversation_id} not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": conversation.id,
|
||||
"session_id": conversation.session_id,
|
||||
"project_name": conversation.session.project.name,
|
||||
"timestamp": conversation.timestamp,
|
||||
"user_prompt": conversation.user_prompt,
|
||||
"claude_response": conversation.claude_response,
|
||||
"tools_used": conversation.tools_used,
|
||||
"files_affected": conversation.files_affected,
|
||||
"context": conversation.context,
|
||||
"exchange_type": conversation.exchange_type,
|
||||
"content_length": conversation.content_length,
|
||||
"estimated_tokens": conversation.estimated_tokens,
|
||||
"intent_category": conversation.get_intent_category(),
|
||||
"complexity_level": conversation.get_complexity_level(),
|
||||
"has_file_operations": conversation.has_file_operations(),
|
||||
"has_code_execution": conversation.has_code_execution()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get conversation: {str(e)}"
|
||||
)
|
||||
360
app/api/git.py
Normal file
360
app/api/git.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
"""
|
||||
Git operation 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.git_operation import GitOperation
|
||||
from app.models.session import Session
|
||||
from app.api.schemas import GitOperationRequest, GitOperationResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/git", response_model=GitOperationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def record_git_operation(
|
||||
request: GitOperationRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Record a git operation performed during development.
|
||||
|
||||
Called by hooks when git commands are executed via Bash tool.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
# Create git operation record
|
||||
git_operation = GitOperation(
|
||||
session_id=request.session_id,
|
||||
timestamp=request.timestamp,
|
||||
operation=request.operation,
|
||||
command=request.command,
|
||||
result=request.result,
|
||||
success=request.success,
|
||||
files_changed=request.files_changed,
|
||||
lines_added=request.lines_added,
|
||||
lines_removed=request.lines_removed,
|
||||
commit_hash=request.commit_hash,
|
||||
branch_from=request.branch_from,
|
||||
branch_to=request.branch_to
|
||||
)
|
||||
|
||||
db.add(git_operation)
|
||||
await db.commit()
|
||||
await db.refresh(git_operation)
|
||||
|
||||
return GitOperationResponse(
|
||||
id=git_operation.id,
|
||||
session_id=git_operation.session_id,
|
||||
operation=git_operation.operation,
|
||||
timestamp=git_operation.timestamp,
|
||||
success=git_operation.success,
|
||||
commit_hash=git_operation.commit_hash
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to record git operation: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/git/operations")
|
||||
async def get_git_operations(
|
||||
session_id: Optional[int] = Query(None, description="Filter by session ID"),
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
operation: Optional[str] = Query(None, description="Filter by operation type"),
|
||||
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 git operations with optional filtering."""
|
||||
try:
|
||||
query = select(GitOperation).options(
|
||||
selectinload(GitOperation.session).selectinload(Session.project)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if session_id:
|
||||
query = query.where(GitOperation.session_id == session_id)
|
||||
elif project_id:
|
||||
query = query.join(Session).where(Session.project_id == project_id)
|
||||
|
||||
if operation:
|
||||
query = query.where(GitOperation.operation == operation)
|
||||
|
||||
# Order by timestamp descending
|
||||
query = query.order_by(GitOperation.timestamp.desc()).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
git_operations = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": op.id,
|
||||
"session_id": op.session_id,
|
||||
"project_name": op.session.project.name,
|
||||
"timestamp": op.timestamp,
|
||||
"operation": op.operation,
|
||||
"command": op.command,
|
||||
"result": op.result,
|
||||
"success": op.success,
|
||||
"files_changed": op.files_changed,
|
||||
"files_count": op.files_count,
|
||||
"lines_added": op.lines_added,
|
||||
"lines_removed": op.lines_removed,
|
||||
"total_lines_changed": op.total_lines_changed,
|
||||
"net_lines_changed": op.net_lines_changed,
|
||||
"commit_hash": op.commit_hash,
|
||||
"commit_message": op.get_commit_message(),
|
||||
"commit_category": op.get_commit_category(),
|
||||
"change_size_category": op.get_change_size_category()
|
||||
}
|
||||
for op in git_operations
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get git operations: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/git/operations/{operation_id}")
|
||||
async def get_git_operation(
|
||||
operation_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get detailed information about a specific git operation."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(GitOperation)
|
||||
.options(selectinload(GitOperation.session).selectinload(Session.project))
|
||||
.where(GitOperation.id == operation_id)
|
||||
)
|
||||
git_operation = result.scalars().first()
|
||||
|
||||
if not git_operation:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Git operation {operation_id} not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": git_operation.id,
|
||||
"session_id": git_operation.session_id,
|
||||
"project_name": git_operation.session.project.name,
|
||||
"timestamp": git_operation.timestamp,
|
||||
"operation": git_operation.operation,
|
||||
"command": git_operation.command,
|
||||
"result": git_operation.result,
|
||||
"success": git_operation.success,
|
||||
"files_changed": git_operation.files_changed,
|
||||
"files_count": git_operation.files_count,
|
||||
"lines_added": git_operation.lines_added,
|
||||
"lines_removed": git_operation.lines_removed,
|
||||
"total_lines_changed": git_operation.total_lines_changed,
|
||||
"net_lines_changed": git_operation.net_lines_changed,
|
||||
"commit_hash": git_operation.commit_hash,
|
||||
"branch_from": git_operation.branch_from,
|
||||
"branch_to": git_operation.branch_to,
|
||||
"commit_message": git_operation.get_commit_message(),
|
||||
"branch_name": git_operation.get_branch_name(),
|
||||
"is_commit": git_operation.is_commit,
|
||||
"is_push": git_operation.is_push,
|
||||
"is_pull": git_operation.is_pull,
|
||||
"is_branch_operation": git_operation.is_branch_operation,
|
||||
"is_merge_commit": git_operation.is_merge_commit(),
|
||||
"is_feature_commit": git_operation.is_feature_commit(),
|
||||
"is_bugfix_commit": git_operation.is_bugfix_commit(),
|
||||
"is_refactor_commit": git_operation.is_refactor_commit(),
|
||||
"commit_category": git_operation.get_commit_category(),
|
||||
"change_size_category": git_operation.get_change_size_category()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get git operation: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/git/stats/commits")
|
||||
async def get_commit_stats(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
days: int = Query(30, description="Number of days to include"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get commit statistics and patterns."""
|
||||
try:
|
||||
# Base query for commits
|
||||
query = select(GitOperation).where(GitOperation.operation == "commit")
|
||||
|
||||
# Apply filters
|
||||
if project_id:
|
||||
query = query.join(Session).where(Session.project_id == project_id)
|
||||
|
||||
if days > 0:
|
||||
from datetime import datetime, timedelta
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
query = query.where(GitOperation.timestamp >= start_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
commits = result.scalars().all()
|
||||
|
||||
if not commits:
|
||||
return {
|
||||
"total_commits": 0,
|
||||
"commit_categories": {},
|
||||
"change_size_distribution": {},
|
||||
"average_lines_per_commit": 0.0,
|
||||
"commit_frequency": []
|
||||
}
|
||||
|
||||
# Calculate statistics
|
||||
total_commits = len(commits)
|
||||
|
||||
# Categorize commits
|
||||
categories = {}
|
||||
for commit in commits:
|
||||
category = commit.get_commit_category()
|
||||
categories[category] = categories.get(category, 0) + 1
|
||||
|
||||
# Change size distribution
|
||||
size_distribution = {}
|
||||
for commit in commits:
|
||||
size_category = commit.get_change_size_category()
|
||||
size_distribution[size_category] = size_distribution.get(size_category, 0) + 1
|
||||
|
||||
# Average lines per commit
|
||||
total_lines = sum(commit.total_lines_changed for commit in commits)
|
||||
avg_lines_per_commit = total_lines / total_commits if total_commits > 0 else 0
|
||||
|
||||
# Daily commit frequency
|
||||
daily_commits = {}
|
||||
for commit in commits:
|
||||
date_key = commit.timestamp.date().isoformat()
|
||||
daily_commits[date_key] = daily_commits.get(date_key, 0) + 1
|
||||
|
||||
commit_frequency = [
|
||||
{"date": date, "commits": count}
|
||||
for date, count in sorted(daily_commits.items())
|
||||
]
|
||||
|
||||
return {
|
||||
"total_commits": total_commits,
|
||||
"commit_categories": categories,
|
||||
"change_size_distribution": size_distribution,
|
||||
"average_lines_per_commit": round(avg_lines_per_commit, 1),
|
||||
"commit_frequency": commit_frequency,
|
||||
"top_commit_messages": [
|
||||
{
|
||||
"message": commit.get_commit_message(),
|
||||
"lines_changed": commit.total_lines_changed,
|
||||
"timestamp": commit.timestamp
|
||||
}
|
||||
for commit in sorted(commits, key=lambda c: c.total_lines_changed, reverse=True)[:5]
|
||||
if commit.get_commit_message()
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get commit stats: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/git/stats/activity")
|
||||
async def get_git_activity_stats(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
days: int = Query(30, description="Number of days to include"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get overall git activity statistics."""
|
||||
try:
|
||||
query = select(GitOperation)
|
||||
|
||||
# Apply filters
|
||||
if project_id:
|
||||
query = query.join(Session).where(Session.project_id == project_id)
|
||||
|
||||
if days > 0:
|
||||
from datetime import datetime, timedelta
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
query = query.where(GitOperation.timestamp >= start_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
operations = result.scalars().all()
|
||||
|
||||
if not operations:
|
||||
return {
|
||||
"total_operations": 0,
|
||||
"operations_by_type": {},
|
||||
"success_rate": 0.0,
|
||||
"most_active_days": []
|
||||
}
|
||||
|
||||
# Operations by type
|
||||
operations_by_type = {}
|
||||
successful_operations = 0
|
||||
|
||||
for op in operations:
|
||||
operations_by_type[op.operation] = operations_by_type.get(op.operation, 0) + 1
|
||||
if op.success:
|
||||
successful_operations += 1
|
||||
|
||||
success_rate = successful_operations / len(operations) * 100 if operations else 0
|
||||
|
||||
# Daily activity
|
||||
daily_activity = {}
|
||||
for op in operations:
|
||||
date_key = op.timestamp.date().isoformat()
|
||||
if date_key not in daily_activity:
|
||||
daily_activity[date_key] = 0
|
||||
daily_activity[date_key] += 1
|
||||
|
||||
most_active_days = [
|
||||
{"date": date, "operations": count}
|
||||
for date, count in sorted(daily_activity.items(), key=lambda x: x[1], reverse=True)[:7]
|
||||
]
|
||||
|
||||
return {
|
||||
"total_operations": len(operations),
|
||||
"operations_by_type": operations_by_type,
|
||||
"success_rate": round(success_rate, 1),
|
||||
"most_active_days": most_active_days,
|
||||
"timeline": [
|
||||
{
|
||||
"date": date,
|
||||
"operations": count
|
||||
}
|
||||
for date, count in sorted(daily_activity.items())
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get git activity stats: {str(e)}"
|
||||
)
|
||||
430
app/api/projects.py
Normal file
430
app/api/projects.py
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
"""
|
||||
Project data retrieval 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.project import Project
|
||||
from app.models.session import Session
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.activity import Activity
|
||||
from app.models.waiting_period import WaitingPeriod
|
||||
from app.models.git_operation import GitOperation
|
||||
from app.api.schemas import ProjectSummary, ProjectTimeline, TimelineEvent
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/projects", response_model=List[ProjectSummary])
|
||||
async def list_projects(
|
||||
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 list of all tracked projects with summary statistics."""
|
||||
try:
|
||||
# Get projects with basic info
|
||||
query = select(Project).order_by(Project.last_session.desc().nullslast()).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
projects = result.scalars().all()
|
||||
|
||||
project_summaries = []
|
||||
|
||||
for project in projects:
|
||||
# Get latest session for last_activity
|
||||
latest_session_result = await db.execute(
|
||||
select(Session.start_time)
|
||||
.where(Session.project_id == project.id)
|
||||
.order_by(Session.start_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_session = latest_session_result.scalars().first()
|
||||
|
||||
project_summaries.append(ProjectSummary(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=project.path,
|
||||
git_repo=project.git_repo,
|
||||
languages=project.languages,
|
||||
total_sessions=project.total_sessions,
|
||||
total_time_minutes=project.total_time_minutes,
|
||||
last_activity=latest_session or project.created_at,
|
||||
files_modified_count=project.files_modified_count,
|
||||
lines_changed_count=project.lines_changed_count
|
||||
))
|
||||
|
||||
return project_summaries
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to list projects: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ProjectSummary)
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get detailed information about a specific project."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project {project_id} not found"
|
||||
)
|
||||
|
||||
# Get latest session for last_activity
|
||||
latest_session_result = await db.execute(
|
||||
select(Session.start_time)
|
||||
.where(Session.project_id == project.id)
|
||||
.order_by(Session.start_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_session = latest_session_result.scalars().first()
|
||||
|
||||
return ProjectSummary(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=project.path,
|
||||
git_repo=project.git_repo,
|
||||
languages=project.languages,
|
||||
total_sessions=project.total_sessions,
|
||||
total_time_minutes=project.total_time_minutes,
|
||||
last_activity=latest_session or project.created_at,
|
||||
files_modified_count=project.files_modified_count,
|
||||
lines_changed_count=project.lines_changed_count
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get project: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/timeline", response_model=ProjectTimeline)
|
||||
async def get_project_timeline(
|
||||
project_id: int,
|
||||
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
|
||||
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get chronological timeline of project development."""
|
||||
try:
|
||||
# Get project info
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = project_result.scalars().first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project {project_id} not found"
|
||||
)
|
||||
|
||||
# Parse date filters
|
||||
date_filters = []
|
||||
if start_date:
|
||||
from datetime import datetime
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
date_filters.append(lambda table: table.timestamp >= start_dt if hasattr(table, 'timestamp') else table.start_time >= start_dt)
|
||||
|
||||
if end_date:
|
||||
from datetime import datetime
|
||||
end_dt = datetime.fromisoformat(end_date + " 23:59:59")
|
||||
date_filters.append(lambda table: table.timestamp <= end_dt if hasattr(table, 'timestamp') else table.start_time <= end_dt)
|
||||
|
||||
timeline_events = []
|
||||
|
||||
# Get sessions for this project
|
||||
session_query = select(Session).where(Session.project_id == project_id)
|
||||
session_result = await db.execute(session_query)
|
||||
sessions = session_result.scalars().all()
|
||||
session_ids = [s.id for s in sessions]
|
||||
|
||||
# Session start/end events
|
||||
for session in sessions:
|
||||
# Session start
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=session.start_time,
|
||||
type="session_start",
|
||||
data={
|
||||
"session_id": session.id,
|
||||
"session_type": session.session_type,
|
||||
"git_branch": session.git_branch,
|
||||
"working_directory": session.working_directory
|
||||
}
|
||||
))
|
||||
|
||||
# Session end (if ended)
|
||||
if session.end_time:
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=session.end_time,
|
||||
type="session_end",
|
||||
data={
|
||||
"session_id": session.id,
|
||||
"duration_minutes": session.duration_minutes,
|
||||
"activity_count": session.activity_count,
|
||||
"conversation_count": session.conversation_count
|
||||
}
|
||||
))
|
||||
|
||||
if session_ids:
|
||||
# Get conversations
|
||||
conv_query = select(Conversation).where(Conversation.session_id.in_(session_ids))
|
||||
if date_filters:
|
||||
for date_filter in date_filters:
|
||||
conv_query = conv_query.where(date_filter(Conversation))
|
||||
|
||||
conv_result = await db.execute(conv_query)
|
||||
conversations = conv_result.scalars().all()
|
||||
|
||||
for conv in conversations:
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=conv.timestamp,
|
||||
type="conversation",
|
||||
data={
|
||||
"id": conv.id,
|
||||
"session_id": conv.session_id,
|
||||
"exchange_type": conv.exchange_type,
|
||||
"user_prompt": conv.user_prompt[:100] + "..." if conv.user_prompt and len(conv.user_prompt) > 100 else conv.user_prompt,
|
||||
"tools_used": conv.tools_used,
|
||||
"files_affected": conv.files_affected
|
||||
}
|
||||
))
|
||||
|
||||
# Get activities
|
||||
activity_query = select(Activity).where(Activity.session_id.in_(session_ids))
|
||||
if date_filters:
|
||||
for date_filter in date_filters:
|
||||
activity_query = activity_query.where(date_filter(Activity))
|
||||
|
||||
activity_result = await db.execute(activity_query)
|
||||
activities = activity_result.scalars().all()
|
||||
|
||||
for activity in activities:
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=activity.timestamp,
|
||||
type="activity",
|
||||
data={
|
||||
"id": activity.id,
|
||||
"session_id": activity.session_id,
|
||||
"tool_name": activity.tool_name,
|
||||
"action": activity.action,
|
||||
"file_path": activity.file_path,
|
||||
"success": activity.success,
|
||||
"lines_changed": activity.total_lines_changed
|
||||
}
|
||||
))
|
||||
|
||||
# Get git operations
|
||||
git_query = select(GitOperation).where(GitOperation.session_id.in_(session_ids))
|
||||
if date_filters:
|
||||
for date_filter in date_filters:
|
||||
git_query = git_query.where(date_filter(GitOperation))
|
||||
|
||||
git_result = await db.execute(git_query)
|
||||
git_operations = git_result.scalars().all()
|
||||
|
||||
for git_op in git_operations:
|
||||
timeline_events.append(TimelineEvent(
|
||||
timestamp=git_op.timestamp,
|
||||
type="git_operation",
|
||||
data={
|
||||
"id": git_op.id,
|
||||
"session_id": git_op.session_id,
|
||||
"operation": git_op.operation,
|
||||
"commit_hash": git_op.commit_hash,
|
||||
"commit_message": git_op.get_commit_message(),
|
||||
"files_changed": git_op.files_count,
|
||||
"lines_changed": git_op.total_lines_changed
|
||||
}
|
||||
))
|
||||
|
||||
# Sort timeline by timestamp
|
||||
timeline_events.sort(key=lambda x: x.timestamp)
|
||||
|
||||
# Create project summary for response
|
||||
latest_session_result = await db.execute(
|
||||
select(Session.start_time)
|
||||
.where(Session.project_id == project.id)
|
||||
.order_by(Session.start_time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
latest_session = latest_session_result.scalars().first()
|
||||
|
||||
project_summary = ProjectSummary(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
path=project.path,
|
||||
git_repo=project.git_repo,
|
||||
languages=project.languages,
|
||||
total_sessions=project.total_sessions,
|
||||
total_time_minutes=project.total_time_minutes,
|
||||
last_activity=latest_session or project.created_at,
|
||||
files_modified_count=project.files_modified_count,
|
||||
lines_changed_count=project.lines_changed_count
|
||||
)
|
||||
|
||||
return ProjectTimeline(
|
||||
project=project_summary,
|
||||
timeline=timeline_events
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get project timeline: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/stats")
|
||||
async def get_project_stats(
|
||||
project_id: int,
|
||||
days: int = Query(30, description="Number of days to include in statistics"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get comprehensive statistics for a project."""
|
||||
try:
|
||||
# Verify project exists
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id)
|
||||
)
|
||||
project = project_result.scalars().first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Project {project_id} not found"
|
||||
)
|
||||
|
||||
# Date filter for recent activity
|
||||
from datetime import datetime, timedelta
|
||||
start_date = datetime.utcnow() - timedelta(days=days) if days > 0 else None
|
||||
|
||||
# Get sessions
|
||||
session_query = select(Session).where(Session.project_id == project_id)
|
||||
if start_date:
|
||||
session_query = session_query.where(Session.start_time >= start_date)
|
||||
|
||||
session_result = await db.execute(session_query)
|
||||
sessions = session_result.scalars().all()
|
||||
session_ids = [s.id for s in sessions] if sessions else []
|
||||
|
||||
# Calculate session statistics
|
||||
total_sessions = len(sessions)
|
||||
total_time = sum(s.calculated_duration_minutes or 0 for s in sessions)
|
||||
avg_session_length = total_time / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
# Activity statistics
|
||||
activity_stats = {"total": 0, "by_tool": {}, "by_language": {}}
|
||||
if session_ids:
|
||||
activity_query = select(Activity).where(Activity.session_id.in_(session_ids))
|
||||
activity_result = await db.execute(activity_query)
|
||||
activities = activity_result.scalars().all()
|
||||
|
||||
activity_stats["total"] = len(activities)
|
||||
|
||||
for activity in activities:
|
||||
# Tool usage
|
||||
tool = activity.tool_name
|
||||
activity_stats["by_tool"][tool] = activity_stats["by_tool"].get(tool, 0) + 1
|
||||
|
||||
# Language usage
|
||||
lang = activity.get_programming_language()
|
||||
if lang:
|
||||
activity_stats["by_language"][lang] = activity_stats["by_language"].get(lang, 0) + 1
|
||||
|
||||
# Conversation statistics
|
||||
conversation_stats = {"total": 0, "by_type": {}}
|
||||
if session_ids:
|
||||
conv_query = select(Conversation).where(Conversation.session_id.in_(session_ids))
|
||||
conv_result = await db.execute(conv_query)
|
||||
conversations = conv_result.scalars().all()
|
||||
|
||||
conversation_stats["total"] = len(conversations)
|
||||
|
||||
for conv in conversations:
|
||||
conv_type = conv.exchange_type
|
||||
conversation_stats["by_type"][conv_type] = conversation_stats["by_type"].get(conv_type, 0) + 1
|
||||
|
||||
# Git statistics
|
||||
git_stats = {"total": 0, "commits": 0, "by_operation": {}}
|
||||
if session_ids:
|
||||
git_query = select(GitOperation).where(GitOperation.session_id.in_(session_ids))
|
||||
git_result = await db.execute(git_query)
|
||||
git_operations = git_result.scalars().all()
|
||||
|
||||
git_stats["total"] = len(git_operations)
|
||||
|
||||
for git_op in git_operations:
|
||||
if git_op.is_commit:
|
||||
git_stats["commits"] += 1
|
||||
|
||||
op_type = git_op.operation
|
||||
git_stats["by_operation"][op_type] = git_stats["by_operation"].get(op_type, 0) + 1
|
||||
|
||||
# Productivity trends (daily aggregation)
|
||||
daily_stats = {}
|
||||
for session in sessions:
|
||||
date_key = session.start_time.date().isoformat()
|
||||
if date_key not in daily_stats:
|
||||
daily_stats[date_key] = {
|
||||
"date": date_key,
|
||||
"sessions": 0,
|
||||
"time_minutes": 0,
|
||||
"activities": 0
|
||||
}
|
||||
|
||||
daily_stats[date_key]["sessions"] += 1
|
||||
daily_stats[date_key]["time_minutes"] += session.calculated_duration_minutes or 0
|
||||
daily_stats[date_key]["activities"] += session.activity_count
|
||||
|
||||
productivity_trends = list(daily_stats.values())
|
||||
productivity_trends.sort(key=lambda x: x["date"])
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"project_name": project.name,
|
||||
"time_period_days": days,
|
||||
"session_statistics": {
|
||||
"total_sessions": total_sessions,
|
||||
"total_time_minutes": total_time,
|
||||
"average_session_length_minutes": round(avg_session_length, 1)
|
||||
},
|
||||
"activity_statistics": activity_stats,
|
||||
"conversation_statistics": conversation_stats,
|
||||
"git_statistics": git_stats,
|
||||
"productivity_trends": productivity_trends,
|
||||
"summary": {
|
||||
"most_used_tool": max(activity_stats["by_tool"].items(), key=lambda x: x[1])[0] if activity_stats["by_tool"] else None,
|
||||
"primary_language": max(activity_stats["by_language"].items(), key=lambda x: x[1])[0] if activity_stats["by_language"] else None,
|
||||
"daily_average_time": round(total_time / days, 1) if days > 0 else 0,
|
||||
"daily_average_sessions": round(total_sessions / days, 1) if days > 0 else 0
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get project stats: {str(e)}"
|
||||
)
|
||||
220
app/api/schemas.py
Normal file
220
app/api/schemas.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""
|
||||
Pydantic schemas for API request/response models.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any, Union
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# Base schemas
|
||||
class TimestampMixin(BaseModel):
|
||||
"""Mixin for timestamp fields."""
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Session schemas
|
||||
class SessionStartRequest(BaseModel):
|
||||
"""Request schema for starting a session."""
|
||||
session_type: str = Field(..., description="Type of session: startup, resume, or clear")
|
||||
working_directory: str = Field(..., description="Current working directory")
|
||||
git_branch: Optional[str] = Field(None, description="Current git branch")
|
||||
git_repo: Optional[str] = Field(None, description="Git repository URL")
|
||||
environment: Optional[Dict[str, Any]] = Field(None, description="Environment variables and context")
|
||||
|
||||
|
||||
class SessionEndRequest(BaseModel):
|
||||
"""Request schema for ending a session."""
|
||||
session_id: int = Field(..., description="ID of the session to end")
|
||||
end_reason: str = Field(default="normal", description="Reason for ending: normal, interrupted, or timeout")
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
"""Response schema for session operations."""
|
||||
session_id: int
|
||||
project_id: int
|
||||
status: str
|
||||
message: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Conversation schemas
|
||||
class ConversationRequest(BaseModel):
|
||||
"""Request schema for logging conversations."""
|
||||
session_id: int = Field(..., description="Associated session ID")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the exchange occurred")
|
||||
user_prompt: Optional[str] = Field(None, description="User's input message")
|
||||
claude_response: Optional[str] = Field(None, description="Claude's response")
|
||||
tools_used: Optional[List[str]] = Field(None, description="Tools used in the response")
|
||||
files_affected: Optional[List[str]] = Field(None, description="Files mentioned or modified")
|
||||
context: Optional[Dict[str, Any]] = Field(None, description="Additional context")
|
||||
tokens_input: Optional[int] = Field(None, description="Estimated input token count")
|
||||
tokens_output: Optional[int] = Field(None, description="Estimated output token count")
|
||||
exchange_type: str = Field(..., description="Type of exchange: user_prompt or claude_response")
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
"""Response schema for conversation operations."""
|
||||
id: int
|
||||
session_id: int
|
||||
timestamp: datetime
|
||||
exchange_type: str
|
||||
content_length: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Activity schemas
|
||||
class ActivityRequest(BaseModel):
|
||||
"""Request schema for recording activities."""
|
||||
session_id: int = Field(..., description="Associated session ID")
|
||||
conversation_id: Optional[int] = Field(None, description="Associated conversation ID")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the activity occurred")
|
||||
tool_name: str = Field(..., description="Name of the tool used")
|
||||
action: str = Field(..., description="Specific action taken")
|
||||
file_path: Optional[str] = Field(None, description="Target file path if applicable")
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Tool-specific metadata")
|
||||
success: bool = Field(default=True, description="Whether the operation succeeded")
|
||||
error_message: Optional[str] = Field(None, description="Error details if failed")
|
||||
lines_added: Optional[int] = Field(None, description="Lines added for Edit/Write operations")
|
||||
lines_removed: Optional[int] = Field(None, description="Lines removed for Edit operations")
|
||||
|
||||
|
||||
class ActivityResponse(BaseModel):
|
||||
"""Response schema for activity operations."""
|
||||
id: int
|
||||
session_id: int
|
||||
tool_name: str
|
||||
action: str
|
||||
timestamp: datetime
|
||||
success: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Waiting period schemas
|
||||
class WaitingStartRequest(BaseModel):
|
||||
"""Request schema for starting a waiting period."""
|
||||
session_id: int = Field(..., description="Associated session ID")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When waiting started")
|
||||
context_before: Optional[str] = Field(None, description="Context before waiting")
|
||||
|
||||
|
||||
class WaitingEndRequest(BaseModel):
|
||||
"""Request schema for ending a waiting period."""
|
||||
session_id: int = Field(..., description="Associated session ID")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When waiting ended")
|
||||
duration_seconds: Optional[int] = Field(None, description="Total waiting duration")
|
||||
context_after: Optional[str] = Field(None, description="Context after waiting")
|
||||
|
||||
|
||||
class WaitingPeriodResponse(BaseModel):
|
||||
"""Response schema for waiting period operations."""
|
||||
id: int
|
||||
session_id: int
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime]
|
||||
duration_seconds: Optional[int]
|
||||
engagement_score: float
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Git operation schemas
|
||||
class GitOperationRequest(BaseModel):
|
||||
"""Request schema for git operations."""
|
||||
session_id: int = Field(..., description="Associated session ID")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow, description="When the operation occurred")
|
||||
operation: str = Field(..., description="Type of git operation")
|
||||
command: str = Field(..., description="Full git command executed")
|
||||
result: Optional[str] = Field(None, description="Command output")
|
||||
success: bool = Field(default=True, description="Whether the command succeeded")
|
||||
files_changed: Optional[List[str]] = Field(None, description="Files affected by the operation")
|
||||
lines_added: Optional[int] = Field(None, description="Lines added")
|
||||
lines_removed: Optional[int] = Field(None, description="Lines removed")
|
||||
commit_hash: Optional[str] = Field(None, description="Git commit SHA")
|
||||
branch_from: Optional[str] = Field(None, description="Source branch")
|
||||
branch_to: Optional[str] = Field(None, description="Target branch")
|
||||
|
||||
|
||||
class GitOperationResponse(BaseModel):
|
||||
"""Response schema for git operations."""
|
||||
id: int
|
||||
session_id: int
|
||||
operation: str
|
||||
timestamp: datetime
|
||||
success: bool
|
||||
commit_hash: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Project schemas
|
||||
class ProjectSummary(BaseModel):
|
||||
"""Summary information about a project."""
|
||||
id: int
|
||||
name: str
|
||||
path: str
|
||||
git_repo: Optional[str]
|
||||
languages: Optional[List[str]]
|
||||
total_sessions: int
|
||||
total_time_minutes: int
|
||||
last_activity: Optional[datetime]
|
||||
files_modified_count: int
|
||||
lines_changed_count: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TimelineEvent(BaseModel):
|
||||
"""Individual event in project timeline."""
|
||||
timestamp: datetime
|
||||
type: str # session_start, session_end, conversation, activity, git_operation
|
||||
data: Dict[str, Any]
|
||||
|
||||
|
||||
class ProjectTimeline(BaseModel):
|
||||
"""Project timeline with events."""
|
||||
project: ProjectSummary
|
||||
timeline: List[TimelineEvent]
|
||||
|
||||
|
||||
# Analytics schemas
|
||||
class ProductivityMetrics(BaseModel):
|
||||
"""Productivity analytics response."""
|
||||
engagement_score: float = Field(..., description="Overall engagement level (0-100)")
|
||||
average_session_length: float = Field(..., description="Minutes per session")
|
||||
think_time_average: float = Field(..., description="Average waiting time between interactions")
|
||||
files_per_session: float = Field(..., description="Average files touched per session")
|
||||
tools_most_used: List[Dict[str, Union[str, int]]] = Field(..., description="Most frequently used tools")
|
||||
productivity_trends: List[Dict[str, Union[str, float]]] = Field(..., description="Daily productivity scores")
|
||||
|
||||
|
||||
class ConversationSearchResult(BaseModel):
|
||||
"""Search result for conversation search."""
|
||||
id: int
|
||||
project_name: str
|
||||
timestamp: datetime
|
||||
user_prompt: Optional[str]
|
||||
claude_response: Optional[str]
|
||||
relevance_score: float
|
||||
context: List[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Error schemas
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response."""
|
||||
error: str
|
||||
message: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
255
app/api/sessions.py
Normal file
255
app/api/sessions.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"""
|
||||
Session management API endpoints.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.connection import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.session import Session
|
||||
from app.api.schemas import SessionStartRequest, SessionEndRequest, SessionResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def get_or_create_project(
|
||||
db: AsyncSession,
|
||||
working_directory: str,
|
||||
git_repo: Optional[str] = None
|
||||
) -> Project:
|
||||
"""Get existing project or create a new one based on working directory."""
|
||||
|
||||
# Try to find existing project by path
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.path == working_directory)
|
||||
)
|
||||
project = result.scalars().first()
|
||||
|
||||
if project:
|
||||
return project
|
||||
|
||||
# Create new project
|
||||
project_name = os.path.basename(working_directory) or "Unknown Project"
|
||||
|
||||
# Try to infer languages from directory (simple heuristic)
|
||||
languages = []
|
||||
try:
|
||||
for root, dirs, files in os.walk(working_directory):
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext == ".py":
|
||||
languages.append("python")
|
||||
elif ext in [".js", ".jsx"]:
|
||||
languages.append("javascript")
|
||||
elif ext in [".ts", ".tsx"]:
|
||||
languages.append("typescript")
|
||||
elif ext == ".go":
|
||||
languages.append("go")
|
||||
elif ext == ".rs":
|
||||
languages.append("rust")
|
||||
elif ext == ".java":
|
||||
languages.append("java")
|
||||
elif ext in [".cpp", ".cc", ".cxx"]:
|
||||
languages.append("cpp")
|
||||
elif ext == ".c":
|
||||
languages.append("c")
|
||||
# Don't traverse too deep
|
||||
if len(root.replace(working_directory, "").split(os.sep)) > 2:
|
||||
break
|
||||
|
||||
languages = list(set(languages))[:5] # Keep unique, limit to 5
|
||||
except (OSError, PermissionError):
|
||||
# If we can't read the directory, that's okay
|
||||
pass
|
||||
|
||||
project = Project(
|
||||
name=project_name,
|
||||
path=working_directory,
|
||||
git_repo=git_repo,
|
||||
languages=languages if languages else None
|
||||
)
|
||||
|
||||
db.add(project)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/session/start", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def start_session(
|
||||
request: SessionStartRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Start a new development session.
|
||||
|
||||
This endpoint is called by Claude Code hooks when a session begins.
|
||||
It will create a project if one doesn't exist for the working directory.
|
||||
"""
|
||||
try:
|
||||
# Get or create project
|
||||
project = await get_or_create_project(
|
||||
db=db,
|
||||
working_directory=request.working_directory,
|
||||
git_repo=request.git_repo
|
||||
)
|
||||
|
||||
# Create new session
|
||||
session = Session(
|
||||
project_id=project.id,
|
||||
session_type=request.session_type,
|
||||
working_directory=request.working_directory,
|
||||
git_branch=request.git_branch,
|
||||
environment=request.environment
|
||||
)
|
||||
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
# Store session ID in temp file for hooks to use
|
||||
session_file = "/tmp/claude-session-id"
|
||||
try:
|
||||
with open(session_file, "w") as f:
|
||||
f.write(str(session.id))
|
||||
except OSError:
|
||||
# If we can't write the session file, log but don't fail
|
||||
pass
|
||||
|
||||
return SessionResponse(
|
||||
session_id=session.id,
|
||||
project_id=project.id,
|
||||
status="started",
|
||||
message=f"Session started for project '{project.name}'"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to start session: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/session/end", response_model=SessionResponse)
|
||||
async def end_session(
|
||||
request: SessionEndRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
End an active development session.
|
||||
|
||||
This endpoint calculates final session statistics and updates
|
||||
project-level metrics.
|
||||
"""
|
||||
try:
|
||||
# Find the session
|
||||
result = await db.execute(
|
||||
select(Session)
|
||||
.options(selectinload(Session.project))
|
||||
.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"
|
||||
)
|
||||
|
||||
if not session.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Session is already ended"
|
||||
)
|
||||
|
||||
# End the session
|
||||
session.end_session(end_reason=request.end_reason)
|
||||
await db.commit()
|
||||
|
||||
# Clean up session ID file
|
||||
session_file = "/tmp/claude-session-id"
|
||||
try:
|
||||
if os.path.exists(session_file):
|
||||
os.remove(session_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return SessionResponse(
|
||||
session_id=session.id,
|
||||
project_id=session.project_id,
|
||||
status="ended",
|
||||
message=f"Session ended after {session.duration_minutes} minutes"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to end session: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=dict)
|
||||
async def get_session(
|
||||
session_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get detailed information about a specific session."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Session)
|
||||
.options(
|
||||
selectinload(Session.project),
|
||||
selectinload(Session.conversations),
|
||||
selectinload(Session.activities),
|
||||
selectinload(Session.waiting_periods),
|
||||
selectinload(Session.git_operations)
|
||||
)
|
||||
.where(Session.id == session_id)
|
||||
)
|
||||
session = result.scalars().first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": session.id,
|
||||
"project": {
|
||||
"id": session.project.id,
|
||||
"name": session.project.name,
|
||||
"path": session.project.path
|
||||
},
|
||||
"start_time": session.start_time,
|
||||
"end_time": session.end_time,
|
||||
"duration_minutes": session.calculated_duration_minutes,
|
||||
"session_type": session.session_type,
|
||||
"git_branch": session.git_branch,
|
||||
"activity_count": session.activity_count,
|
||||
"conversation_count": session.conversation_count,
|
||||
"is_active": session.is_active,
|
||||
"statistics": {
|
||||
"conversations": len(session.conversations),
|
||||
"activities": len(session.activities),
|
||||
"waiting_periods": len(session.waiting_periods),
|
||||
"git_operations": len(session.git_operations),
|
||||
"files_touched": len(session.files_touched or [])
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get session: {str(e)}"
|
||||
)
|
||||
285
app/api/waiting.py
Normal file
285
app/api/waiting.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""
|
||||
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)}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue