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>
220 lines
8.0 KiB
Python
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)}"
|
|
) |