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
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)}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue