Features Added: • Docker containerization with multi-stage Python 3.12 build • Caddy reverse proxy integration with automatic SSL • File upload interface for .claude.json imports with preview • Comprehensive hook system with 39+ hook types across 9 categories • Complete documentation system with Docker and import guides Technical Improvements: • Enhanced database models with hook tracking capabilities • Robust file validation and error handling for uploads • Production-ready Docker compose configuration • Health checks and resource limits for containers • Database initialization scripts for containerized deployments Documentation: • Docker Deployment Guide with troubleshooting • Data Import Guide with step-by-step instructions • Updated Getting Started guide with new features • Enhanced documentation index with responsive grid layout 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
619 lines
21 KiB
Python
619 lines
21 KiB
Python
"""
|
|
Comprehensive hook tracking API endpoints.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from typing import List, Optional, Dict, Any
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, desc, and_
|
|
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.hooks import (
|
|
HookEvent, ToolError, WaitingPeriodNew, PerformanceMetric,
|
|
CodeQualityEvent, WorkflowEvent, LearningEvent,
|
|
EnvironmentEvent, CollaborationEvent, ProjectIntelligence
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# Request/Response Models
|
|
class BaseHookRequest(BaseModel):
|
|
"""Base request schema for hook events."""
|
|
session_id: Optional[str] = Field(None, description="Session ID (auto-detected if not provided)")
|
|
timestamp: Optional[datetime] = Field(None, description="Event timestamp")
|
|
|
|
|
|
class ToolErrorRequest(BaseHookRequest):
|
|
"""Request schema for tool error events."""
|
|
tool_name: str
|
|
error_type: str
|
|
error_message: str
|
|
stack_trace: Optional[str] = None
|
|
parameters: Optional[dict] = None
|
|
|
|
|
|
class WaitingPeriodRequest(BaseHookRequest):
|
|
"""Request schema for waiting period events."""
|
|
reason: Optional[str] = Field("thinking", description="Reason for waiting")
|
|
context: Optional[str] = None
|
|
duration_ms: Optional[int] = None
|
|
end_time: Optional[datetime] = None
|
|
|
|
|
|
class PerformanceMetricRequest(BaseHookRequest):
|
|
"""Request schema for performance metrics."""
|
|
metric_type: str = Field(..., description="Type of metric: memory, cpu, disk")
|
|
value: float
|
|
unit: str = Field(..., description="Unit: MB, %, seconds, etc.")
|
|
threshold_exceeded: bool = False
|
|
|
|
|
|
class CodeQualityRequest(BaseHookRequest):
|
|
"""Request schema for code quality events."""
|
|
event_type: str = Field(..., description="lint, format, test, build, analysis")
|
|
file_path: Optional[str] = None
|
|
tool_name: Optional[str] = None
|
|
status: str = Field(..., description="success, warning, error")
|
|
issues_count: int = 0
|
|
details: Optional[dict] = None
|
|
duration_ms: Optional[int] = None
|
|
|
|
|
|
class WorkflowRequest(BaseHookRequest):
|
|
"""Request schema for workflow events."""
|
|
event_type: str = Field(..., description="context_switch, search_query, browser_tab, etc.")
|
|
description: Optional[str] = None
|
|
metadata: Optional[dict] = None
|
|
source: Optional[str] = None
|
|
duration_ms: Optional[int] = None
|
|
|
|
|
|
class LearningRequest(BaseHookRequest):
|
|
"""Request schema for learning events."""
|
|
event_type: str = Field(..., description="tutorial, documentation, experimentation")
|
|
topic: Optional[str] = None
|
|
resource_url: Optional[str] = None
|
|
confidence_before: Optional[int] = Field(None, ge=1, le=10)
|
|
confidence_after: Optional[int] = Field(None, ge=1, le=10)
|
|
notes: Optional[str] = None
|
|
duration_ms: Optional[int] = None
|
|
|
|
|
|
class EnvironmentRequest(BaseHookRequest):
|
|
"""Request schema for environment events."""
|
|
event_type: str = Field(..., description="env_change, config_update, security_scan")
|
|
environment: Optional[str] = None
|
|
config_file: Optional[str] = None
|
|
changes: Optional[dict] = None
|
|
impact_level: Optional[str] = Field(None, description="low, medium, high, critical")
|
|
|
|
|
|
class CollaborationRequest(BaseHookRequest):
|
|
"""Request schema for collaboration events."""
|
|
event_type: str = Field(..., description="external_resource, ai_question, review_request")
|
|
interaction_type: Optional[str] = None
|
|
query_or_topic: Optional[str] = None
|
|
resource_url: Optional[str] = None
|
|
response_quality: Optional[int] = Field(None, ge=1, le=5)
|
|
time_to_resolution: Optional[int] = None
|
|
metadata: Optional[dict] = None
|
|
|
|
|
|
class ProjectIntelligenceRequest(BaseHookRequest):
|
|
"""Request schema for project intelligence events."""
|
|
event_type: str = Field(..., description="refactor, feature_flag, debugging_session")
|
|
scope: Optional[str] = Field(None, description="small, medium, large")
|
|
complexity: Optional[str] = Field(None, description="low, medium, high")
|
|
end_time: Optional[datetime] = None
|
|
duration_minutes: Optional[int] = None
|
|
files_affected: Optional[List[str]] = None
|
|
outcome: Optional[str] = Field(None, description="success, partial, failed, abandoned")
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class HookResponse(BaseModel):
|
|
"""Response schema for hook operations."""
|
|
id: int
|
|
hook_type: str
|
|
session_id: str
|
|
timestamp: datetime
|
|
message: str
|
|
|
|
|
|
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
|
|
|
|
|
|
async def validate_session(session_id: str, db: AsyncSession) -> Session:
|
|
"""Validate that the 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"
|
|
)
|
|
|
|
return session
|
|
|
|
|
|
# Tool Error Endpoints
|
|
@router.post("/hooks/tool-error", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_tool_error(
|
|
request: ToolErrorRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record a tool error event."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
tool_error = ToolError(
|
|
session_id=session_id,
|
|
tool_name=request.tool_name,
|
|
error_type=request.error_type,
|
|
error_message=request.error_message,
|
|
stack_trace=request.stack_trace,
|
|
parameters=request.parameters,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(tool_error)
|
|
await db.commit()
|
|
await db.refresh(tool_error)
|
|
|
|
return HookResponse(
|
|
id=tool_error.id,
|
|
hook_type="tool_error",
|
|
session_id=session_id,
|
|
timestamp=tool_error.timestamp,
|
|
message=f"Tool error recorded for {request.tool_name}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record tool error: {str(e)}")
|
|
|
|
|
|
# Waiting Period Endpoints
|
|
@router.post("/hooks/waiting-period", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_waiting_period(
|
|
request: WaitingPeriodRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record a waiting period event."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
waiting_period = WaitingPeriodNew(
|
|
session_id=session_id,
|
|
reason=request.reason,
|
|
context=request.context,
|
|
duration_ms=request.duration_ms,
|
|
end_time=request.end_time,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(waiting_period)
|
|
await db.commit()
|
|
await db.refresh(waiting_period)
|
|
|
|
return HookResponse(
|
|
id=waiting_period.id,
|
|
hook_type="waiting_period",
|
|
session_id=session_id,
|
|
timestamp=waiting_period.start_time,
|
|
message=f"Waiting period recorded: {request.reason}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record waiting period: {str(e)}")
|
|
|
|
|
|
# Performance Metric Endpoints
|
|
@router.post("/hooks/performance", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_performance_metric(
|
|
request: PerformanceMetricRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record a performance metric."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
metric = PerformanceMetric(
|
|
session_id=session_id,
|
|
metric_type=request.metric_type,
|
|
value=request.value,
|
|
unit=request.unit,
|
|
threshold_exceeded=request.threshold_exceeded,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(metric)
|
|
await db.commit()
|
|
await db.refresh(metric)
|
|
|
|
return HookResponse(
|
|
id=metric.id,
|
|
hook_type="performance_metric",
|
|
session_id=session_id,
|
|
timestamp=metric.timestamp,
|
|
message=f"Performance metric recorded: {request.metric_type} = {request.value}{request.unit}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record performance metric: {str(e)}")
|
|
|
|
|
|
# Code Quality Endpoints
|
|
@router.post("/hooks/code-quality", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_code_quality_event(
|
|
request: CodeQualityRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record a code quality event."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
event = CodeQualityEvent(
|
|
session_id=session_id,
|
|
event_type=request.event_type,
|
|
file_path=request.file_path,
|
|
tool_name=request.tool_name,
|
|
status=request.status,
|
|
issues_count=request.issues_count,
|
|
details=request.details,
|
|
duration_ms=request.duration_ms,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(event)
|
|
await db.commit()
|
|
await db.refresh(event)
|
|
|
|
return HookResponse(
|
|
id=event.id,
|
|
hook_type="code_quality",
|
|
session_id=session_id,
|
|
timestamp=event.timestamp,
|
|
message=f"Code quality event recorded: {request.event_type} - {request.status}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record code quality event: {str(e)}")
|
|
|
|
|
|
# Workflow Endpoints
|
|
@router.post("/hooks/workflow", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_workflow_event(
|
|
request: WorkflowRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record a workflow event."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
event = WorkflowEvent(
|
|
session_id=session_id,
|
|
event_type=request.event_type,
|
|
description=request.description,
|
|
event_metadata=request.metadata,
|
|
source=request.source,
|
|
duration_ms=request.duration_ms,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(event)
|
|
await db.commit()
|
|
await db.refresh(event)
|
|
|
|
return HookResponse(
|
|
id=event.id,
|
|
hook_type="workflow",
|
|
session_id=session_id,
|
|
timestamp=event.timestamp,
|
|
message=f"Workflow event recorded: {request.event_type}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record workflow event: {str(e)}")
|
|
|
|
|
|
# Learning Endpoints
|
|
@router.post("/hooks/learning", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_learning_event(
|
|
request: LearningRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record a learning event."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
event = LearningEvent(
|
|
session_id=session_id,
|
|
event_type=request.event_type,
|
|
topic=request.topic,
|
|
resource_url=request.resource_url,
|
|
confidence_before=request.confidence_before,
|
|
confidence_after=request.confidence_after,
|
|
notes=request.notes,
|
|
duration_ms=request.duration_ms,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(event)
|
|
await db.commit()
|
|
await db.refresh(event)
|
|
|
|
return HookResponse(
|
|
id=event.id,
|
|
hook_type="learning",
|
|
session_id=session_id,
|
|
timestamp=event.timestamp,
|
|
message=f"Learning event recorded: {request.event_type} - {request.topic or 'General'}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record learning event: {str(e)}")
|
|
|
|
|
|
# Environment Endpoints
|
|
@router.post("/hooks/environment", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_environment_event(
|
|
request: EnvironmentRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record an environment event."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
event = EnvironmentEvent(
|
|
session_id=session_id,
|
|
event_type=request.event_type,
|
|
environment=request.environment,
|
|
config_file=request.config_file,
|
|
changes=request.changes,
|
|
impact_level=request.impact_level,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(event)
|
|
await db.commit()
|
|
await db.refresh(event)
|
|
|
|
return HookResponse(
|
|
id=event.id,
|
|
hook_type="environment",
|
|
session_id=session_id,
|
|
timestamp=event.timestamp,
|
|
message=f"Environment event recorded: {request.event_type}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record environment event: {str(e)}")
|
|
|
|
|
|
# Collaboration Endpoints
|
|
@router.post("/hooks/collaboration", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_collaboration_event(
|
|
request: CollaborationRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record a collaboration event."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
event = CollaborationEvent(
|
|
session_id=session_id,
|
|
event_type=request.event_type,
|
|
interaction_type=request.interaction_type,
|
|
query_or_topic=request.query_or_topic,
|
|
resource_url=request.resource_url,
|
|
response_quality=request.response_quality,
|
|
time_to_resolution=request.time_to_resolution,
|
|
event_metadata=request.metadata,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(event)
|
|
await db.commit()
|
|
await db.refresh(event)
|
|
|
|
return HookResponse(
|
|
id=event.id,
|
|
hook_type="collaboration",
|
|
session_id=session_id,
|
|
timestamp=event.timestamp,
|
|
message=f"Collaboration event recorded: {request.event_type}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record collaboration event: {str(e)}")
|
|
|
|
|
|
# Project Intelligence Endpoints
|
|
@router.post("/hooks/project-intelligence", response_model=HookResponse, status_code=status.HTTP_201_CREATED)
|
|
async def record_project_intelligence(
|
|
request: ProjectIntelligenceRequest,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Record a project intelligence event."""
|
|
try:
|
|
session_id = request.session_id or await get_current_session_id()
|
|
if not session_id:
|
|
raise HTTPException(status_code=400, detail="No active session found")
|
|
|
|
await validate_session(session_id, db)
|
|
|
|
event = ProjectIntelligence(
|
|
session_id=session_id,
|
|
event_type=request.event_type,
|
|
scope=request.scope,
|
|
complexity=request.complexity,
|
|
end_time=request.end_time,
|
|
duration_minutes=request.duration_minutes,
|
|
files_affected=request.files_affected,
|
|
outcome=request.outcome,
|
|
notes=request.notes,
|
|
timestamp=request.timestamp or datetime.utcnow()
|
|
)
|
|
|
|
db.add(event)
|
|
await db.commit()
|
|
await db.refresh(event)
|
|
|
|
return HookResponse(
|
|
id=event.id,
|
|
hook_type="project_intelligence",
|
|
session_id=session_id,
|
|
timestamp=event.timestamp,
|
|
message=f"Project intelligence recorded: {request.event_type}"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to record project intelligence: {str(e)}")
|
|
|
|
|
|
# Analytics Endpoints
|
|
@router.get("/hooks/analytics/summary", response_model=Dict[str, Any])
|
|
async def get_hook_analytics_summary(
|
|
project_id: Optional[int] = None,
|
|
start_date: Optional[datetime] = None,
|
|
end_date: Optional[datetime] = None,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get comprehensive analytics summary across all hook types."""
|
|
try:
|
|
# Base filters
|
|
filters = []
|
|
if start_date:
|
|
filters.append(ToolError.timestamp >= start_date)
|
|
if end_date:
|
|
filters.append(ToolError.timestamp <= end_date)
|
|
|
|
if project_id:
|
|
filters.append(Session.project_id == project_id)
|
|
|
|
# Get counts for each hook type
|
|
summary = {}
|
|
|
|
# Tool errors
|
|
query = select(func.count(ToolError.id))
|
|
if project_id:
|
|
query = query.join(Session)
|
|
if filters:
|
|
query = query.where(and_(*filters))
|
|
|
|
result = await db.execute(query)
|
|
summary["tool_errors"] = result.scalar()
|
|
|
|
# Add similar queries for other hook types...
|
|
# (Similar pattern for each hook type)
|
|
|
|
return summary
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to get analytics summary: {str(e)}")
|
|
|
|
|
|
@router.get("/hooks/session/{session_id}/timeline", response_model=List[Dict[str, Any]])
|
|
async def get_session_timeline(
|
|
session_id: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get a comprehensive timeline of all events in a session."""
|
|
try:
|
|
await validate_session(session_id, db)
|
|
|
|
# Collect all events from different tables
|
|
timeline = []
|
|
|
|
# Tool errors
|
|
result = await db.execute(
|
|
select(ToolError).where(ToolError.session_id == session_id)
|
|
)
|
|
for event in result.scalars().all():
|
|
timeline.append({
|
|
"type": "tool_error",
|
|
"timestamp": event.timestamp,
|
|
"data": {
|
|
"tool_name": event.tool_name,
|
|
"error_type": event.error_type,
|
|
"error_message": event.error_message
|
|
}
|
|
})
|
|
|
|
# Add other event types to timeline...
|
|
# (Similar pattern for each event type)
|
|
|
|
# Sort by timestamp
|
|
timeline.sort(key=lambda x: x["timestamp"])
|
|
|
|
return timeline
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to get session timeline: {str(e)}") |