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:
Ryan Malloy 2025-08-11 02:59:21 -06:00
commit 44ed9936b7
48 changed files with 9707 additions and 0 deletions

3
app/api/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""
API modules for the Claude Code Project Tracker.
"""

304
app/api/activities.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)}"
)