claude-code-tracker/app/api/conversations.py
Ryan Malloy bec1606c86 Add comprehensive documentation system and tool call tracking
## Documentation System
- Create complete documentation hub at /dashboard/docs with:
  - Getting Started guide with quick setup and troubleshooting
  - Hook Setup Guide with platform-specific configurations
  - API Reference with all endpoints and examples
  - FAQ with searchable questions and categories
- Add responsive design with interactive features
- Update navigation in base template

## Tool Call Tracking
- Add ToolCall model for tracking Claude Code tool usage
- Create /api/tool-calls endpoints for recording and analytics
- Add tool_call hook type with auto-session detection
- Include tool calls in project statistics and recalculation
- Track tool names, parameters, execution time, and success rates

## Project Enhancements
- Add project timeline and statistics pages (fix 404 errors)
- Create recalculation script for fixing zero statistics
- Update project stats to include tool call counts
- Enhance session model with tool call relationships

## Infrastructure
- Switch from requirements.txt to pyproject.toml/uv.lock
- Add data import functionality for claude.json files
- Update database connection to include all new models
- Add comprehensive API documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 05:58:27 -06:00

268 lines
9.9 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", response_model=List[ConversationSearchResult])
async def get_conversations(
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 recent conversations with optional project filtering.
"""
try:
# Build query
query = select(Conversation).options(
selectinload(Conversation.session).selectinload(Session.project)
)
# Add project filter if specified
if project_id:
query = query.join(Session).where(Session.project_id == project_id)
# Order by timestamp descending, add pagination
query = query.order_by(Conversation.timestamp.desc()).offset(offset).limit(limit)
result = await db.execute(query)
conversations = result.scalars().all()
# Convert to response format
results = []
for conversation in conversations:
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=1.0, # All results are equally relevant when just listing
context=[] # No context snippets needed for listing
))
return results
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get conversations: {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)}"
)