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>
This commit is contained in:
parent
166247bf70
commit
bec1606c86
27 changed files with 6150 additions and 32 deletions
|
|
@ -83,6 +83,54 @@ async def log_conversation(
|
|||
)
|
||||
|
||||
|
||||
@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"),
|
||||
|
|
|
|||
390
app/api/importer.py
Normal file
390
app/api/importer.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
"""
|
||||
Data importer for Claude Code .claude.json file.
|
||||
|
||||
This module provides functionality to import historical data from the
|
||||
.claude.json configuration file into the project tracker.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.connection import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.session import Session
|
||||
from app.models.conversation import Conversation
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ClaudeJsonImporter:
|
||||
"""Importer for .claude.json data."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def import_from_file(self, file_path: str) -> Dict[str, Any]:
|
||||
"""Import data from .claude.json file."""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"Claude configuration file not found: {file_path}")
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
claude_data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in Claude configuration file: {e}")
|
||||
|
||||
results = {
|
||||
"projects_imported": 0,
|
||||
"sessions_estimated": 0,
|
||||
"conversations_imported": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
# Import basic usage statistics
|
||||
await self._import_usage_stats(claude_data, results)
|
||||
|
||||
# Import projects and their history
|
||||
if "projects" in claude_data:
|
||||
await self._import_projects(claude_data["projects"], results)
|
||||
|
||||
return results
|
||||
|
||||
async def _import_usage_stats(self, claude_data: Dict[str, Any], results: Dict[str, Any]):
|
||||
"""Import basic usage statistics."""
|
||||
# We could create a synthetic "Claude Code Usage" project to track overall stats
|
||||
if claude_data.get("numStartups") and claude_data.get("firstStartTime"):
|
||||
try:
|
||||
first_start = datetime.fromisoformat(
|
||||
claude_data["firstStartTime"].replace('Z', '+00:00')
|
||||
)
|
||||
|
||||
# Create a synthetic project for overall Claude Code usage
|
||||
usage_project = await self._get_or_create_project(
|
||||
name="Claude Code Usage Statistics",
|
||||
path="<system>",
|
||||
description="Imported usage statistics from .claude.json"
|
||||
)
|
||||
|
||||
# Estimate session distribution over time
|
||||
num_startups = claude_data["numStartups"]
|
||||
days_since_first = (datetime.now() - first_start.replace(tzinfo=None)).days
|
||||
|
||||
if days_since_first > 0:
|
||||
# Create estimated sessions spread over the usage period
|
||||
await self._create_estimated_sessions(
|
||||
usage_project,
|
||||
first_start.replace(tzinfo=None),
|
||||
num_startups,
|
||||
days_since_first
|
||||
)
|
||||
results["sessions_estimated"] = num_startups
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Failed to import usage stats: {e}")
|
||||
|
||||
async def _import_projects(self, projects_data: Dict[str, Any], results: Dict[str, Any]):
|
||||
"""Import project data from .claude.json."""
|
||||
for project_path, project_info in projects_data.items():
|
||||
try:
|
||||
# Skip system paths or non-meaningful paths
|
||||
if project_path in ["<system>", "/", "/tmp"]:
|
||||
continue
|
||||
|
||||
# Extract project name from path
|
||||
project_name = Path(project_path).name or "Unknown Project"
|
||||
|
||||
# Create or get existing project
|
||||
project = await self._get_or_create_project(
|
||||
name=project_name,
|
||||
path=project_path
|
||||
)
|
||||
|
||||
results["projects_imported"] += 1
|
||||
|
||||
# Import conversation history if available
|
||||
if "history" in project_info and isinstance(project_info["history"], list):
|
||||
conversation_count = await self._import_project_history(
|
||||
project,
|
||||
project_info["history"]
|
||||
)
|
||||
results["conversations_imported"] += conversation_count
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append(f"Failed to import project {project_path}: {e}")
|
||||
|
||||
async def _get_or_create_project(
|
||||
self,
|
||||
name: str,
|
||||
path: str,
|
||||
description: Optional[str] = None
|
||||
) -> Project:
|
||||
"""Get existing project or create new one."""
|
||||
# Check if project already exists
|
||||
result = await self.db.execute(
|
||||
select(Project).where(Project.path == path)
|
||||
)
|
||||
existing_project = result.scalars().first()
|
||||
|
||||
if existing_project:
|
||||
return existing_project
|
||||
|
||||
# Try to detect languages from path
|
||||
languages = self._detect_languages(path)
|
||||
|
||||
# Create new project
|
||||
project = Project(
|
||||
name=name,
|
||||
path=path,
|
||||
languages=languages
|
||||
)
|
||||
|
||||
self.db.add(project)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(project)
|
||||
|
||||
return project
|
||||
|
||||
def _detect_languages(self, project_path: str) -> Optional[List[str]]:
|
||||
"""Attempt to detect programming languages from project directory."""
|
||||
languages = []
|
||||
|
||||
try:
|
||||
if os.path.exists(project_path) and os.path.isdir(project_path):
|
||||
# Look for common files to infer languages
|
||||
files = os.listdir(project_path)
|
||||
|
||||
# Python
|
||||
if any(f.endswith(('.py', '.pyx', '.pyi')) for f in files) or 'requirements.txt' in files:
|
||||
languages.append('python')
|
||||
|
||||
# JavaScript/TypeScript
|
||||
if any(f.endswith(('.js', '.jsx', '.ts', '.tsx')) for f in files) or 'package.json' in files:
|
||||
if any(f.endswith(('.ts', '.tsx')) for f in files):
|
||||
languages.append('typescript')
|
||||
else:
|
||||
languages.append('javascript')
|
||||
|
||||
# Go
|
||||
if any(f.endswith('.go') for f in files) or 'go.mod' in files:
|
||||
languages.append('go')
|
||||
|
||||
# Rust
|
||||
if any(f.endswith('.rs') for f in files) or 'Cargo.toml' in files:
|
||||
languages.append('rust')
|
||||
|
||||
# Java
|
||||
if any(f.endswith('.java') for f in files) or 'pom.xml' in files:
|
||||
languages.append('java')
|
||||
|
||||
except (OSError, PermissionError):
|
||||
# If we can't read the directory, that's okay
|
||||
pass
|
||||
|
||||
return languages if languages else None
|
||||
|
||||
async def _create_estimated_sessions(
|
||||
self,
|
||||
project: Project,
|
||||
first_start: datetime,
|
||||
num_startups: int,
|
||||
days_since_first: int
|
||||
):
|
||||
"""Create estimated sessions based on startup count."""
|
||||
# Check if we already have sessions for this project
|
||||
existing_sessions = await self.db.execute(
|
||||
select(Session).where(
|
||||
Session.project_id == project.id,
|
||||
Session.session_type == "startup"
|
||||
)
|
||||
)
|
||||
if existing_sessions.scalars().first():
|
||||
return # Sessions already exist, skip creation
|
||||
|
||||
# Don't create too many sessions - limit to reasonable estimates
|
||||
max_sessions = min(num_startups, 50) # Cap at 50 sessions
|
||||
|
||||
# Distribute sessions over the time period
|
||||
if days_since_first > 0:
|
||||
sessions_per_day = max_sessions / days_since_first
|
||||
|
||||
for i in range(max_sessions):
|
||||
# Spread sessions over the time period
|
||||
days_offset = int(i / sessions_per_day) if sessions_per_day > 0 else i
|
||||
session_time = first_start + timedelta(days=days_offset)
|
||||
|
||||
# Estimate session duration (30-180 minutes)
|
||||
import random
|
||||
duration = random.randint(30, 180)
|
||||
|
||||
session = Session(
|
||||
project_id=project.id,
|
||||
start_time=session_time,
|
||||
end_time=session_time + timedelta(minutes=duration),
|
||||
session_type="startup",
|
||||
working_directory=project.path,
|
||||
duration_minutes=duration,
|
||||
activity_count=random.randint(5, 25), # Estimated activity
|
||||
conversation_count=random.randint(2, 8) # Estimated conversations
|
||||
)
|
||||
|
||||
self.db.add(session)
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
async def _import_project_history(
|
||||
self,
|
||||
project: Project,
|
||||
history: List[Dict[str, Any]]
|
||||
) -> int:
|
||||
"""Import conversation history for a project."""
|
||||
# Check if we already have history conversations for this project
|
||||
existing_conversations = await self.db.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.context.like('%"imported_from": ".claude.json"%'),
|
||||
Conversation.session.has(Session.project_id == project.id)
|
||||
)
|
||||
)
|
||||
if existing_conversations.scalars().first():
|
||||
return 0 # History already imported, skip
|
||||
|
||||
conversation_count = 0
|
||||
|
||||
# Create a synthetic session for imported history
|
||||
history_session = Session(
|
||||
project_id=project.id,
|
||||
start_time=datetime.now() - timedelta(days=30), # Assume recent
|
||||
session_type="history_import", # Different type to avoid conflicts
|
||||
working_directory=project.path,
|
||||
activity_count=len(history),
|
||||
conversation_count=len(history)
|
||||
)
|
||||
|
||||
self.db.add(history_session)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(history_session)
|
||||
|
||||
# Import each history entry as a conversation
|
||||
for i, entry in enumerate(history[:20]): # Limit to 20 entries
|
||||
try:
|
||||
display_text = entry.get("display", "")
|
||||
if display_text:
|
||||
conversation = Conversation(
|
||||
session_id=history_session.id,
|
||||
timestamp=history_session.start_time + timedelta(minutes=i * 5),
|
||||
user_prompt=display_text,
|
||||
exchange_type="user_prompt",
|
||||
context={"imported_from": ".claude.json"}
|
||||
)
|
||||
|
||||
self.db.add(conversation)
|
||||
conversation_count += 1
|
||||
except Exception as e:
|
||||
# Skip problematic entries
|
||||
continue
|
||||
|
||||
if conversation_count > 0:
|
||||
await self.db.commit()
|
||||
|
||||
return conversation_count
|
||||
|
||||
|
||||
@router.post("/import/claude-json")
|
||||
async def import_claude_json(
|
||||
file_path: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Import data from .claude.json file.
|
||||
|
||||
If no file_path is provided, tries to find .claude.json in the user's home directory.
|
||||
"""
|
||||
if not file_path:
|
||||
# Try default location
|
||||
home_path = Path.home() / ".claude.json"
|
||||
file_path = str(home_path)
|
||||
|
||||
try:
|
||||
importer = ClaudeJsonImporter(db)
|
||||
results = await importer.import_from_file(file_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Import completed successfully",
|
||||
"results": results
|
||||
}
|
||||
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Claude configuration file not found: {e}"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid file format: {e}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {e}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/import/claude-json/preview")
|
||||
async def preview_claude_json_import(
|
||||
file_path: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Preview what would be imported from .claude.json file without actually importing.
|
||||
"""
|
||||
if not file_path:
|
||||
home_path = Path.home() / ".claude.json"
|
||||
file_path = str(home_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Claude configuration file not found"
|
||||
)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
claude_data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid JSON in Claude configuration file: {e}"
|
||||
)
|
||||
|
||||
preview = {
|
||||
"file_path": file_path,
|
||||
"file_size_mb": round(os.path.getsize(file_path) / (1024 * 1024), 2),
|
||||
"claude_usage": {
|
||||
"num_startups": claude_data.get("numStartups", 0),
|
||||
"first_start_time": claude_data.get("firstStartTime"),
|
||||
"prompt_queue_use_count": claude_data.get("promptQueueUseCount", 0)
|
||||
},
|
||||
"projects": {
|
||||
"total_count": len(claude_data.get("projects", {})),
|
||||
"paths": list(claude_data.get("projects", {}).keys())[:10], # Show first 10
|
||||
"has_more": len(claude_data.get("projects", {})) > 10
|
||||
},
|
||||
"history_entries": 0
|
||||
}
|
||||
|
||||
# Count total history entries across all projects
|
||||
if "projects" in claude_data:
|
||||
total_history = sum(
|
||||
len(proj.get("history", []))
|
||||
for proj in claude_data["projects"].values()
|
||||
)
|
||||
preview["history_entries"] = total_history
|
||||
|
||||
return preview
|
||||
254
app/api/tool_calls.py
Normal file
254
app/api/tool_calls.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""
|
||||
Tool call tracking API endpoints.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.database.connection import get_db
|
||||
from app.models.session import Session
|
||||
from app.models.tool_call import ToolCall
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ToolCallRequest(BaseModel):
|
||||
"""Request schema for recording a tool call."""
|
||||
session_id: Optional[str] = Field(None, description="Session ID (auto-detected if not provided)")
|
||||
tool_name: str = Field(..., description="Name of the tool being called")
|
||||
parameters: Optional[dict] = Field(None, description="Tool parameters as JSON object")
|
||||
result_status: Optional[str] = Field("success", description="Result status: success, error, timeout")
|
||||
error_message: Optional[str] = Field(None, description="Error message if failed")
|
||||
execution_time_ms: Optional[int] = Field(None, description="Execution time in milliseconds")
|
||||
timestamp: Optional[datetime] = Field(None, description="Timestamp of the tool call")
|
||||
|
||||
|
||||
class ToolCallResponse(BaseModel):
|
||||
"""Response schema for tool call operations."""
|
||||
id: int
|
||||
session_id: str
|
||||
tool_name: str
|
||||
result_status: str
|
||||
timestamp: datetime
|
||||
message: str
|
||||
|
||||
|
||||
class ToolUsageStats(BaseModel):
|
||||
"""Statistics about tool usage."""
|
||||
tool_name: str
|
||||
total_calls: int
|
||||
success_calls: int
|
||||
error_calls: int
|
||||
avg_execution_time_ms: Optional[float]
|
||||
success_rate: float
|
||||
|
||||
|
||||
async def get_current_session_id() -> Optional[str]:
|
||||
"""Get the current session ID from the temporary file."""
|
||||
try:
|
||||
with open("/tmp/claude-session-id", "r") as f:
|
||||
return f.read().strip()
|
||||
except (OSError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/tool-calls", response_model=ToolCallResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def record_tool_call(
|
||||
request: ToolCallRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Record a tool call made during a Claude Code session.
|
||||
|
||||
This endpoint is called by Claude Code hooks when tools are used.
|
||||
It automatically detects the current session if not provided.
|
||||
"""
|
||||
try:
|
||||
# Get session ID
|
||||
session_id = request.session_id
|
||||
if not session_id:
|
||||
session_id = await get_current_session_id()
|
||||
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No active session found. Please provide session_id or ensure a session is running."
|
||||
)
|
||||
|
||||
# Verify session exists
|
||||
result = await db.execute(
|
||||
select(Session).where(Session.id == int(session_id))
|
||||
)
|
||||
session = result.scalars().first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Session {session_id} not found"
|
||||
)
|
||||
|
||||
# Create tool call record
|
||||
tool_call = ToolCall(
|
||||
session_id=str(session_id),
|
||||
tool_name=request.tool_name,
|
||||
parameters=json.dumps(request.parameters) if request.parameters else None,
|
||||
result_status=request.result_status or "success",
|
||||
error_message=request.error_message,
|
||||
execution_time_ms=request.execution_time_ms,
|
||||
timestamp=request.timestamp or datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(tool_call)
|
||||
await db.commit()
|
||||
await db.refresh(tool_call)
|
||||
|
||||
return ToolCallResponse(
|
||||
id=tool_call.id,
|
||||
session_id=tool_call.session_id,
|
||||
tool_name=tool_call.tool_name,
|
||||
result_status=tool_call.result_status,
|
||||
timestamp=tool_call.timestamp,
|
||||
message=f"Tool call '{request.tool_name}' recorded successfully"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to record tool call: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tool-calls/session/{session_id}", response_model=List[dict])
|
||||
async def get_session_tool_calls(
|
||||
session_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get all tool calls for a specific session."""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ToolCall)
|
||||
.where(ToolCall.session_id == session_id)
|
||||
.order_by(desc(ToolCall.timestamp))
|
||||
)
|
||||
tool_calls = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": tc.id,
|
||||
"tool_name": tc.tool_name,
|
||||
"parameters": json.loads(tc.parameters) if tc.parameters else None,
|
||||
"result_status": tc.result_status,
|
||||
"error_message": tc.error_message,
|
||||
"execution_time_ms": tc.execution_time_ms,
|
||||
"timestamp": tc.timestamp
|
||||
}
|
||||
for tc in tool_calls
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get tool calls: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tool-calls/stats", response_model=List[ToolUsageStats])
|
||||
async def get_tool_usage_stats(
|
||||
project_id: Optional[int] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get tool usage statistics with optional filtering."""
|
||||
try:
|
||||
# Base query
|
||||
query = select(
|
||||
ToolCall.tool_name,
|
||||
func.count(ToolCall.id).label("total_calls"),
|
||||
func.sum(func.case([(ToolCall.result_status == "success", 1)], else_=0)).label("success_calls"),
|
||||
func.sum(func.case([(ToolCall.result_status == "error", 1)], else_=0)).label("error_calls"),
|
||||
func.avg(ToolCall.execution_time_ms).label("avg_execution_time_ms")
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if project_id:
|
||||
query = query.join(Session).where(Session.project_id == project_id)
|
||||
|
||||
if start_date:
|
||||
query = query.where(ToolCall.timestamp >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.where(ToolCall.timestamp <= end_date)
|
||||
|
||||
# Group by tool name
|
||||
query = query.group_by(ToolCall.tool_name).order_by(desc("total_calls"))
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
stats = []
|
||||
for row in rows:
|
||||
success_rate = (row.success_calls / row.total_calls * 100) if row.total_calls > 0 else 0
|
||||
|
||||
stats.append(ToolUsageStats(
|
||||
tool_name=row.tool_name,
|
||||
total_calls=row.total_calls,
|
||||
success_calls=row.success_calls,
|
||||
error_calls=row.error_calls,
|
||||
avg_execution_time_ms=float(row.avg_execution_time_ms) if row.avg_execution_time_ms else None,
|
||||
success_rate=round(success_rate, 2)
|
||||
))
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get tool usage stats: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tool-calls/popular", response_model=List[dict])
|
||||
async def get_popular_tools(
|
||||
limit: int = 10,
|
||||
project_id: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get the most frequently used tools."""
|
||||
try:
|
||||
query = select(
|
||||
ToolCall.tool_name,
|
||||
func.count(ToolCall.id).label("usage_count"),
|
||||
func.max(ToolCall.timestamp).label("last_used")
|
||||
)
|
||||
|
||||
if project_id:
|
||||
query = query.join(Session).where(Session.project_id == project_id)
|
||||
|
||||
query = query.group_by(ToolCall.tool_name).order_by(desc("usage_count")).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"tool_name": row.tool_name,
|
||||
"usage_count": row.usage_count,
|
||||
"last_used": row.last_used
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get popular tools: {str(e)}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue