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

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)}"
)