claude-code-tracker/app/api/conversations.py
Ryan Malloy 44ed9936b7 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>
2025-08-11 02:59:21 -06:00

220 lines
8.0 KiB
Python

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