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