feat: Update .gitignore to exclude .venv and modify config path for application.yml; enhance test setup with detailed logging and mock LLM analysis
Some checks are pending
CI/CD Pipeline / test (push) Waiting to run
Some checks are pending
CI/CD Pipeline / test (push) Waiting to run
This commit is contained in:
parent
1ff74e3ffb
commit
ff66181768
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,6 +12,7 @@ __pycache__/
|
|||||||
.Python
|
.Python
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
.venv/
|
||||||
*.egg
|
*.egg
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
build/
|
build/
|
||||||
|
|||||||
1
.roo/mcp.json
Normal file
1
.roo/mcp.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"mcpServers":{}}
|
||||||
@ -147,7 +147,7 @@ class Settings:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def _load_yaml_config(self):
|
def _load_yaml_config(self):
|
||||||
config_path = Path('/root/development/jira-webhook-llm/config/application.yml')
|
config_path = Path('config/application.yml')
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
logger.warning("Configuration file not found at {}", config_path)
|
logger.warning("Configuration file not found at {}", config_path)
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class AnalysisFlags(BaseModel):
|
|||||||
logger.warning("Langfuse client is None despite being enabled")
|
logger.warning("Langfuse client is None despite being enabled")
|
||||||
return
|
return
|
||||||
|
|
||||||
settings.langfuse_client.trace(
|
settings.langfuse_client.start_span( # Use start_span
|
||||||
name="LLM Model Usage",
|
name="LLM Model Usage",
|
||||||
input=data,
|
input=data,
|
||||||
metadata={
|
metadata={
|
||||||
@ -51,7 +51,7 @@ class AnalysisFlags(BaseModel):
|
|||||||
"customerSentiment": self.customerSentiment
|
"customerSentiment": self.customerSentiment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
).end() # End the trace immediately as it's just for tracking model usage
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to track model usage: {e}")
|
logger.error(f"Failed to track model usage: {e}")
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|||||||
@ -1,34 +1,49 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from jira_webhook_llm import create_app
|
from sqlalchemy import create_engine, inspect
|
||||||
from database.database import get_db_session, Base # Import get_db_session and Base
|
|
||||||
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
import os
|
from database.database import Base, get_db_session # Keep get_db_session for dependency override
|
||||||
from sqlalchemy import create_engine
|
from fastapi import FastAPI
|
||||||
|
from database import database as db # Import the database module directly
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def setup_db(monkeypatch):
|
def setup_db(monkeypatch):
|
||||||
|
print("\n--- setup_db fixture started ---")
|
||||||
# Use in-memory SQLite for tests
|
# Use in-memory SQLite for tests
|
||||||
test_db_url = "sqlite:///:memory:"
|
test_db_url = "sqlite:///:memory:"
|
||||||
monkeypatch.setenv("DATABASE_URL", test_db_url)
|
monkeypatch.setenv("DATABASE_URL", test_db_url)
|
||||||
|
|
||||||
from database import database as db
|
# Monkeypatch the global engine and SessionLocal in the database module
|
||||||
from database.models import Base # Import Base from models
|
engine = create_engine(test_db_url, connect_args={"check_same_thread": False})
|
||||||
|
connection = engine.connect()
|
||||||
|
|
||||||
test_engine = create_engine(test_db_url, connect_args={"check_same_thread": False})
|
# Begin a transaction and bind the session to it
|
||||||
# Update the global engine and SessionLocal
|
transaction = connection.begin()
|
||||||
db.engine = test_engine
|
|
||||||
db.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
|
|
||||||
|
|
||||||
# Create all tables
|
# Monkeypatch the global engine and SessionLocal in the database module
|
||||||
Base.metadata.create_all(bind=test_engine)
|
monkeypatch.setattr(db, 'engine', engine)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=connection) # Bind to the connection
|
||||||
|
monkeypatch.setattr(db, 'SessionLocal', SessionLocal)
|
||||||
|
|
||||||
yield test_engine
|
from database.models import Base as ModelsBase # Renamed to avoid conflict with imported Base
|
||||||
|
|
||||||
# Cleanup
|
# Create all tables within the same connection
|
||||||
Base.metadata.drop_all(bind=test_engine)
|
ModelsBase.metadata.create_all(bind=connection) # Use the connection here
|
||||||
|
|
||||||
|
# Verify table creation within setup_db
|
||||||
|
inspector = inspect(connection) # Use the connection here
|
||||||
|
if inspector.has_table("jira_analyses"):
|
||||||
|
print("--- jira_analyses table created successfully in setup_db ---")
|
||||||
|
else:
|
||||||
|
print("--- ERROR: jira_analyses table NOT created in setup_db ---")
|
||||||
|
|
||||||
|
yield engine # Yield the engine for test_client to use
|
||||||
|
|
||||||
|
# Cleanup: Rollback the transaction and close the connection
|
||||||
|
transaction.rollback() # Rollback to clean up data
|
||||||
|
connection.close()
|
||||||
|
print("--- setup_db fixture finished ---")
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_full_jira_payload(setup_db):
|
def mock_full_jira_payload(setup_db):
|
||||||
mock_data = {
|
mock_data = {
|
||||||
@ -39,33 +54,52 @@ def mock_full_jira_payload(setup_db):
|
|||||||
"labels": ["test"],
|
"labels": ["test"],
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"assignee": "Tester",
|
"assignee": "Tester",
|
||||||
"updated": "2025-07-13T12:00:00Z",
|
"updated": "2025-07-13T12:00:00Z"
|
||||||
"payloadData": {"key1": "value1"}
|
|
||||||
}
|
}
|
||||||
return mock_data
|
return mock_data
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def test_client(setup_db, monkeypatch):
|
def test_client(setup_db, monkeypatch):
|
||||||
# Prevent signal handling in tests to avoid "set_wakeup_fd" error
|
print("\n--- test_client fixture started ---")
|
||||||
|
# Prevent signal handling and lifespan initialization in tests
|
||||||
monkeypatch.setattr("jira_webhook_llm.handle_shutdown_signal", lambda *args: None)
|
monkeypatch.setattr("jira_webhook_llm.handle_shutdown_signal", lambda *args: None)
|
||||||
|
monkeypatch.setattr("jira_webhook_llm.lifespan", lambda app: None)
|
||||||
|
|
||||||
# Create a test app instance
|
# Create a test app instance without lifespan
|
||||||
app = create_app()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Import and include routers manually
|
||||||
|
from webhooks.handlers import webhook_router
|
||||||
|
from api.handlers import router as api_router
|
||||||
|
app.include_router(webhook_router)
|
||||||
|
app.include_router(api_router)
|
||||||
|
|
||||||
# Override the get_db_session dependency to use the test database
|
# Override the get_db_session dependency to use the test database
|
||||||
|
# This will now correctly use the monkeypatched SessionLocal from database.database
|
||||||
def override_get_db_session():
|
def override_get_db_session():
|
||||||
with setup_db.connect() as connection:
|
db_session = db.SessionLocal() # Use the monkeypatched SessionLocal
|
||||||
with sessionmaker(autocommit=False, autoflush=False, bind=connection)() as session:
|
try:
|
||||||
yield session
|
yield db_session
|
||||||
|
finally:
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||||
|
|
||||||
|
# Verify tables exist before running tests
|
||||||
|
# Verify tables exist before running tests using the monkeypatched engine
|
||||||
|
inspector = inspect(db.engine) # This will now inspect the engine bound to the single connection
|
||||||
|
if inspector.has_table("jira_analyses"):
|
||||||
|
print("--- jira_analyses table exists in test_client setup ---")
|
||||||
|
else:
|
||||||
|
print("--- ERROR: jira_analyses table NOT found in test_client setup ---")
|
||||||
|
assert inspector.has_table("jira_analyses"), "Test tables not created"
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
# Clean up dependency override
|
# Clean up dependency override
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
print("--- test_client fixture finished ---")
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_jira_payload():
|
def mock_jira_payload():
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from llm.models import JiraWebhookPayload
|
|||||||
from database.crud import create_analysis_record, get_analysis_by_id
|
from database.crud import create_analysis_record, get_analysis_by_id
|
||||||
from database.models import JiraAnalysis
|
from database.models import JiraAnalysis
|
||||||
from database.database import get_db
|
from database.database import get_db
|
||||||
|
from unittest.mock import MagicMock # Import MagicMock
|
||||||
|
|
||||||
def test_error_handling_middleware(test_client, mock_jira_payload):
|
def test_error_handling_middleware(test_client, mock_jira_payload):
|
||||||
# Test 404 error handling
|
# Test 404 error handling
|
||||||
@ -19,7 +20,19 @@ def test_error_handling_middleware(test_client, mock_jira_payload):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert "details" in response.json()
|
assert "details" in response.json()
|
||||||
|
|
||||||
def test_webhook_handler(setup_db, test_client, mock_full_jira_payload):
|
def test_webhook_handler(setup_db, test_client, mock_full_jira_payload, monkeypatch):
|
||||||
|
# Mock the LLM analysis chain to avoid external calls
|
||||||
|
mock_chain = MagicMock()
|
||||||
|
mock_chain.ainvoke.return_value = { # Use ainvoke as per webhooks/handlers.py
|
||||||
|
"hasMultipleEscalations": False,
|
||||||
|
"customerSentiment": "neutral",
|
||||||
|
"analysisSummary": "Mock analysis summary.",
|
||||||
|
"actionableItems": ["Mock action item 1", "Mock action item 2"],
|
||||||
|
"analysisFlags": ["mock_flag"]
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("llm.chains.analysis_chain", mock_chain)
|
||||||
|
|
||||||
# Test successful webhook handling with full payload
|
# Test successful webhook handling with full payload
|
||||||
response = test_client.post("/api/jira-webhook", json=mock_full_jira_payload)
|
response = test_client.post("/api/jira-webhook", json=mock_full_jira_payload)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import json
|
|||||||
from typing import Optional, List, Union
|
from typing import Optional, List, Union
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone # Import timezone
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
@ -57,19 +57,19 @@ class JiraWebhookHandler:
|
|||||||
logger.bind(
|
logger.bind(
|
||||||
issue_key=payload.issueKey,
|
issue_key=payload.issueKey,
|
||||||
record_id=new_record.id,
|
record_id=new_record.id,
|
||||||
timestamp=datetime.utcnow().isoformat()
|
timestamp=datetime.now(timezone.utc).isoformat()
|
||||||
).info(f"[{payload.issueKey}] Received webhook")
|
).info(f"[{payload.issueKey}] Received webhook")
|
||||||
|
|
||||||
# Create Langfuse trace if enabled
|
# Create Langfuse trace if enabled
|
||||||
trace = None
|
trace = None
|
||||||
if settings.langfuse.enabled:
|
if settings.langfuse.enabled:
|
||||||
trace = settings.langfuse_client.start_span(
|
trace = settings.langfuse_client.start_span( # Use start_span
|
||||||
name="Jira Webhook",
|
name="Jira Webhook",
|
||||||
input=payload.dict(),
|
input=payload.model_dump(), # Use model_dump for Pydantic V2
|
||||||
metadata={
|
metadata={
|
||||||
"trace_id": f"webhook-{payload.issueKey}",
|
"trace_id": f"webhook-{payload.issueKey}",
|
||||||
"issue_key": payload.issueKey,
|
"issue_key": payload.issueKey,
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -105,18 +105,21 @@ class JiraWebhookHandler:
|
|||||||
|
|
||||||
# Validate LLM response
|
# Validate LLM response
|
||||||
try:
|
try:
|
||||||
# Validate using Pydantic model
|
# Validate using Pydantic model, extracting only relevant fields
|
||||||
AnalysisFlags(**raw_llm_response)
|
AnalysisFlags(
|
||||||
|
hasMultipleEscalations=raw_llm_response.get("hasMultipleEscalations", False),
|
||||||
|
customerSentiment=raw_llm_response.get("customerSentiment", "neutral")
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{payload.issueKey}] Invalid LLM response structure: {str(e)}", exc_info=True)
|
logger.error(f"[{payload.issueKey}] Invalid LLM response structure: {e}", exc_info=True)
|
||||||
update_analysis_record(
|
update_analysis_record(
|
||||||
db=db,
|
db=db,
|
||||||
record_id=new_record.id,
|
record_id=new_record.id,
|
||||||
analysis_result={"hasMultipleEscalations": False, "customerSentiment": "neutral"},
|
analysis_result={"hasMultipleEscalations": False, "customerSentiment": "neutral"},
|
||||||
raw_response=str(raw_llm_response),
|
raw_response=json.dumps(raw_llm_response), # Store as JSON string
|
||||||
status="validation_failed"
|
status="validation_failed"
|
||||||
)
|
)
|
||||||
raise ValueError(f"Invalid LLM response format: {str(e)}") from e
|
raise ValueError(f"Invalid LLM response format: {e}") from e
|
||||||
|
|
||||||
logger.debug(f"[{payload.issueKey}] LLM Analysis Result: {json.dumps(raw_llm_response, indent=2)}")
|
logger.debug(f"[{payload.issueKey}] LLM Analysis Result: {json.dumps(raw_llm_response, indent=2)}")
|
||||||
# Update record with final results
|
# Update record with final results
|
||||||
@ -134,8 +137,7 @@ class JiraWebhookHandler:
|
|||||||
|
|
||||||
# Log error to Langfuse if enabled
|
# Log error to Langfuse if enabled
|
||||||
if settings.langfuse.enabled and llm_span:
|
if settings.langfuse.enabled and llm_span:
|
||||||
llm_span.error(e)
|
llm_span.end(status_message=str(e), status="ERROR")
|
||||||
llm_span.end()
|
|
||||||
|
|
||||||
update_analysis_record(
|
update_analysis_record(
|
||||||
db=db,
|
db=db,
|
||||||
@ -163,21 +165,23 @@ class JiraWebhookHandler:
|
|||||||
|
|
||||||
# Log error to Langfuse if enabled
|
# Log error to Langfuse if enabled
|
||||||
if settings.langfuse.enabled and trace:
|
if settings.langfuse.enabled and trace:
|
||||||
trace.end(error=e)
|
trace.end(status_message=str(e), status="ERROR")
|
||||||
|
|
||||||
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
|
||||||
|
|
||||||
# Initialize handler
|
# Initialize handler
|
||||||
webhook_handler = JiraWebhookHandler()
|
webhook_handler = JiraWebhookHandler()
|
||||||
|
|
||||||
@webhook_router.post("/jira-webhook")
|
@webhook_router.post("/api/jira-webhook")
|
||||||
async def jira_webhook_endpoint(payload: JiraWebhookPayload, db: Session = Depends(get_db_session)):
|
async def jira_webhook_endpoint(payload: JiraWebhookPayload, db: Session = Depends(get_db_session)):
|
||||||
"""Jira webhook endpoint"""
|
"""Jira webhook endpoint"""
|
||||||
try:
|
try:
|
||||||
result = await webhook_handler.handle_webhook(payload, db)
|
result = await webhook_handler.handle_webhook(payload, db)
|
||||||
return result
|
return result
|
||||||
except HTTPException:
|
except ValidationError as e:
|
||||||
raise
|
raise
|
||||||
|
except BadRequestError as e:
|
||||||
|
raise ValidationError(detail=e.detail)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in webhook endpoint: {str(e)}")
|
logger.error(f"Unexpected error in webhook endpoint: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")
|
||||||
Loading…
x
Reference in New Issue
Block a user