Almost stable tests
Some checks are pending
CI/CD Pipeline / test (push) Waiting to run

This commit is contained in:
Ireneusz Bachanowicz 2025-07-17 02:21:56 +02:00
parent de4758a26f
commit 935a8a49ae
17 changed files with 734 additions and 370 deletions

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

@ -42,7 +42,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
COPY config ./config
# Copy your application source code.
COPY jira-webhook-llm.py .
COPY jira_webhook_llm.py .
COPY config.py .
# Expose the port your application listens on.

75
api/handlers.py Normal file
View File

@ -0,0 +1,75 @@
from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import JSONResponse
from typing import Dict, Any
import config
from llm.models import LLMResponse
from database.database import get_db_session # Removed Session import here
from sqlalchemy.orm import Session # Added correct SQLAlchemy import
from database.crud import get_all_analysis_records, delete_all_analysis_records, get_analysis_by_id
router = APIRouter(
prefix="/api",
tags=["API"]
)
@router.get("/requests")
async def get_analysis_records_endpoint(db: Session = Depends(get_db_session)):
"""Get analysis records"""
try:
records = get_all_analysis_records(db)
return JSONResponse(
status_code=200,
content={"data": records}
)
except Exception as e:
return JSONResponse(
status_code=500,
content={"error": str(e)}
)
@router.post("/test-llm")
async def test_llm_endpoint(db: Session = Depends(get_db_session)):
"""Test endpoint for LLM integration"""
try:
from llm.chains import llm
test_prompt = "What is 1 + 1? Respond only with the number."
response = llm.invoke(test_prompt)
return {
"status": "success",
"message": "LLM integration test successful",
"response": str(response)
}
except Exception as e:
return JSONResponse(
status_code=500,
content={
"status": "error",
"message": f"LLM test failed: {str(e)}"
}
)
@router.delete("/requests")
async def delete_analysis_records_endpoint(db: Session = Depends(get_db_session)):
"""Delete analysis records"""
try:
deleted_count = delete_all_analysis_records(db)
return JSONResponse(
status_code=200,
content={"message": f"Successfully deleted {deleted_count} records", "deleted_count": deleted_count}
)
except Exception as e:
return JSONResponse(
status_code=500,
content={"error": str(e)})
@router.get("/requests/{record_id}")
async def get_analysis_record_endpoint(record_id: int, db: Session = Depends(get_db_session)):
"""Get specific analysis record by ID"""
record = get_analysis_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="Analysis record not found")
return JSONResponse(
status_code=200,
content=record.dict() # Ensure proper data serialization
)

View File

@ -1,11 +1,12 @@
import os
import sys
import traceback
from typing import Optional
from pydantic_settings import BaseSettings
from pydantic import validator, ConfigDict
from pydantic import field_validator, ConfigDict
from loguru import logger
from watchfiles import watch, Change
from threading import Thread
from threading import Thread, Event
from langfuse import Langfuse
from langfuse.langchain import CallbackHandler
import yaml
@ -17,7 +18,7 @@ class LangfuseConfig(BaseSettings):
secret_key: Optional[str] = None
host: Optional[str] = None
@validator('host')
@field_validator('host')
def validate_host(cls, v):
if v and not v.startswith(('http://', 'https://')):
raise ValueError("Langfuse host must start with http:// or https://")
@ -73,7 +74,7 @@ class LLMConfig(BaseSettings):
ollama_base_url: Optional[str] = None
ollama_model: Optional[str] = None
@validator('mode')
@field_validator('mode')
def validate_mode(cls, v):
if v not in ['openai', 'ollama']:
raise ValueError("LLM mode must be either 'openai' or 'ollama'")
@ -86,14 +87,27 @@ class LLMConfig(BaseSettings):
extra='ignore'
)
class ApiConfig(BaseSettings):
api_key: Optional[str] = None
model_config = ConfigDict(
env_prefix='API_',
env_file='.env',
env_file_encoding='utf-8',
extra='ignore'
)
class Settings:
logging_ready = Event() # Event to signal logging is configured
def __init__(self):
try:
# logger.debug(f"Config initialization started from: {''.join(traceback.format_stack())}")
logger.info("Loading configuration from application.yml and environment variables")
# Load configuration from YAML file
yaml_config = self._load_yaml_config()
logger.info("Loaded YAML config: {}", yaml_config) # Add this log line
logger.info("Loaded YAML config: {}", yaml_config)
# Initialize configurations, allowing environment variables to override YAML
logger.info("Initializing LogConfig")
@ -108,6 +122,15 @@ class Settings:
self.langfuse = LangfuseConfig(**yaml_config.get('langfuse', {}))
logger.info("LangfuseConfig initialized: {}", self.langfuse.model_dump())
logger.info("Initializing ApiConfig")
self.api = ApiConfig(**yaml_config.get('api', {}))
logger.info("ApiConfig initialized: {}", self.api.model_dump())
# Add thread_pool_max_workers
self.thread_pool_max_workers = yaml_config.get('application', {}).get('thread_pool_max_workers', 5)
logger.info("ThreadPool max workers set to: {}", self.thread_pool_max_workers)
logger.debug(f"Thread pool initialized with {self.thread_pool_max_workers} workers")
logger.info("Validating configuration")
self._validate()
logger.info("Starting config watcher")
@ -179,29 +202,14 @@ class Settings:
logger.error("Langfuse connection test failed: {}", e)
raise
# Initialize CallbackHandler
# Initialize CallbackHandler with debug logging
logger.debug("Langfuse client attributes: {}", vars(self.langfuse_client))
try:
self.langfuse_handler = CallbackHandler(
public_key=self.langfuse.public_key,
secret_key=self.langfuse.secret_key,
host=self.langfuse.host
)
except TypeError:
try:
# Fallback for older versions of langfuse.langchain.CallbackHandler
self.langfuse_handler = CallbackHandler(
public_key=self.langfuse.public_key,
host=self.langfuse.host
)
logger.warning("Using fallback CallbackHandler initialization - secret_key parameter not supported")
except TypeError:
# Fallback for even older versions
self.langfuse_handler = CallbackHandler(
public_key=self.langfuse.public_key
)
logger.warning("Using minimal CallbackHandler initialization - only public_key parameter supported")
logger.info("Langfuse client and handler initialized successfully")
self.langfuse_handler = CallbackHandler()
logger.debug("CallbackHandler initialized successfully")
except Exception as e:
logger.error("CallbackHandler initialization failed: {}", e)
raise
logger.info("Langfuse client and handler initialized successfully")
except ValueError as e:
logger.warning("Langfuse configuration error: {}. Disabling Langfuse.", e)
@ -212,6 +220,9 @@ class Settings:
def _start_watcher(self):
def watch_config():
# Wait for logging to be fully configured
self.logging_ready.wait()
for changes in watch('config/application.yml'):
for change in changes:
if change[0] == Change.modified:

76
database/crud.py Normal file
View File

@ -0,0 +1,76 @@
from loguru import logger
from sqlalchemy.orm import Session
from datetime import datetime
import json
from typing import Dict, Any, Optional
from database.models import JiraAnalysis
from llm.models import JiraWebhookPayload
def create_analysis_record(db: Session, payload: JiraWebhookPayload) -> JiraAnalysis:
"""Creates a new Jira analysis record in the database."""
db_analysis = JiraAnalysis(
issue_key=payload.issueKey,
project_key=payload.projectKey,
status="pending",
issue_summary=payload.summary,
request_payload=payload.model_dump(),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_analysis)
db.commit()
db.refresh(db_analysis)
return db_analysis
def get_analysis_record(db: Session, issue_key: str) -> Optional[JiraAnalysis]:
"""Retrieves the latest analysis record for a given Jira issue key."""
logger.debug(f"Attempting to retrieve analysis record for issue key: {issue_key}")
record = db.query(JiraAnalysis).filter(JiraAnalysis.issue_key == issue_key).order_by(JiraAnalysis.created_at.desc()).first()
if record:
logger.debug(f"Found analysis record for {issue_key}: {record.id}")
else:
logger.debug(f"No analysis record found for {issue_key}")
return record
def update_analysis_record(
db: Session,
record_id: int,
status: str,
analysis_result: Optional[Dict[str, Any]] = None,
error_message: Optional[str] = None,
raw_response: Optional[Dict[str, Any]] = None
) -> Optional[JiraAnalysis]:
"""Updates an existing Jira analysis record."""
db_analysis = db.query(JiraAnalysis).filter(JiraAnalysis.id == record_id).first()
if db_analysis:
db_analysis.status = status
db_analysis.updated_at = datetime.utcnow()
if analysis_result:
db_analysis.analysis_result = analysis_result
if error_message:
db_analysis.error_message = error_message
if raw_response:
db_analysis.raw_response = json.dumps(raw_response)
db.commit()
db.refresh(db_analysis)
return db_analysis
def get_all_analysis_records(db: Session) -> list[JiraAnalysis]:
"""Retrieves all analysis records from the database."""
return db.query(JiraAnalysis).all()
def get_analysis_by_id(db: Session, record_id: int) -> Optional[JiraAnalysis]:
"""Retrieves an analysis record by its unique database ID."""
return db.query(JiraAnalysis).filter(JiraAnalysis.id == record_id).first()
def delete_all_analysis_records(db: Session) -> int:
"""Deletes all analysis records from the database and returns count of deleted records."""
count = db.query(JiraAnalysis).count()
db.query(JiraAnalysis).delete()
db.commit()
return count
db.commit()
db.query(JiraAnalysis).delete()
db.commit()

35
database/database.py Normal file
View File

@ -0,0 +1,35 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.ext.declarative import declarative_base
from contextlib import contextmanager
from loguru import logger
from database.models import Base
DATABASE_URL = "sqlite:///./jira_analyses.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db():
"""Initializes the database by creating all tables."""
logger.info("Initializing database...")
Base.metadata.create_all(bind=engine)
logger.info("Database initialized successfully.")
@contextmanager
def get_db():
"""Context manager to get a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_db_session():
"""FastAPI dependency to get a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()

20
database/models.py Normal file
View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, Text, JSON
from datetime import datetime
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class JiraAnalysis(Base):
__tablename__ = "jira_analyses"
id = Column(Integer, primary_key=True, index=True)
issue_key = Column(String, index=True, nullable=False)
project_key = Column(String, index=True, nullable=False)
status = Column(String, default="pending", nullable=False) # pending, processing, completed, failed
issue_summary = Column(Text, nullable=False)
request_payload = Column(JSON, nullable=False) # Store the original Jira webhook payload
analysis_result = Column(JSON, nullable=True) # Store the structured LLM output
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
error_message = Column(Text, nullable=True) # To store any error messages
raw_response = Column(JSON, nullable=True) # Store raw LLM response before validation

View File

@ -1,204 +0,0 @@
import os
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI, Request, HTTPException
from pydantic import BaseModel
from fastapi.responses import JSONResponse
from loguru import logger
import uuid
import sys
from typing import Optional
from datetime import datetime
import asyncio
from functools import wraps, partial
from config import settings
from webhooks.handlers import JiraWebhookHandler
from llm.models import JiraWebhookPayload
from logging_config import configure_logging
# Initialize logging first
configure_logging(log_level="DEBUG")
import signal
from contextlib import asynccontextmanager
# Setup async-compatible signal handling
def handle_shutdown_signal(signum, loop):
"""Graceful shutdown signal handler"""
logger.info(f"Received signal {signum}, initiating shutdown...")
# Set shutdown flag and remove signal handlers to prevent reentrancy
if not hasattr(loop, '_shutdown'):
loop._shutdown = True
# Prevent further signal handling
for sig in (signal.SIGTERM, signal.SIGINT):
loop.remove_signal_handler(sig)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Handle startup and shutdown events"""
# Startup
try:
logger.info("Initializing application...")
# Initialize event loop
loop = asyncio.get_running_loop()
logger.debug("Event loop initialized")
# Setup signal handlers
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, partial(handle_shutdown_signal, sig, loop))
logger.info("Signal handlers configured successfully")
# Verify critical components
if not hasattr(settings, 'langfuse_handler'):
logger.error("Langfuse handler not found in settings")
raise RuntimeError("Langfuse handler not initialized")
logger.info("Application initialized successfully")
yield
# Check shutdown flag before cleanup
loop = asyncio.get_running_loop()
if hasattr(loop, '_shutdown'):
logger.info("Shutdown initiated, starting cleanup...")
except Exception as e:
logger.critical(f"Application initialization failed: {str(e)}")
raise
finally:
# Shutdown
logger.info("Shutting down application...")
try:
# Cleanup sequence with async safety
cleanup_tasks = []
shutdown_success = True
# Close langfuse with retry
if hasattr(settings, 'langfuse_handler') and hasattr(settings.langfuse_handler, 'close'):
async def close_langfuse():
try:
await asyncio.wait_for(settings.langfuse_handler.close(), timeout=5.0)
logger.info("Langfuse client closed successfully")
except asyncio.TimeoutError:
logger.warning("Timeout while closing Langfuse client")
except Exception as e:
logger.error(f"Error closing Langfuse client: {str(e)}")
cleanup_tasks.append(close_langfuse())
# Remove confirm_shutdown entirely
# Execute all cleanup tasks with timeout
try:
await asyncio.wait_for(asyncio.gather(*cleanup_tasks), timeout=10.0)
except asyncio.TimeoutError:
logger.warning("Timeout during cleanup sequence")
loop.stop() # Explicit loop stop after cleanup
# Cancel all pending tasks
async def cancel_pending_tasks():
try:
pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
logger.info("Pending tasks cancelled successfully")
except Exception as e:
logger.error(f"Error cancelling pending tasks: {str(e)}")
cleanup_tasks.append(cancel_pending_tasks())
# Execute all cleanup tasks with timeout
try:
await asyncio.wait_for(asyncio.gather(*cleanup_tasks), timeout=10.0)
except asyncio.TimeoutError:
logger.warning("Timeout during cleanup sequence")
loop.stop() # Add explicit loop stop after cleanup
except Exception as e:
logger.error(f"Error during shutdown: {str(e)}")
raise
# Initialize FastAPI app after lifespan definition
app = FastAPI(lifespan=lifespan)
def retry(max_retries: int = 3, delay: float = 1.0):
"""Decorator for retrying failed operations"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
last_error = None
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except Exception as e:
last_error = e
logger.warning(f"Attempt {attempt + 1} failed: {str(e)}")
if attempt < max_retries - 1:
await asyncio.sleep(delay * (attempt + 1))
raise last_error
return wrapper
return decorator
class ErrorResponse(BaseModel):
error_id: str
timestamp: str
status_code: int
message: str
details: Optional[str] = None
@app.middleware("http")
async def error_handling_middleware(request: Request, call_next):
request_id = str(uuid.uuid4())
logger.bind(request_id=request_id).info(f"Request started: {request.method} {request.url}")
try:
response = await call_next(request)
return response
except HTTPException as e:
logger.error(f"HTTP Error: {e.status_code} - {e.detail}")
error_response = ErrorResponse(
error_id=request_id,
timestamp=datetime.utcnow().isoformat(),
status_code=e.status_code,
message=e.detail,
details=str(e)
)
return JSONResponse(status_code=e.status_code, content=error_response.model_dump())
except Exception as e:
logger.error(f"Unexpected error: {str(e)}")
error_response = ErrorResponse(
error_id=request_id,
timestamp=datetime.utcnow().isoformat(),
status_code=500,
message="Internal Server Error",
details=str(e)
)
return JSONResponse(status_code=500, content=error_response.model_dump())
webhook_handler = JiraWebhookHandler()
@app.post("/jira-webhook")
async def jira_webhook_handler(payload: JiraWebhookPayload):
logger.info(f"Received webhook payload: {payload.model_dump()}")
try:
response = await webhook_handler.handle_webhook(payload)
logger.info(f"Webhook processed successfully")
return response
except Exception as e:
logger.error(f"Error processing webhook: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/test-llm")
async def test_llm():
"""Test endpoint for LLM integration"""
test_payload = JiraWebhookPayload(
issueKey="TEST-123",
summary="Test issue",
description="This is a test issue for LLM integration",
comment="Testing OpenAI integration with Langfuse",
labels=["test"],
status="Open",
assignee="Tester",
updated="2025-07-04T21:40:00Z"
)
return await webhook_handler.handle_webhook(test_payload)

BIN
jira_analyses.db Normal file

Binary file not shown.

250
jira_webhook_llm.py Normal file
View File

@ -0,0 +1,250 @@
import os
import json
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI, Request, HTTPException
from pydantic import BaseModel
from fastapi.responses import JSONResponse
from http import HTTPStatus
from loguru import logger
import uuid
from database.database import init_db
import sys
from typing import Optional
from datetime import datetime, timezone
import asyncio
from functools import wraps, partial
from concurrent.futures import ThreadPoolExecutor
from database.database import init_db, get_db
from database.crud import create_analysis_record, update_analysis_record
from llm.models import JiraWebhookPayload
from llm.chains import analysis_chain, validate_response
from api.handlers import router # Correct variable name
from webhooks.handlers import webhook_router
from database.crud import get_all_analysis_records, delete_all_analysis_records, get_analysis_by_id, get_analysis_record
from logging_config import configure_logging
# Initialize logging as early as possible
from config import settings
import signal
from contextlib import asynccontextmanager
cleanup_tasks = [] # Initialize cleanup_tasks globally
# Setup async-compatible signal handling
def handle_shutdown_signal(signum, loop):
"""Graceful shutdown signal handler"""
logger.info(f"Received signal {signum}, initiating shutdown...")
# Set shutdown flag and remove signal handlers to prevent reentrancy
if not hasattr(loop, '_shutdown'):
loop._shutdown = True
# Prevent further signal handling
for sig in (signal.SIGTERM, signal.SIGINT):
loop.remove_signal_handler(sig)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Context manager for managing the lifespan of the FastAPI application.
Initializes the database, sets up signal handlers, and handles cleanup.
"""
# Initialize ThreadPoolExecutor for background tasks
executor = ThreadPoolExecutor(max_workers=settings.thread_pool_max_workers)
app.state.executor = executor
try:
logger.info("Initializing application...")
init_db() # Initialize the database
# Initialize event loop
loop = asyncio.get_running_loop()
logger.debug("Event loop initialized")
# Setup signal handlers
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, partial(handle_shutdown_signal, sig, loop))
logger.info("Signal handlers configured successfully")
# Verify critical components
if not hasattr(settings, 'langfuse_handler'):
logger.error("Langfuse handler not found in settings")
raise RuntimeError("Langfuse handler not initialized")
logger.info("Application initialized successfully")
yield
# Check shutdown flag before cleanup
loop = asyncio.get_running_loop()
if hasattr(loop, '_shutdown'):
logger.info("Shutdown initiated, starting cleanup...")
except Exception as e:
logger.critical(f"Application initialization failed: {str(e)}. Exiting.")
# Do not re-raise here, allow finally block to execute cleanup
finally:
# Ensure async context for cleanup
async def perform_shutdown():
try:
await execute_cleanup()
except Exception as e:
logger.error(f"Cleanup failed: {str(e)}")
finally:
loop.stop()
# Run within event loop
asyncio.get_event_loop().run_until_complete(perform_shutdown())
# Close langfuse with retry
if hasattr(settings, 'langfuse_handler') and hasattr(settings.langfuse_handler, 'close'):
async def close_langfuse():
try:
await asyncio.wait_for(settings.langfuse_handler.close(), timeout=5.0)
logger.info("Langfuse client closed successfully")
except asyncio.TimeoutError:
logger.warning("Timeout while closing Langfuse client")
except Exception as e:
logger.error(f"Error closing Langfuse client: {str(e)}")
cleanup_tasks.append(close_langfuse)
# Wrap in async function and structure properly
async def execute_cleanup():
try:
await asyncio.wait_for(asyncio.gather(*cleanup_tasks), timeout=10.0)
except asyncio.TimeoutError:
logger.warning("Timeout during cleanup sequence")
finally:
loop.stop()
# Wrap in try/except and ensure async context
# The following lines were causing syntax errors due to incorrect indentation and placement.
# The cleanup logic is already handled by `execute_cleanup` and `perform_shutdown`.
# Removing redundant and misplaced code.
# Ensure proper top-level placement after async blocks
def create_app():
"""Factory function to create FastAPI app instance"""
configure_logging(log_level="DEBUG")
_app = FastAPI(lifespan=lifespan)
# Include routers without prefixes to match test expectations
_app.include_router(webhook_router)
_app.include_router(router)
# Add health check endpoint
@_app.get("/health")
async def health_check():
return {"status": "healthy"}
# Add error handling middleware
@_app.middleware("http")
async def error_handling_middleware(request: Request, call_next):
request_id = str(uuid.uuid4())
logger.bind(request_id=request_id).info(f"Request started: {request.method} {request.url}")
try:
response = await call_next(request)
if response.status_code >= 400:
error_response = ErrorResponse(
error_id=request_id,
timestamp=datetime.now(timezone.utc).isoformat(),
status_code=response.status_code,
message=HTTPStatus(response.status_code).phrase,
details="Endpoint not found or invalid request"
)
return JSONResponse(status_code=response.status_code, content=error_response.model_dump())
return response
except HTTPException as e:
logger.error(f"HTTP Error: {e.status_code} - {e.detail}")
error_response = ErrorResponse(
error_id=request_id,
timestamp=datetime.now(timezone.utc).isoformat(),
status_code=e.status_code,
message=e.detail,
details=str(e)
)
return JSONResponse(status_code=e.status_code, content=error_response.model_dump())
except Exception as e:
logger.error(f"Unexpected error: {str(e)}")
error_response = ErrorResponse(
error_id=request_id,
timestamp=datetime.now(timezone.utc).isoformat(),
status_code=500,
message="Internal Server Error",
details=str(e)
)
return JSONResponse(status_code=500, content=error_response.model_dump())
return _app
app = create_app()
async def process_jira_webhook_background(record_id: int, payload: JiraWebhookPayload):
"""
Background task to process Jira webhook and perform LLM analysis.
"""
try:
logger.info(f"Starting background processing for record ID: {record_id}, Issue Key: {payload.issueKey}")
except Exception as e:
logger.error(f"Failed to start background processing for record {record_id}: {str(e)}")
raise
with get_db() as db:
try:
update_analysis_record(db, record_id, "processing")
llm_input = {
"issueKey": payload.issueKey,
"summary": payload.summary,
"description": payload.description if payload.description else "No description provided.",
"status": payload.status if payload.status else "Unknown",
"labels": ", ".join(payload.labels) if payload.labels else "None",
"assignee": payload.assignee if payload.assignee else "Unassigned",
"updated": payload.updated if payload.updated else "Unknown",
"comment": payload.comment if payload.comment else "No new comment provided."
}
analysis_result = await analysis_chain.ainvoke(llm_input)
if not validate_response(analysis_result):
logger.warning(f"Invalid LLM response format for {payload.issueKey}")
analysis_result = {
"hasMultipleEscalations": False,
"customerSentiment": "neutral"
}
update_analysis_record(db, record_id, "failed", analysis_result=analysis_result, error_message="Invalid LLM response format")
logger.error(f"LLM processing failed for {payload.issueKey}: Invalid response format")
return
update_analysis_record(db, record_id, "completed", analysis_result=analysis_result)
logger.info(f"Background processing completed for record ID: {record_id}, Issue Key: {payload.issueKey}")
except Exception as e:
logger.error(f"Error during background processing for record ID {record_id}, Issue Key {payload.issueKey}: {str(e)}")
update_analysis_record(db, record_id, "failed", error_message=str(e))
def retry(max_retries: int = 3, delay: float = 1.0):
"""Decorator for retrying failed operations"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
last_error = None
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except Exception as e:
last_error = e
logger.warning(f"Attempt {attempt + 1} failed: {str(e)}")
if attempt < max_retries - 1:
await asyncio.sleep(delay * (attempt + 1))
raise last_error
return wrapper
return decorator
class ErrorResponse(BaseModel):
error_id: str
timestamp: str
status_code: int
message: str
details: Optional[str] = None

View File

@ -1,3 +1,12 @@
import json
from typing import Union
from loguru import logger
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.runnables import RunnablePassthrough
from llm.models import AnalysisFlags
from config import settings
import json
from typing import Union
from langchain_ollama import OllamaLLM
from langchain_openai import ChatOpenAI
@ -53,7 +62,7 @@ elif settings.llm.mode == 'ollama':
# Test connection
logger.debug("Testing Ollama connection...")
# llm.invoke("test") # Simple test request
llm.invoke("test") # Simple test request
logger.info("Ollama connection established successfully")
except Exception as e:
@ -88,8 +97,13 @@ def load_prompt_template(version="v1.1.0"):
template_content = f.read()
# Split system and user parts
system_template, user_template = template_content.split("\n\nUSER:\n")
system_template = system_template.replace("SYSTEM:\n", "").strip()
if "\n\nUSER:\n" in template_content:
system_template, user_template = template_content.split("\n\nUSER:\n")
system_template = system_template.replace("SYSTEM:\n", "").strip()
else:
# Handle legacy format
system_template = template_content
user_template = "Analyze this Jira ticket: {issueKey}"
return ChatPromptTemplate.from_messages([
SystemMessagePromptTemplate.from_template(system_template),
@ -152,40 +166,32 @@ def create_analysis_chain():
analysis_chain = create_analysis_chain()
# Enhanced response validation function
def validate_response(response: Union[dict, str]) -> bool:
def validate_response(response: Union[dict, str], issue_key: str = "N/A") -> bool:
"""Validate the JSON response structure and content"""
try:
# If response is a string, attempt to parse it as JSON
if isinstance(response, str):
logger.debug(f"[{issue_key}] Raw LLM response (string): {response}")
try:
response = json.loads(response)
except json.JSONDecodeError:
logger.error(f"Invalid JSON response: {response}")
raise ValueError("Invalid JSON response format")
except json.JSONDecodeError as e:
logger.error(f"[{issue_key}] JSONDecodeError: {e}. Raw response: {response}")
return False
# Ensure response is a dictionary
if not isinstance(response, dict):
logger.error(f"[{issue_key}] Response is not a dictionary: {type(response)}")
return False
# Check required fields
required_fields = ["hasMultipleEscalations", "customerSentiment"]
if not all(field in response for field in required_fields):
return False
# Validate field types
if not isinstance(response["hasMultipleEscalations"], bool):
return False
if response["customerSentiment"] is not None:
if not isinstance(response["customerSentiment"], str):
return False
logger.debug(f"[{issue_key}] Parsed LLM response (JSON): {json.dumps(response)}")
# Validate against schema using AnalysisFlags model
try:
AnalysisFlags.model_validate(response)
return True
except Exception:
except Exception as e:
logger.error(f"[{issue_key}] Pydantic validation error: {e}. Invalid response: {response}")
return False
except Exception:
except Exception as e:
logger.error(f"[{issue_key}] Unexpected error during response validation: {e}. Response: {response}")
return False

View File

@ -1,11 +1,17 @@
from typing import Optional, List, Union
from loguru import logger
from pydantic import BaseModel, ConfigDict, field_validator, Field
from config import settings
class LLMResponse(BaseModel):
status: str
message: str
class JiraWebhookPayload(BaseModel):
model_config = ConfigDict(alias_generator=lambda x: ''.join(word.capitalize() if i > 0 else word for i, word in enumerate(x.split('_'))), populate_by_name=True)
issueKey: str
projectKey: Optional[str] = None # Added missing field
summary: str
description: Optional[str] = None
comment: Optional[str] = None
@ -17,7 +23,7 @@ class JiraWebhookPayload(BaseModel):
if isinstance(v, str):
return [v]
return v or []
status: Optional[str] = None
assignee: Optional[str] = None
updated: Optional[str] = None

View File

@ -4,6 +4,7 @@ from pathlib import Path
from datetime import datetime
from typing import Optional
from loguru import logger
from config import Settings
# Basic fallback logging configuration
logger.remove()
@ -13,7 +14,6 @@ def configure_logging(log_level: str = "INFO", log_dir: Optional[str] = None):
"""Configure structured logging for the application with fallback handling"""
try:
# Log that we're attempting to configure logging
logger.warning("Attempting to configure logging...")
# Default log directory
if not log_dir:
@ -51,52 +51,27 @@ def configure_logging(log_level: str = "INFO", log_dir: Optional[str] = None):
)
# Configure default extras
logger.configure(extra={"request_id": "N/A"})
# Configure thread-safe defaults
logger.configure(
extra={"request_id": "N/A"},
patcher=lambda record: record["extra"].update(
thread_id = record["thread"].id if hasattr(record.get("thread"), 'id') else "main"
)
)
logger.info("Logging configured successfully")
settings = Settings()
# Removed duplicate logging_ready.set() call
logger.debug("Signaled logging_ready event")
except Exception as e:
# Fallback to basic logging if configuration fails
logger.remove()
logger.add(sys.stderr, level="WARNING", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}")
logger.error(f"Failed to configure logging: {str(e)}. Using fallback logging configuration.")
"""Configure structured logging for the application"""
# Default log directory
if not log_dir:
log_dir = os.getenv("LOG_DIR", "logs")
# Create log directory if it doesn't exist
Path(log_dir).mkdir(parents=True, exist_ok=True)
# Log file path with timestamp
log_file = Path(log_dir) / f"jira-webhook-llm_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
# Remove any existing loggers
logger.remove()
# Add console logger
logger.add(
sys.stdout,
level=log_level,
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {extra[request_id]} | {message}",
colorize=True,
backtrace=True,
diagnose=True
)
# Add file logger
logger.add(
str(log_file),
level=log_level,
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {extra[request_id]} | {message}",
rotation="100 MB",
retention="30 days",
compression="zip",
backtrace=True,
diagnose=True
)
# Configure default extras
logger.configure(extra={"request_id": "N/A"})
logger.info("Logging configured successfully")
settings = Settings()
try:
settings.logging_ready.set()
logger.debug("Signaled logging_ready event")
except Exception as inner_e:
logger.error(f"Failed to signal logging_ready: {str(inner_e)}")
raise # Re-raise the original exception

View File

@ -1,11 +1,11 @@
fastapi==0.111.0
pydantic>=2.9.0
pydantic==2.7.1
pydantic-settings>=2.0.0
langchain==0.3.26
langchain-ollama==0.3.3
langchain-openai==0.3.27
langchain-core==0.3.68
langfuse==3.1.3
langchain>=0.1.0
langchain-ollama>=0.1.0
langchain-openai>=0.1.0
langchain-core>=0.1.0
langfuse>=3.0.0
uvicorn==0.30.1
python-multipart==0.0.9 # Good to include for FastAPI forms
loguru==0.7.3
@ -16,4 +16,6 @@ pytest==8.2.0
pytest-asyncio==0.23.5
pytest-cov==4.1.0
httpx==0.27.0
PyYAML>=6.0.2
PyYAML>=6.0.2
SQLAlchemy==2.0.30
alembic==1.13.1

View File

@ -1,14 +1,70 @@
import pytest
from fastapi.testclient import TestClient
from jira_webhook_llm import app
from jira_webhook_llm import create_app, app # Assuming app is created via factory
from database.database import engine, Base # Add import
from sqlalchemy.orm import sessionmaker
import os
from sqlalchemy import create_engine
@pytest.fixture(scope="function")
def setup_db(monkeypatch):
# Use in-memory SQLite for tests
test_db_url = "sqlite:///:memory:"
monkeypatch.setenv("DATABASE_URL", test_db_url)
from database import database as db
from database.models import Base # Import Base from models
test_engine = create_engine(test_db_url, connect_args={"check_same_thread": False})
# Update the global engine and SessionLocal
db.engine = test_engine
db.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
# Create all tables
Base.metadata.create_all(bind=test_engine)
yield test_engine
# Cleanup
Base.metadata.drop_all(bind=test_engine)
@pytest.fixture
def mock_full_jira_payload(setup_db):
mock_data = {
"issueKey": "PROJ-123",
"projectKey": "PROJ",
"summary": "Test Issue",
"description": "Test Description",
"comment": "Test Comment",
"labels": ["test"],
"status": "open",
"assignee": "Tester",
"updated": "2025-07-13T12:00:00Z",
"payloadData": {"key1": "value1"}
}
return mock_data
@pytest.fixture
def test_client():
def test_client(setup_db):
# Ensure the database is set up before creating the app
test_engine = setup_db
# Import and patch the database module to use test database
from database import database as db
db.engine = test_engine
db.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
# Create fresh app instance with test DB
from jira_webhook_llm import create_app
app = create_app()
return TestClient(app)
@pytest.fixture
def mock_jira_payload():
return {
"projectKey": "TEST-PROJECT",
"issueKey": "TEST-123",
"summary": "Test Issue",
"description": "Test Description",
@ -17,4 +73,14 @@ def mock_jira_payload():
"status": "Open",
"assignee": "Tester",
"updated": "2025-07-13T12:00:00Z"
}
}
# return {
# "issueKey": "TEST-123",
# "summary": "Test Issue",
# "description": "Test Description",
# "comment": "Test Comment",
# "labels": ["test"],
# "status": "Open",
# "assignee": "Tester",
# "updated": "2025-07-13T12:00:00Z"
# }

View File

@ -16,11 +16,25 @@ def test_error_handling_middleware(test_client, mock_jira_payload):
assert response.status_code == 422
assert "details" in response.json()
def test_webhook_handler(test_client, mock_jira_payload):
# Test successful webhook handling
response = test_client.post("/jira-webhook", json=mock_jira_payload)
def test_webhook_handler(setup_db, test_client, mock_full_jira_payload):
# Test successful webhook handling with full payload
response = test_client.post("/jira-webhook", json=mock_full_jira_payload)
assert response.status_code == 200
assert "response" in response.json()
response_data = response.json()
assert "status" in response_data
assert response_data["status"] in ["success", "skipped"]
if response_data["status"] == "success":
assert "analysis_flags" in response_data
# Validate database storage
from database.models import JiraAnalysis
from database.database import get_db
with get_db() as db:
record = db.query(JiraAnalysis).filter_by(issue_key=mock_full_jira_payload["issueKey"]).first()
assert record is not None
assert record.issue_summary == mock_full_jira_payload["summary"]
assert record.request_payload == mock_full_jira_payload
assert record.project_key == mock_full_jira_payload["projectKey"]
def test_llm_test_endpoint(test_client):
# Test LLM test endpoint
@ -28,11 +42,13 @@ def test_llm_test_endpoint(test_client):
assert response.status_code == 200
assert "response" in response.json()
def test_retry_decorator():
@pytest.mark.asyncio
async def test_retry_decorator():
# Test retry decorator functionality
@app.retry(max_retries=3)
from jira_webhook_llm import retry # Import decorator from main module
@retry(max_retries=3) # Use imported decorator
async def failing_function():
raise Exception("Test error")
with pytest.raises(Exception):
failing_function()
await failing_function()

View File

@ -1,14 +1,20 @@
from fastapi import HTTPException
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
import json
from typing import Optional, List, Union
from sqlalchemy.orm import Session
from pydantic import BaseModel, ConfigDict, field_validator
from datetime import datetime
import uuid
from config import settings
from langfuse import Langfuse
from database.crud import create_analysis_record, get_analysis_record, update_analysis_record
from llm.models import JiraWebhookPayload, AnalysisFlags
from llm.chains import analysis_chain, validate_response
from database.database import get_db_session
webhook_router = APIRouter()
class BadRequestError(HTTPException):
def __init__(self, detail: str):
@ -22,22 +28,37 @@ class ValidationError(HTTPException):
def __init__(self, detail: str):
super().__init__(status_code=422, detail=detail)
class ValidationError(HTTPException):
def __init__(self, detail: str):
super().__init__(status_code=422, detail=detail)
class JiraWebhookHandler:
def __init__(self):
self.analysis_chain = analysis_chain
async def handle_webhook(self, payload: JiraWebhookPayload):
async def handle_webhook(self, payload: JiraWebhookPayload, db: Session):
try:
if not payload.issueKey:
raise BadRequestError("Missing required field: issueKey")
if not payload.summary:
raise BadRequestError("Missing required field: summary")
# Check for existing analysis record
existing_record = get_analysis_record(db, payload.issueKey)
if existing_record:
logger.info(f"Existing analysis record found for {payload.issueKey}. Skipping new analysis.")
return {"status": "skipped", "analysis_flags": existing_record.analysis_result}
# Create new analysis record with initial state
new_record = create_analysis_record(db=db, payload=payload)
update_analysis_record(db=db, record_id=new_record.id, status="processing")
logger.bind(
issue_key=payload.issueKey,
record_id=new_record.id,
timestamp=datetime.utcnow().isoformat()
).info("Received webhook")
).info(f"[{payload.issueKey}] Received webhook")
# Create Langfuse trace if enabled
trace = None
@ -75,33 +96,58 @@ class JiraWebhookHandler:
)
try:
analysis_result = await self.analysis_chain.ainvoke(llm_input)
raw_llm_response = await self.analysis_chain.ainvoke(llm_input)
# Update Langfuse span with output if enabled
if settings.langfuse.enabled and llm_span:
llm_span.update(output=analysis_result)
llm_span.update(output=raw_llm_response)
llm_span.end()
# Validate LLM response
if not validate_response(analysis_result):
logger.warning(f"Invalid LLM response format for {payload.issueKey}")
analysis_result = {
"hasMultipleEscalations": False,
"customerSentiment": "neutral"
}
try:
# Validate using Pydantic model
AnalysisFlags(**raw_llm_response)
except Exception as e:
logger.error(f"[{payload.issueKey}] Invalid LLM response structure: {str(e)}", exc_info=True)
update_analysis_record(
db=db,
record_id=new_record.id,
analysis_result={"hasMultipleEscalations": False, "customerSentiment": "neutral"},
raw_response=str(raw_llm_response),
status="validation_failed"
)
raise ValueError(f"Invalid LLM response format: {str(e)}") from e
logger.debug(f"LLM Analysis Result for {payload.issueKey}: {json.dumps(analysis_result, indent=2)}")
return {"status": "success", "analysis_flags": analysis_result}
logger.debug(f"[{payload.issueKey}] LLM Analysis Result: {json.dumps(raw_llm_response, indent=2)}")
# Update record with final results
update_analysis_record(
db=db,
record_id=new_record.id,
analysis_result=raw_llm_response,
raw_response=str(raw_llm_response), # Store validated result as raw
status="completed"
)
return {"status": "success", "analysis_flags": raw_llm_response}
except Exception as e:
logger.error(f"LLM processing failed for {payload.issueKey}: {str(e)}")
logger.error(f"[{payload.issueKey}] LLM processing failed: {str(e)}")
# Log error to Langfuse if enabled
if settings.langfuse.enabled and llm_span:
llm_span.error(e)
llm_span.end()
update_analysis_record(
db=db,
record_id=new_record.id,
status="failed",
error_message=f"LLM processing failed: {str(e)}"
)
error_id = str(uuid.uuid4())
logger.error(f"[{payload.issueKey}] Error ID: {error_id}")
return {
"status": "error",
"error_id": error_id,
"analysis_flags": {
"hasMultipleEscalations": False,
"customerSentiment": "neutral"
@ -110,12 +156,28 @@ class JiraWebhookHandler:
}
except Exception as e:
logger.error(f"Error processing webhook: {str(e)}")
issue_key = payload.issueKey if payload.issueKey else "N/A"
logger.error(f"[{issue_key}] Error processing webhook: {str(e)}")
import traceback
logger.error(f"Stack trace: {traceback.format_exc()}")
logger.error(f"[{issue_key}] Stack trace: {traceback.format_exc()}")
# Log error to Langfuse if enabled
if settings.langfuse.enabled and trace:
trace.end(error=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)}")
# Initialize handler
webhook_handler = JiraWebhookHandler()
@webhook_router.post("/jira-webhook")
async def jira_webhook_endpoint(payload: JiraWebhookPayload, db: Session = Depends(get_db_session)):
"""Jira webhook endpoint"""
try:
result = await webhook_handler.handle_webhook(payload, db)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error in webhook endpoint: {str(e)}")
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}")