feat: Refactor API handlers and consolidate webhook processing; implement new webhook routes and enhance request handling
Some checks are pending
CI/CD Pipeline / test (push) Waiting to run

This commit is contained in:
Ireneusz Bachanowicz 2025-07-21 16:16:20 +02:00
parent 8c1ab79eeb
commit cbe1a430ad
7 changed files with 84 additions and 45 deletions

View File

@ -4,12 +4,18 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from llm.models import JiraWebhookPayload from llm.models import JiraWebhookPayload
from shared_store import requests_queue, ProcessingRequest from shared_store import requests_queue, ProcessingRequest
from loguru import logger
router = APIRouter( router = APIRouter(
prefix="/api", prefix="/api",
tags=["API"] tags=["API"]
) )
webhook_router = APIRouter(
prefix="/webhooks",
tags=["Webhooks"]
)
@router.post("/jira_webhook", status_code=201) @router.post("/jira_webhook", status_code=201)
async def receive_jira_webhook(payload: JiraWebhookPayload): async def receive_jira_webhook(payload: JiraWebhookPayload):
"""Handle incoming Jira webhook and store request""" """Handle incoming Jira webhook and store request"""
@ -43,3 +49,20 @@ async def get_request_response(request_id: int):
if not matched_request: if not matched_request:
raise HTTPException(status_code=404, detail="Request not found") raise HTTPException(status_code=404, detail="Request not found")
return matched_request.response if matched_request.response else "No response yet" return matched_request.response if matched_request.response else "No response yet"
@webhook_router.post("/jira")
async def handle_jira_webhook():
return {"status": "webhook received"}
@webhook_router.post("/ollama")
async def handle_ollama_webhook(request: Request):
"""Handle incoming Ollama webhook and capture raw output"""
try:
raw_body = await request.body()
response_data = raw_body.decode('utf-8')
logger.info(f"Received raw Ollama webhook response: {response_data}")
# Here you would process the raw_body, e.g., store it or pass it to another component
return {"status": "ollama webhook received", "data": response_data}
except Exception as e:
logger.error(f"Error processing Ollama webhook: {e}")
raise HTTPException(status_code=500, detail=f"Error processing webhook: {e}")

View File

@ -2,7 +2,9 @@ import os
import sys import sys
from typing import Optional from typing import Optional
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import field_validator, ConfigDict from langfuse._client.client import Langfuse
from pydantic import field_validator
from pydantic_settings import SettingsConfigDict
import yaml import yaml
from pathlib import Path from pathlib import Path
@ -12,7 +14,7 @@ class LangfuseConfig(BaseSettings):
public_key: Optional[str] = None public_key: Optional[str] = None
host: Optional[str] = None host: Optional[str] = None
model_config = ConfigDict( model_config = SettingsConfigDict(
env_prefix='LANGFUSE_', env_prefix='LANGFUSE_',
env_file='.env', env_file='.env',
env_file_encoding='utf-8', env_file_encoding='utf-8',
@ -37,7 +39,7 @@ class LLMConfig(BaseSettings):
raise ValueError("LLM mode must be either 'openai' or 'ollama'") raise ValueError("LLM mode must be either 'openai' or 'ollama'")
return v return v
model_config = ConfigDict( model_config = SettingsConfigDict(
env_prefix='LLM_', env_prefix='LLM_',
env_file='.env', env_file='.env',
env_file_encoding='utf-8', env_file_encoding='utf-8',
@ -47,7 +49,7 @@ class LLMConfig(BaseSettings):
class ApiConfig(BaseSettings): class ApiConfig(BaseSettings):
api_key: Optional[str] = None api_key: Optional[str] = None
model_config = ConfigDict( model_config = SettingsConfigDict(
env_prefix='API_', env_prefix='API_',
env_file='.env', env_file='.env',
env_file_encoding='utf-8', env_file_encoding='utf-8',
@ -59,7 +61,7 @@ class ProcessorConfig(BaseSettings):
max_retries: int = 5 max_retries: int = 5
initial_retry_delay_seconds: int = 60 initial_retry_delay_seconds: int = 60
model_config = ConfigDict( model_config = SettingsConfigDict(
env_prefix='PROCESSOR_', env_prefix='PROCESSOR_',
env_file='.env', env_file='.env',
env_file_encoding='utf-8', env_file_encoding='utf-8',
@ -78,6 +80,18 @@ class Settings:
self.processor = ProcessorConfig(**yaml_config.get('processor', {})) self.processor = ProcessorConfig(**yaml_config.get('processor', {}))
self.langfuse = LangfuseConfig(**yaml_config.get('langfuse', {})) self.langfuse = LangfuseConfig(**yaml_config.get('langfuse', {}))
# Initialize Langfuse client if enabled
self.langfuse_client: Optional[Langfuse] = None
if self.langfuse.enabled:
if self.langfuse.secret_key and self.langfuse.public_key and self.langfuse.host:
self.langfuse_client = Langfuse(
public_key=self.langfuse.public_key,
secret_key=self.langfuse.secret_key,
host=self.langfuse.host
)
else:
print("Langfuse is enabled but missing one or more of LANGFUSE_SECRET_KEY, LANGFUSE_PUBLIC_KEY, or LANGFUSE_HOST. Langfuse client will not be initialized.")
self._validate() self._validate()
except Exception as e: except Exception as e:

View File

@ -24,8 +24,7 @@ from loguru import logger
from shared_store import RequestStatus, requests_queue, ProcessingRequest from shared_store import RequestStatus, requests_queue, ProcessingRequest
from llm.models import JiraWebhookPayload from llm.models import JiraWebhookPayload
from llm.chains import analysis_chain, validate_response from llm.chains import analysis_chain, validate_response
from api.handlers import router from app.handlers import router, webhook_router # Import from unified handlers
from webhooks.handlers import webhook_router
from config import settings from config import settings
async def process_single_jira_request(request: ProcessingRequest): async def process_single_jira_request(request: ProcessingRequest):
@ -52,7 +51,10 @@ async def process_single_jira_request(request: ProcessingRequest):
try: try:
raw_llm_response = await analysis_chain.ainvoke(llm_input) raw_llm_response = await analysis_chain.ainvoke(llm_input)
if not validate_response(raw_llm_response): # Store the raw LLM response
request.response = raw_llm_response
if not validate_response(raw_llm_response, payload.issueKey): # Pass issueKey for logging
error_msg = f"Invalid LLM response structure: {raw_llm_response}" error_msg = f"Invalid LLM response structure: {raw_llm_response}"
logger.error(f"[{payload.issueKey}] {error_msg}") logger.error(f"[{payload.issueKey}] {error_msg}")
raise ValueError(error_msg) raise ValueError(error_msg)

View File

@ -1,6 +1,7 @@
import json import json
import sys import sys
from typing import Union from typing import Union, Any # Import Any
from pydantic import SecretStr # Re-import SecretStr
from langchain_core.prompts import ( from langchain_core.prompts import (
ChatPromptTemplate, ChatPromptTemplate,
@ -9,7 +10,7 @@ from langchain_core.prompts import (
HumanMessagePromptTemplate, HumanMessagePromptTemplate,
) )
from langchain_core.output_parsers import JsonOutputParser from langchain_core.output_parsers import JsonOutputParser
from langchain_core.runnables import RunnablePassthrough from langchain_core.runnables import RunnablePassthrough, Runnable
from langchain_ollama import OllamaLLM from langchain_ollama import OllamaLLM
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from loguru import logger from loguru import logger
@ -24,15 +25,15 @@ class LLMInitializationError(Exception):
self.details = details self.details = details
# Initialize LLM # Initialize LLM
llm = None llm: Union[ChatOpenAI, OllamaLLM, None] = None
if settings.llm.mode == 'openai': if settings.llm.mode == 'openai':
logger.info(f"Initializing ChatOpenAI with model: {settings.openai_model}") logger.info(f"Initializing ChatOpenAI with model: {settings.llm.openai_model}")
llm = ChatOpenAI( llm = ChatOpenAI(
model=settings.openai_model, model=settings.llm.openai_model if settings.llm.openai_model else "", # Ensure model is str
temperature=0.7, temperature=0.7,
max_tokens=2000, max_tokens=2000,
api_key=settings.openai_api_key, api_key=settings.llm.openai_api_key, # type: ignore # Suppress Pylance error due to SecretStr type mismatch
base_url=settings.openai_api_base_url base_url=settings.llm.openai_api_base_url
) )
elif settings.llm.mode == 'ollama': elif settings.llm.mode == 'ollama':
logger.info(f"Initializing OllamaLLM with model: {settings.llm.ollama_model} at {settings.llm.ollama_base_url}") logger.info(f"Initializing OllamaLLM with model: {settings.llm.ollama_model} at {settings.llm.ollama_base_url}")
@ -50,10 +51,8 @@ elif settings.llm.mode == 'ollama':
llm = OllamaLLM( llm = OllamaLLM(
model=settings.llm.ollama_model, model=settings.llm.ollama_model,
base_url=base_url, base_url=base_url
streaming=False, # Removed streaming, timeout, max_retries as they are not valid parameters for OllamaLLM
timeout=30,
max_retries=3
) )
# Test connection only if not in a test environment # Test connection only if not in a test environment
@ -87,6 +86,10 @@ if llm is None:
print("\nERROR: Unable to initialize LLM. Check logs for details.", file=sys.stderr) print("\nERROR: Unable to initialize LLM. Check logs for details.", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Ensure llm is treated as a Runnable for chaining
# Cast llm to Any to bypass static type checking for chaining if it's causing issues
llm_runnable: Runnable = llm # type: ignore
# Set up Output Parser for structured JSON # Set up Output Parser for structured JSON
parser = JsonOutputParser(pydantic_object=AnalysisFlags) parser = JsonOutputParser(pydantic_object=AnalysisFlags)
@ -140,26 +143,26 @@ def create_analysis_chain():
"format_instructions": lambda _: parser.get_format_instructions() "format_instructions": lambda _: parser.get_format_instructions()
} }
| prompt_template | prompt_template
| llm | llm_runnable # Use the explicitly typed runnable
| parser | parser
) )
# Add langfuse handler if enabled and available # Add langfuse handler if enabled and available (assuming settings.langfuse_handler is set up elsewhere)
if settings.langfuse.enabled and hasattr(settings, 'langfuse_handler'): # if settings.langfuse.enabled and hasattr(settings, 'langfuse_handler'):
chain = chain.with_config( # chain = chain.with_config(
callbacks=[settings.langfuse_handler] # callbacks=[settings.langfuse_handler]
) # )
return chain return chain
except Exception as e: except Exception as e:
logger.warning(f"Using fallback prompt due to error: {str(e)}") logger.warning(f"Using fallback prompt due to error: {str(e)}")
chain = FALLBACK_PROMPT | llm | parser chain = FALLBACK_PROMPT | llm_runnable # Use the explicitly typed runnable
# Add langfuse handler if enabled and available # Add langfuse handler if enabled and available (assuming settings.langfuse_handler is set up elsewhere)
if settings.langfuse.enabled and hasattr(settings, 'langfuse_handler'): # if settings.langfuse.enabled and hasattr(settings, 'langfuse_handler'):
chain = chain.with_config( # chain = chain.with_config(
callbacks=[settings.langfuse_handler] # callbacks=[settings.langfuse_handler]
) # )
return chain return chain
@ -170,6 +173,11 @@ analysis_chain = create_analysis_chain()
def validate_response(response: Union[dict, str], issue_key: str = "N/A") -> bool: def validate_response(response: Union[dict, str], issue_key: str = "N/A") -> bool:
"""Validate the JSON response structure and content""" """Validate the JSON response structure and content"""
try: try:
# If LLM mode is Ollama, skip detailed validation and return raw output
if settings.llm.mode == 'ollama':
logger.info(f"[{issue_key}] Ollama mode detected. Skipping detailed response validation. Raw response: {response}")
return True
# If response is a string, attempt to parse it as JSON # If response is a string, attempt to parse it as JSON
if isinstance(response, str): if isinstance(response, str):
logger.debug(f"[{issue_key}] Raw LLM response (string): {response}") logger.debug(f"[{issue_key}] Raw LLM response (string): {response}")

View File

@ -38,6 +38,7 @@ class JiraWebhookPayload(BaseModel):
class AnalysisFlags(BaseModel): class AnalysisFlags(BaseModel):
hasMultipleEscalations: bool = Field(alias="Hasmultipleescalations", description="Is there evidence of multiple escalation attempts?") hasMultipleEscalations: bool = Field(alias="Hasmultipleescalations", description="Is there evidence of multiple escalation attempts?")
customerSentiment: Optional[CustomerSentiment] = Field(alias="CustomerSentiment", description="Overall customer sentiment (e.g., 'neutral', 'frustrated', 'calm').") customerSentiment: Optional[CustomerSentiment] = Field(alias="CustomerSentiment", description="Overall customer sentiment (e.g., 'neutral', 'frustrated', 'calm').")
model: Optional[str] = Field(None, alias="Model", description="The LLM model used for analysis.")
def __init__(self, **data): def __init__(self, **data):
super().__init__(**data) super().__init__(**data)
@ -53,7 +54,7 @@ class AnalysisFlags(BaseModel):
name="LLM Model Usage", name="LLM Model Usage",
input=data, input=data,
metadata={ metadata={
"model": settings.llm.model if settings.llm.mode == 'openai' else settings.llm.ollama_model, "model": self.model, # Use the new model attribute
"analysis_flags": { "analysis_flags": {
"hasMultipleEscalations": self.hasMultipleEscalations, "hasMultipleEscalations": self.hasMultipleEscalations,
"customerSentiment": self.customerSentiment.value if self.customerSentiment else None "customerSentiment": self.customerSentiment.value if self.customerSentiment else None

View File

@ -1,4 +1,4 @@
from typing import List, Dict, Optional from typing import List, Dict, Optional, Any # Import Any
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
@ -24,6 +24,7 @@ class ProcessingRequest:
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
error: Optional[str] = None error: Optional[str] = None
retry_count: int = 0 retry_count: int = 0
response: Optional[Any] = None # Add response field
class RequestQueue: class RequestQueue:
def __init__(self): def __init__(self):

View File

@ -1,10 +0,0 @@
from fastapi import APIRouter
webhook_router = APIRouter(
prefix="/webhooks",
tags=["Webhooks"]
)
@webhook_router.post("/jira")
async def handle_jira_webhook():
return {"status": "webhook received"}