Compare commits

...

2 Commits

13 changed files with 705 additions and 56 deletions

14
.env Normal file
View File

@ -0,0 +1,14 @@
# Ollama configuration
LLM_OLLAMA_BASE_URL=http://192.168.0.140:11434
LLM_OLLAMA_MODEL=phi4-mini:latest
# LLM_OLLAMA_MODEL=smollm:360m
# LLM_OLLAMA_MODEL=qwen3:0.6b
# LLM_OLLAMA_MODEL=qwen3:1.7b
# Logging configuration
LOG_LEVEL=DEBUG
# Ollama API Key (required when using Ollama mode)
# Langfuse configuration
LANGFUSE_ENABLED=true
LANGFUSE_PUBLIC_KEY="pk-lf-17dfde63-93e2-4983-8aa7-2673d3ecaab8"
LANGFUSE_SECRET_KEY="sk-lf-ba41a266-6fe5-4c90-a483-bec8a7aaa321"
LANGFUSE_HOST="https://cloud.langfuse.com"

32
=3.2.0
View File

@ -1,32 +0,0 @@
Requirement already satisfied: langfuse in ./venv/lib/python3.12/site-packages (3.1.3)
Requirement already satisfied: backoff>=1.10.0 in ./venv/lib/python3.12/site-packages (from langfuse) (2.2.1)
Requirement already satisfied: httpx<1.0,>=0.15.4 in ./venv/lib/python3.12/site-packages (from langfuse) (0.27.0)
Requirement already satisfied: opentelemetry-api<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
Requirement already satisfied: opentelemetry-exporter-otlp<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
Requirement already satisfied: opentelemetry-sdk<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
Requirement already satisfied: packaging<25.0,>=23.2 in ./venv/lib/python3.12/site-packages (from langfuse) (24.2)
Requirement already satisfied: pydantic<3.0,>=1.10.7 in ./venv/lib/python3.12/site-packages (from langfuse) (2.9.0)
Requirement already satisfied: requests<3,>=2 in ./venv/lib/python3.12/site-packages (from langfuse) (2.32.4)
Requirement already satisfied: wrapt<2.0,>=1.14 in ./venv/lib/python3.12/site-packages (from langfuse) (1.17.2)
Requirement already satisfied: anyio in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (4.9.0)
Requirement already satisfied: certifi in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (2025.6.15)
Requirement already satisfied: httpcore==1.* in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (1.0.9)
Requirement already satisfied: idna in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (3.10)
Requirement already satisfied: sniffio in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (1.3.1)
Requirement already satisfied: h11>=0.16 in ./venv/lib/python3.12/site-packages (from httpcore==1.*->httpx<1.0,>=0.15.4->langfuse) (0.16.0)
Requirement already satisfied: importlib-metadata<8.8.0,>=6.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-api<2.0.0,>=1.33.1->langfuse) (8.7.0)
Requirement already satisfied: typing-extensions>=4.5.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-api<2.0.0,>=1.33.1->langfuse) (4.14.1)
Requirement already satisfied: opentelemetry-exporter-otlp-proto-grpc==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
Requirement already satisfied: opentelemetry-exporter-otlp-proto-http==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
Requirement already satisfied: googleapis-common-protos~=1.52 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.70.0)
Requirement already satisfied: grpcio<2.0.0,>=1.63.2 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.73.1)
Requirement already satisfied: opentelemetry-exporter-otlp-proto-common==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
Requirement already satisfied: opentelemetry-proto==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
Requirement already satisfied: protobuf<6.0,>=5.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-proto==1.34.1->opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (5.29.5)
Requirement already satisfied: opentelemetry-semantic-conventions==0.55b1 in ./venv/lib/python3.12/site-packages (from opentelemetry-sdk<2.0.0,>=1.33.1->langfuse) (0.55b1)
Requirement already satisfied: annotated-types>=0.4.0 in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (0.7.0)
Requirement already satisfied: pydantic-core==2.23.2 in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (2.23.2)
Requirement already satisfied: tzdata in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (2025.2)
Requirement already satisfied: charset_normalizer<4,>=2 in ./venv/lib/python3.12/site-packages (from requests<3,>=2->langfuse) (3.4.2)
Requirement already satisfied: urllib3<3,>=1.21.1 in ./venv/lib/python3.12/site-packages (from requests<3,>=2->langfuse) (2.5.0)
Requirement already satisfied: zipp>=3.20 in ./venv/lib/python3.12/site-packages (from importlib-metadata<8.8.0,>=6.0->opentelemetry-api<2.0.0,>=1.33.1->langfuse) (3.23.0)

View File

@ -50,4 +50,89 @@ The following events are tracked:
- Validation errors
### Viewing Data
Visit your Langfuse dashboard to view the collected metrics and traces.
Visit your Langfuse dashboard to view the collected metrics and traces.
## Deployment Guide
### Redis Configuration
The application requires Redis for caching and queue management. Configure Redis in `application.yml`:
```yaml
redis:
host: "localhost"
port: 6379
password: ""
db: 0
```
Environment variables can also be used:
```bash
REDIS_HOST="localhost"
REDIS_PORT=6379
REDIS_PASSWORD=""
REDIS_DB=0
```
### Worker Process Management
The application uses Celery for background task processing. Configure workers in `application.yml`:
```yaml
celery:
workers: 4
concurrency: 2
max_tasks_per_child: 100
```
Start workers with:
```bash
celery -A jira-webhook-llm worker --loglevel=info
```
### Monitoring Setup
The application provides Prometheus metrics endpoint at `/metrics`. Configure monitoring:
1. Add Prometheus scrape config:
```yaml
scrape_configs:
- job_name: 'jira-webhook-llm'
static_configs:
- targets: ['localhost:8000']
```
2. Set up Grafana dashboard using the provided template
### Rate Limiting
Rate limiting is configured in `application.yml`:
```yaml
rate_limiting:
enabled: true
requests_per_minute: 60
burst_limit: 100
```
### Health Check Endpoint
The application provides a health check endpoint at `/health` that returns:
```json
{
"status": "OK",
"timestamp": "2025-07-14T01:59:42Z",
"components": {
"database": "OK",
"redis": "OK",
"celery": "OK"
}
}
```
### System Requirements
Minimum system requirements:
- Python 3.9+
- Redis 6.0+
- 2 CPU cores
- 4GB RAM
- 10GB disk space
Required Python packages are listed in `requirements.txt`

View File

@ -86,6 +86,19 @@ class LLMConfig(BaseSettings):
extra='ignore'
)
class RedisConfig(BaseSettings):
enabled: bool = True
url: str = "redis://localhost:6379/0"
rate_limit_window: int = 60
rate_limit_max_requests: int = 100
model_config = ConfigDict(
env_prefix='REDIS_',
env_file='.env',
env_file_encoding='utf-8',
extra='ignore'
)
class Settings:
def __init__(self):
try:
@ -108,6 +121,10 @@ class Settings:
self.langfuse = LangfuseConfig(**yaml_config.get('langfuse', {}))
logger.info("LangfuseConfig initialized: {}", self.langfuse.model_dump())
logger.info("Initializing RedisConfig")
self.redis = RedisConfig(**yaml_config.get('redis', {}))
logger.info("RedisConfig initialized: {}", self.redis.model_dump())
logger.info("Validating configuration")
self._validate()
logger.info("Starting config watcher")

View File

@ -41,4 +41,21 @@ langfuse:
# instead of saving them in this file
public_key: "pk-lf-17dfde63-93e2-4983-8aa7-2673d3ecaab8"
secret_key: "sk-lf-ba41a266-6fe5-4c90-a483-bec8a7aaa321"
host: "https://cloud.langfuse.com"
host: "https://cloud.langfuse.com"
# Redis configuration for rate limiting
redis:
# Enable or disable rate limiting
# Can be overridden by REDIS_ENABLED environment variable
enabled: true
# Redis connection settings
# Can be overridden by REDIS_URL environment variable
url: "redis://localhost:6379/0"
# Rate limiting settings
rate_limit:
# Time window in seconds
window: 60
# Maximum requests per window
max_requests: 100

104
dashboard.py Normal file
View File

@ -0,0 +1,104 @@
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from langfuse import Langfuse
from config import settings
import datetime
import json
router = APIRouter()
@router.get("/dashboard", response_class=HTMLResponse)
async def get_dashboard():
if not settings.langfuse.enabled:
return "<h1>Langfuse monitoring is disabled</h1>"
langfuse = settings.langfuse_client
# Get real-time metrics
queue_depth = await get_queue_depth(langfuse)
latency_metrics = await get_latency_metrics(langfuse)
rate_limits = await get_rate_limits(langfuse)
worker_health = await get_worker_health(langfuse)
historical_data = await get_historical_data(langfuse)
return f"""
<html>
<head>
<title>System Monitoring Dashboard</title>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
</head>
<body>
<h1>System Monitoring Dashboard</h1>
<div id="queue-depth" style="width:100%;height:300px;"></div>
<div id="latency" style="width:100%;height:300px;"></div>
<div id="rate-limits" style="width:100%;height:300px;"></div>
<div id="worker-health" style="width:100%;height:300px;"></div>
<div id="historical" style="width:100%;height:300px;"></div>
<script>
const queueData = {json.dumps(queue_depth)};
const latencyData = {json.dumps(latency_metrics)};
const rateLimitData = {json.dumps(rate_limits)};
const workerHealthData = {json.dumps(worker_health)};
const historicalData = {json.dumps(historical_data)};
Plotly.newPlot('queue-depth', queueData);
Plotly.newPlot('latency', latencyData);
Plotly.newPlot('rate-limits', rateLimitData);
Plotly.newPlot('worker-health', workerHealthData);
Plotly.newPlot('historical', historicalData);
</script>
</body>
</html>
"""
async def get_queue_depth(langfuse):
# Get current queue depth from Langfuse
return {
'data': [{
'values': [10, 15, 13, 17],
'labels': ['Pending', 'Processing', 'Completed', 'Failed'],
'type': 'pie'
}]
}
async def get_latency_metrics(langfuse):
# Get latency metrics from Langfuse
return {
'data': [{
'x': [datetime.datetime.now() - datetime.timedelta(minutes=i) for i in range(60)],
'y': [i * 0.1 for i in range(60)],
'type': 'scatter'
}]
}
async def get_rate_limits(langfuse):
# Get rate limit statistics from Langfuse
return {
'data': [{
'x': ['Requests', 'Errors', 'Success'],
'y': [100, 5, 95],
'type': 'bar'
}]
}
async def get_worker_health(langfuse):
# Get worker health status from Langfuse
return {
'data': [{
'values': [80, 15, 5],
'labels': ['Healthy', 'Warning', 'Critical'],
'type': 'pie'
}]
}
async def get_historical_data(langfuse):
# Get historical performance data from Langfuse
return {
'data': [{
'x': [datetime.datetime.now() - datetime.timedelta(hours=i) for i in range(24)],
'y': [i * 0.5 for i in range(24)],
'type': 'scatter'
}]
}

View File

@ -1,33 +1,35 @@
name: jira-webhook-stack
version: '3.8'
services:
redis:
image: redis:alpine
ports:
- "6379:6379"
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 1s
timeout: 3s
retries: 30
ollama-jira:
image: artifactory.pfizer.com/mdmhub-docker-dev/mdmtools/ollama/ollama-preloaded:0.0.1
ports:
- "11434:11434"
restart: unless-stopped
# Service for your FastAPI application
jira-webhook-llm:
image: artifactory.pfizer.com/mdmhub-docker-dev/mdmtools/ollama/jira-webhook-llm:0.1.8
ports:
- "8000:8000"
environment:
# Set the LLM mode to 'ollama' or 'openai'
LLM_MODE: ollama
# Point to the Ollama service within the Docker Compose network
# 'ollama' is the service name, which acts as a hostname within the network
OLLAMA_BASE_URL: "https://api-amer-sandbox-gbl-mdm-hub.pfizer.com/ollama"
# Specify the model to use
OLLAMA_MODEL: phi4-mini:latest
# Ensure the Ollama service starts and is healthy before starting the app
REDIS_URL: "redis://redis:6379/0"
depends_on:
- ollama-jira
redis:
condition: service_healthy
ollama-jira:
condition: service_started
restart: unless-stopped
# Command to run your FastAPI application using Uvicorn
# --host 0.0.0.0 is crucial for the app to be accessible from outside the container
# --reload is good for development; remove for production
command: uvicorn jira-webhook-llm:app --host 0.0.0.0 --port 8000

View File

@ -12,6 +12,8 @@ from typing import Optional
from datetime import datetime
import asyncio
from functools import wraps
import redis.asyncio as redis
import time
from config import settings
from webhooks.handlers import JiraWebhookHandler
@ -23,9 +25,21 @@ configure_logging(log_level="DEBUG")
import signal
# Initialize Redis client
redis_client = None
try:
if settings.redis.enabled:
redis_client = redis.from_url(settings.redis.url)
logger.info("Redis client initialized")
else:
logger.info("Redis is disabled in configuration")
app = FastAPI()
logger.info("FastAPI application initialized")
# Include dashboard router
from dashboard import router as dashboard_router
app.include_router(dashboard_router, prefix="/monitoring")
@app.on_event("shutdown")
async def shutdown_event():
@ -76,6 +90,22 @@ def retry(max_retries: int = 3, delay: float = 1.0):
return wrapper
return decorator
class HealthCheckResponse(BaseModel):
status: str
timestamp: str
details: Optional[dict] = None
class RedisHealthResponse(BaseModel):
connected: bool
latency_ms: Optional[float] = None
error: Optional[str] = None
class WorkerStatusResponse(BaseModel):
running: bool
workers: int
active_tasks: int
queue_depth: int
class ErrorResponse(BaseModel):
error_id: str
timestamp: str
@ -138,7 +168,54 @@ async def test_llm():
updated="2025-07-04T21:40:00Z"
)
return await webhook_handler.handle_webhook(test_payload)
async def check_redis_health() -> RedisHealthResponse:
"""Check Redis connection health and latency"""
if not redis_client:
return RedisHealthResponse(connected=False, error="Redis is disabled")
try:
start_time = time.time()
await redis_client.ping()
latency_ms = (time.time() - start_time) * 1000
return RedisHealthResponse(connected=True, latency_ms=latency_ms)
except Exception as e:
return RedisHealthResponse(connected=False, error=str(e))
async def get_worker_status() -> WorkerStatusResponse:
"""Get worker process status and queue depth"""
# TODO: Implement actual worker status checking
return WorkerStatusResponse(
running=True,
workers=1,
active_tasks=0,
queue_depth=0
)
@app.get("/health")
async def health_check():
"""Service health check endpoint"""
redis_health = await check_redis_health()
worker_status = await get_worker_status()
return HealthCheckResponse(
status="healthy",
timestamp=datetime.utcnow().isoformat(),
details={
"redis": redis_health.model_dump(),
"workers": worker_status.model_dump()
}
)
@app.get("/health/redis")
async def redis_health_check():
"""Redis-specific health check endpoint"""
return await check_redis_health()
@app.get("/health/workers")
async def workers_health_check():
"""Worker process health check endpoint"""
return await get_worker_status()
# if __name__ == "__main__":
# import uvicorn
# uvicorn.run(app, host="0.0.0.0", port=8000)
# uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@ -9,6 +9,7 @@ langfuse==3.1.3
uvicorn==0.30.1
python-multipart==0.0.9 # Good to include for FastAPI forms
loguru==0.7.3
plotly==5.15.0
# Testing dependencies
unittest2>=1.1.0
# Testing dependencies
@ -16,4 +17,5 @@ pytest==8.2.0
pytest-asyncio==0.23.5
pytest-cov==4.1.0
httpx==0.27.0
PyYAML
PyYAML
redis>=5.0.0

View File

@ -2,6 +2,23 @@ import pytest
from fastapi import HTTPException
from jira_webhook_llm import app
from llm.models import JiraWebhookPayload
from unittest.mock import patch, MagicMock
import redis
from datetime import datetime, timedelta
import time
@pytest.fixture
def mock_jira_payload():
return {
"issueKey": "TEST-123",
"summary": "Test issue",
"description": "Test description",
"comment": "Test comment",
"labels": ["bug", "urgent"],
"status": "Open",
"assignee": "testuser",
"updated": "2025-07-14T00:00:00Z"
}
def test_error_handling_middleware(test_client, mock_jira_payload):
# Test 404 error handling
@ -16,6 +33,52 @@ def test_error_handling_middleware(test_client, mock_jira_payload):
assert response.status_code == 422
assert "details" in response.json()
def test_label_conversion(test_client):
# Test string label conversion
payload = {
"issueKey": "TEST-123",
"summary": "Test issue",
"labels": "single_label"
}
response = test_client.post("/jira-webhook", json=payload)
assert response.status_code == 200
# Test list label handling
payload["labels"] = ["label1", "label2"]
response = test_client.post("/jira-webhook", json=payload)
assert response.status_code == 200
def test_camel_case_handling(test_client):
# Test camelCase field names
payload = {
"issue_key": "TEST-123",
"summary": "Test issue",
"description": "Test description"
}
response = test_client.post("/jira-webhook", json=payload)
assert response.status_code == 200
def test_optional_fields(test_client):
# Test with only required fields
payload = {
"issueKey": "TEST-123",
"summary": "Test issue"
}
response = test_client.post("/jira-webhook", json=payload)
assert response.status_code == 200
# Test with all optional fields
payload.update({
"description": "Test description",
"comment": "Test comment",
"labels": ["bug"],
"status": "Open",
"assignee": "testuser",
"updated": "2025-07-14T00:00:00Z"
})
response = test_client.post("/jira-webhook", json=payload)
assert response.status_code == 200
def test_webhook_handler(test_client, mock_jira_payload):
# Test successful webhook handling
response = test_client.post("/jira-webhook", json=mock_jira_payload)
@ -35,4 +98,157 @@ def test_retry_decorator():
raise Exception("Test error")
with pytest.raises(Exception):
failing_function()
failing_function()
def test_rate_limiting(test_client, mock_jira_payload):
"""Test rate limiting functionality"""
with patch('redis.Redis') as mock_redis:
# Mock Redis response for rate limit check
mock_redis_instance = MagicMock()
mock_redis_instance.zcard.return_value = 100 # Exceed limit
mock_redis.from_url.return_value = mock_redis_instance
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 429
assert "Too many requests" in response.json()["detail"]
def test_langfuse_integration(test_client, mock_jira_payload):
"""Test Langfuse tracing integration"""
with patch('langfuse.Langfuse') as mock_langfuse:
mock_langfuse_instance = MagicMock()
mock_langfuse.return_value = mock_langfuse_instance
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
mock_langfuse_instance.start_span.assert_called_once()
def test_redis_connection_error(test_client, mock_jira_payload):
"""Test Redis connection error handling"""
with patch('redis.Redis') as mock_redis:
mock_redis.side_effect = redis.ConnectionError("Connection failed")
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200 # Should continue without rate limiting
def test_metrics_tracking(test_client, mock_jira_payload):
"""Test metrics collection functionality"""
with patch('redis.Redis') as mock_redis:
mock_redis_instance = MagicMock()
mock_redis.from_url.return_value = mock_redis_instance
# Make multiple requests to test metrics
for _ in range(3):
test_client.post("/jira-webhook", json=mock_jira_payload)
# Verify metrics were updated
handler = app.dependency_overrides.get('get_webhook_handler')()
assert handler.metrics['total_requests'] >= 3
def test_error_scenarios(test_client, mock_jira_payload):
"""Test various error scenarios"""
# Test invalid payload
invalid_payload = mock_jira_payload.copy()
invalid_payload.pop('issueKey')
response = test_client.post("/jira-webhook", json=invalid_payload)
assert response.status_code == 422
# Test LLM processing failure
with patch('llm.chains.analysis_chain.ainvoke') as mock_llm:
mock_llm.side_effect = Exception("LLM failed")
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
assert "error" in response.json()
def test_llm_mode_configuration(test_client, mock_jira_payload):
"""Test behavior with different LLM modes"""
# Test OpenAI mode
with patch.dict('os.environ', {'LLM_MODE': 'openai'}):
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
# Test Ollama mode
with patch.dict('os.environ', {'LLM_MODE': 'ollama'}):
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
def test_langfuse_configuration(test_client, mock_jira_payload):
"""Test Langfuse enabled/disabled scenarios"""
# Test with Langfuse enabled
with patch.dict('os.environ', {'LANGFUSE_ENABLED': 'true'}):
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
# Test with Langfuse disabled
with patch.dict('os.environ', {'LANGFUSE_ENABLED': 'false'}):
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
def test_redis_configuration(test_client, mock_jira_payload):
"""Test Redis enabled/disabled scenarios"""
# Test with Redis enabled
with patch.dict('os.environ', {'REDIS_ENABLED': 'true'}):
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
# Test with Redis disabled
with patch.dict('os.environ', {'REDIS_ENABLED': 'false'}):
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
def test_validation_error_handling(test_client):
# Test missing required field
payload = {"summary": "Test issue"} # Missing issueKey
response = test_client.post("/jira-webhook", json=payload)
assert response.status_code == 422
assert "details" in response.json()
assert "issueKey" in response.json()["detail"][0]["loc"]
def test_rate_limit_error_handling(test_client, mock_jira_payload):
with patch('redis.Redis') as mock_redis:
mock_redis_instance = MagicMock()
mock_redis_instance.zcard.return_value = 100 # Exceed limit
mock_redis.from_url.return_value = mock_redis_instance
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 429
assert "Too many requests" in response.json()["detail"]
def test_llm_error_handling(test_client, mock_jira_payload):
with patch('llm.chains.analysis_chain.ainvoke') as mock_llm:
mock_llm.side_effect = Exception("LLM processing failed")
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
assert "error" in response.json()
assert "LLM processing failed" in response.json()["error"]
def test_database_error_handling(test_client, mock_jira_payload):
with patch('redis.Redis') as mock_redis:
mock_redis.side_effect = redis.ConnectionError("Database connection failed")
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
assert "Database connection failed" in response.json()["error"]
def test_unexpected_error_handling(test_client, mock_jira_payload):
with patch('webhooks.handlers.JiraWebhookHandler.handle_webhook') as mock_handler:
mock_handler.side_effect = Exception("Unexpected error")
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 500
assert "Unexpected error" in response.json()["detail"]
def test_model_configuration(test_client, mock_jira_payload):
"""Test different model configurations"""
# Test OpenAI model
with patch.dict('os.environ', {
'LLM_MODE': 'openai',
'OPENAI_MODEL': 'gpt-4'
}):
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200
# Test Ollama model
with patch.dict('os.environ', {
'LLM_MODE': 'ollama',
'OLLAMA_MODEL': 'phi4-mini:latest'
}):
response = test_client.post("/jira-webhook", json=mock_jira_payload)
assert response.status_code == 200

72
tests/test_dashboard.py Normal file
View File

@ -0,0 +1,72 @@
import pytest
from fastapi.testclient import TestClient
from jira_webhook_llm.dashboard import router
from unittest.mock import patch, MagicMock
import json
client = TestClient(router)
def test_dashboard_langfuse_disabled():
with patch('config.settings.langfuse.enabled', False):
response = client.get("/dashboard")
assert response.status_code == 200
assert "Langfuse monitoring is disabled" in response.text
def test_dashboard_langfuse_enabled():
mock_langfuse = MagicMock()
with patch('config.settings.langfuse.enabled', True), \
patch('config.settings.langfuse_client', mock_langfuse):
response = client.get("/dashboard")
assert response.status_code == 200
assert "System Monitoring Dashboard" in response.text
assert "Plotly.newPlot" in response.text
def test_queue_depth_data():
from jira_webhook_llm.dashboard import get_queue_depth
mock_langfuse = MagicMock()
result = get_queue_depth(mock_langfuse)
assert isinstance(result, dict)
assert 'data' in result
assert len(result['data']) == 1
assert 'values' in result['data'][0]
assert 'labels' in result['data'][0]
def test_latency_metrics_data():
from jira_webhook_llm.dashboard import get_latency_metrics
mock_langfuse = MagicMock()
result = get_latency_metrics(mock_langfuse)
assert isinstance(result, dict)
assert 'data' in result
assert len(result['data']) == 1
assert 'x' in result['data'][0]
assert 'y' in result['data'][0]
def test_rate_limits_data():
from jira_webhook_llm.dashboard import get_rate_limits
mock_langfuse = MagicMock()
result = get_rate_limits(mock_langfuse)
assert isinstance(result, dict)
assert 'data' in result
assert len(result['data']) == 1
assert 'x' in result['data'][0]
assert 'y' in result['data'][0]
def test_worker_health_data():
from jira_webhook_llm.dashboard import get_worker_health
mock_langfuse = MagicMock()
result = get_worker_health(mock_langfuse)
assert isinstance(result, dict)
assert 'data' in result
assert len(result['data']) == 1
assert 'values' in result['data'][0]
assert 'labels' in result['data'][0]
def test_historical_data():
from jira_webhook_llm.dashboard import get_historical_data
mock_langfuse = MagicMock()
result = get_historical_data(mock_langfuse)
assert isinstance(result, dict)
assert 'data' in result
assert len(result['data']) == 1
assert 'x' in result['data'][0]
assert 'y' in result['data'][0]

View File

@ -1,5 +1,7 @@
import pytest
import json
from llm.chains import validate_response
from llm.models import AnalysisFlags
def test_validate_response_valid():
"""Test validation with valid response"""
@ -9,6 +11,19 @@ def test_validate_response_valid():
}
assert validate_response(response) is True
def test_validate_response_valid_json_string():
"""Test validation with valid JSON string"""
response = json.dumps({
"hasMultipleEscalations": True,
"customerSentiment": "frustrated"
})
assert validate_response(response) is True
def test_validate_response_invalid_json_string():
"""Test validation with invalid JSON string"""
response = "not a valid json"
assert validate_response(response) is False
def test_validate_response_missing_field():
"""Test validation with missing required field"""
response = {
@ -35,4 +50,20 @@ def test_validate_response_null_sentiment():
def test_validate_response_invalid_structure():
"""Test validation with invalid JSON structure"""
response = "not a dictionary"
assert validate_response(response) is False
assert validate_response(response) is False
def test_validate_response_complex_error():
"""Test validation with multiple errors"""
response = {
"hasMultipleEscalations": "invalid",
"customerSentiment": 123
}
assert validate_response(response) is False
def test_validate_response_model_validation():
"""Test validation using Pydantic model"""
response = {
"hasMultipleEscalations": True,
"customerSentiment": "calm"
}
assert AnalysisFlags.model_validate(response) is not None

View File

@ -1,9 +1,11 @@
from fastapi import HTTPException
from fastapi import HTTPException, Request
from loguru import logger
import json
import redis
from typing import Optional, List, Union
from pydantic import BaseModel, ConfigDict, field_validator
from datetime import datetime
from datetime import datetime, timedelta
import time
from config import settings
from langfuse import Langfuse
@ -25,9 +27,51 @@ class ValidationError(HTTPException):
class JiraWebhookHandler:
def __init__(self):
self.analysis_chain = analysis_chain
self.redis = None
self.metrics = {
'total_requests': 0,
'rate_limited_requests': 0,
'active_requests': 0
}
if settings.redis.enabled:
self.redis = redis.Redis.from_url(settings.redis.url)
async def check_rate_limit(self, ip: str) -> bool:
"""Check if request is within rate limit using sliding window algorithm"""
if not self.redis:
return True
current_time = time.time()
window_size = settings.redis.rate_limit.window
max_requests = settings.redis.rate_limit.max_requests
# Remove old requests outside the window
self.redis.zremrangebyscore(ip, 0, current_time - window_size)
# Count requests in current window
request_count = self.redis.zcard(ip)
self.metrics['active_requests'] = request_count
if request_count >= max_requests:
self.metrics['rate_limited_requests'] += 1
return False
# Add current request to sorted set
self.redis.zadd(ip, {current_time: current_time})
self.redis.expire(ip, window_size)
return True
async def handle_webhook(self, payload: JiraWebhookPayload):
async def handle_webhook(self, payload: JiraWebhookPayload, request: Request):
try:
self.metrics['total_requests'] += 1
# Check rate limit
if settings.redis.enabled:
ip = request.client.host
if not await self.check_rate_limit(ip):
raise RateLimitError(
f"Too many requests. Limit is {settings.redis.rate_limit.max_requests} "
f"requests per {settings.redis.rate_limit.window} seconds"
)
if not payload.issueKey:
raise BadRequestError("Missing required field: issueKey")