init
This commit is contained in:
1
vw-agentic-rag/service/utils/__init__.py
Normal file
1
vw-agentic-rag/service/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Empty __init__.py to make this a package
|
||||
165
vw-agentic-rag/service/utils/error_handler.py
Normal file
165
vw-agentic-rag/service/utils/error_handler.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
DRY Error Handling and Logging Utilities
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional, Callable
|
||||
from functools import wraps
|
||||
|
||||
from ..sse import create_error_event, create_tool_error_event
|
||||
|
||||
|
||||
class ErrorCode(Enum):
|
||||
"""Error codes for different types of failures"""
|
||||
# Client errors (4xxx)
|
||||
INVALID_REQUEST = 4001
|
||||
MISSING_PARAMETERS = 4002
|
||||
INVALID_SESSION = 4003
|
||||
|
||||
# Server errors (5xxx)
|
||||
LLM_ERROR = 5001
|
||||
TOOL_ERROR = 5002
|
||||
DATABASE_ERROR = 5003
|
||||
MEMORY_ERROR = 5004
|
||||
EXTERNAL_API_ERROR = 5005
|
||||
INTERNAL_ERROR = 5000
|
||||
|
||||
|
||||
class ErrorCategory(Enum):
|
||||
"""Error categories for better organization"""
|
||||
VALIDATION = "validation"
|
||||
LLM = "llm"
|
||||
TOOL = "tool"
|
||||
DATABASE = "database"
|
||||
MEMORY = "memory"
|
||||
EXTERNAL_API = "external_api"
|
||||
INTERNAL = "internal"
|
||||
|
||||
|
||||
class StructuredLogger:
|
||||
"""DRY structured logging with automatic error handling"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.logger = logging.getLogger(name)
|
||||
|
||||
def error(self, msg: str, error: Optional[Exception] = None, category: ErrorCategory = ErrorCategory.INTERNAL,
|
||||
error_code: ErrorCode = ErrorCode.INTERNAL_ERROR, extra: Optional[Dict[str, Any]] = None):
|
||||
"""Log structured error with stack trace"""
|
||||
data: Dict[str, Any] = {
|
||||
"message": msg,
|
||||
"category": category.value,
|
||||
"error_code": error_code.value,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
if error:
|
||||
data.update({
|
||||
"error_type": type(error).__name__,
|
||||
"error_message": str(error),
|
||||
"stack_trace": traceback.format_exc()
|
||||
})
|
||||
|
||||
if extra:
|
||||
data["extra"] = extra
|
||||
|
||||
self.logger.error(json.dumps(data))
|
||||
|
||||
def info(self, msg: str, extra: Optional[Dict[str, Any]] = None):
|
||||
"""Log structured info"""
|
||||
data: Dict[str, Any] = {"message": msg, "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
if extra:
|
||||
data["extra"] = extra
|
||||
self.logger.info(json.dumps(data))
|
||||
|
||||
def warning(self, msg: str, extra: Optional[Dict[str, Any]] = None):
|
||||
"""Log structured warning"""
|
||||
data: Dict[str, Any] = {"message": msg, "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
if extra:
|
||||
data["extra"] = extra
|
||||
self.logger.warning(json.dumps(data))
|
||||
|
||||
|
||||
def get_user_message(category: ErrorCategory) -> str:
|
||||
"""Get user-friendly error messages in English"""
|
||||
messages = {
|
||||
ErrorCategory.VALIDATION: "Invalid request parameters. Please check your input.",
|
||||
ErrorCategory.LLM: "AI service is temporarily unavailable. Please try again later.",
|
||||
ErrorCategory.TOOL: "Tool execution failed. Please retry your request.",
|
||||
ErrorCategory.DATABASE: "Database service is temporarily unavailable.",
|
||||
ErrorCategory.MEMORY: "Session storage issue occurred. Please refresh the page.",
|
||||
ErrorCategory.EXTERNAL_API: "External service connection failed.",
|
||||
ErrorCategory.INTERNAL: "Internal server error. We are working to resolve this."
|
||||
}
|
||||
return messages.get(category, "Unknown error occurred. Please contact technical support.")
|
||||
|
||||
|
||||
def handle_async_errors(category: ErrorCategory, error_code: ErrorCode,
|
||||
stream_callback: Optional[Callable] = None, tool_id: Optional[str] = None):
|
||||
"""DRY decorator for async error handling with streaming support"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
logger = StructuredLogger(func.__module__)
|
||||
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
user_msg = get_user_message(category)
|
||||
|
||||
logger.error(
|
||||
f"Error in {func.__name__}: {str(e)}",
|
||||
error=e,
|
||||
category=category,
|
||||
error_code=error_code,
|
||||
extra={"function": func.__name__, "args_count": len(args)}
|
||||
)
|
||||
|
||||
# Send error event if streaming
|
||||
if stream_callback:
|
||||
if tool_id:
|
||||
await stream_callback(create_tool_error_event(tool_id, func.__name__, user_msg))
|
||||
else:
|
||||
await stream_callback(create_error_event(user_msg))
|
||||
|
||||
# Re-raise with user-friendly message for API responses
|
||||
raise Exception(user_msg) from e
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def handle_sync_errors(category: ErrorCategory, error_code: ErrorCode):
|
||||
"""DRY decorator for sync error handling"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
logger = StructuredLogger(func.__module__)
|
||||
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in {func.__name__}: {str(e)}",
|
||||
error=e,
|
||||
category=category,
|
||||
error_code=error_code,
|
||||
extra={"function": func.__name__}
|
||||
)
|
||||
raise Exception(get_user_message(category)) from e
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def create_error_response(category: ErrorCategory, error_code: ErrorCode,
|
||||
technical_msg: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Create consistent error response format"""
|
||||
return {
|
||||
"user_message": get_user_message(category),
|
||||
"error_code": error_code.value,
|
||||
"category": category.value,
|
||||
"technical_message": technical_msg,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
94
vw-agentic-rag/service/utils/logging.py
Normal file
94
vw-agentic-rag/service/utils/logging.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import logging
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO", format_type: str = "json") -> None:
|
||||
"""Setup structured logging"""
|
||||
if format_type == "json":
|
||||
formatter = JsonFormatter()
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, level.upper()))
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""JSON log formatter"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_data = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
|
||||
# Add extra fields
|
||||
if hasattr(record, "request_id"):
|
||||
log_data["request_id"] = getattr(record, "request_id")
|
||||
if hasattr(record, "session_id"):
|
||||
log_data["session_id"] = getattr(record, "session_id")
|
||||
if hasattr(record, "duration_ms"):
|
||||
log_data["duration_ms"] = getattr(record, "duration_ms")
|
||||
|
||||
return json.dumps(log_data)
|
||||
|
||||
|
||||
class Timer:
|
||||
"""Simple timer context manager"""
|
||||
|
||||
def __init__(self):
|
||||
self.start_time = None
|
||||
self.end_time = None
|
||||
|
||||
def __enter__(self):
|
||||
self.start_time = time.time()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.end_time = time.time()
|
||||
|
||||
@property
|
||||
def elapsed_ms(self) -> int:
|
||||
if self.start_time and self.end_time:
|
||||
return int((self.end_time - self.start_time) * 1000)
|
||||
return 0
|
||||
|
||||
|
||||
def redact_secrets(data: Dict[str, Any], secret_keys: list[str] | None = None) -> Dict[str, Any]:
|
||||
"""Redact sensitive information from logs"""
|
||||
if secret_keys is None:
|
||||
secret_keys = ["api_key", "password", "token", "secret", "key"]
|
||||
|
||||
redacted = {}
|
||||
for key, value in data.items():
|
||||
if any(secret in key.lower() for secret in secret_keys):
|
||||
redacted[key] = "***REDACTED***"
|
||||
elif isinstance(value, dict):
|
||||
redacted[key] = redact_secrets(value, secret_keys)
|
||||
else:
|
||||
redacted[key] = value
|
||||
|
||||
return redacted
|
||||
|
||||
|
||||
def generate_request_id() -> str:
|
||||
"""Generate unique request ID"""
|
||||
return f"req_{int(time.time() * 1000)}_{hash(time.time()) % 10000:04d}"
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int = 1000, suffix: str = "...") -> str:
|
||||
"""Truncate text to maximum length"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
51
vw-agentic-rag/service/utils/middleware.py
Normal file
51
vw-agentic-rag/service/utils/middleware.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Lightweight Error Handling Middleware
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .error_handler import StructuredLogger, ErrorCategory, ErrorCode, create_error_response
|
||||
|
||||
|
||||
class ErrorMiddleware(BaseHTTPMiddleware):
|
||||
"""Concise error handling middleware following DRY principles"""
|
||||
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
self.logger = StructuredLogger(__name__)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
try:
|
||||
return await call_next(request)
|
||||
except HTTPException as e:
|
||||
# HTTP exceptions - map to appropriate categories
|
||||
category = ErrorCategory.VALIDATION if e.status_code < 500 else ErrorCategory.INTERNAL
|
||||
error_code = ErrorCode.INVALID_REQUEST if e.status_code < 500 else ErrorCode.INTERNAL_ERROR
|
||||
|
||||
self.logger.error(
|
||||
f"HTTP {e.status_code}: {e.detail}",
|
||||
category=category,
|
||||
error_code=error_code,
|
||||
extra={"path": str(request.url), "method": request.method}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content=create_error_response(category, error_code, e.detail)
|
||||
)
|
||||
except Exception as e:
|
||||
# Unexpected errors
|
||||
self.logger.error(
|
||||
f"Unhandled error: {str(e)}",
|
||||
error=e,
|
||||
category=ErrorCategory.INTERNAL,
|
||||
error_code=ErrorCode.INTERNAL_ERROR,
|
||||
extra={"path": str(request.url), "method": request.method}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=create_error_response(ErrorCategory.INTERNAL, ErrorCode.INTERNAL_ERROR)
|
||||
)
|
||||
103
vw-agentic-rag/service/utils/templates.py
Normal file
103
vw-agentic-rag/service/utils/templates.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Template utilities for Jinja2 template rendering with LangChain
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from jinja2 import Environment, BaseLoader, TemplateError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateRenderer:
|
||||
"""Jinja2 template renderer for LLM prompts"""
|
||||
|
||||
def __init__(self):
|
||||
self.env = Environment(
|
||||
loader=BaseLoader(),
|
||||
# Enable safe variable substitution
|
||||
autoescape=False,
|
||||
# Custom delimiters to avoid conflicts with common markdown syntax
|
||||
variable_start_string='{{',
|
||||
variable_end_string='}}',
|
||||
block_start_string='{%',
|
||||
block_end_string='%}',
|
||||
comment_start_string='{#',
|
||||
comment_end_string='#}',
|
||||
# Keep linebreaks
|
||||
keep_trailing_newline=True,
|
||||
# Remove unnecessary whitespace
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True
|
||||
)
|
||||
|
||||
def render_template(self, template_string: str, variables: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Render a Jinja2 template string with provided variables
|
||||
|
||||
Args:
|
||||
template_string: The template string with Jinja2 syntax
|
||||
variables: Dictionary of variables to substitute
|
||||
|
||||
Returns:
|
||||
Rendered template string
|
||||
|
||||
Raises:
|
||||
TemplateError: If template rendering fails
|
||||
"""
|
||||
try:
|
||||
template = self.env.from_string(template_string)
|
||||
rendered = template.render(**variables)
|
||||
logger.debug(f"Template rendered successfully with variables: {list(variables.keys())}")
|
||||
return rendered
|
||||
except TemplateError as e:
|
||||
logger.error(f"Template rendering failed: {e}")
|
||||
logger.error(f"Template: {template_string[:200]}...")
|
||||
logger.error(f"Variables: {variables}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during template rendering: {e}")
|
||||
raise TemplateError(f"Template rendering failed: {e}")
|
||||
|
||||
def render_system_prompt(self, template_string: str, variables: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Render system prompt template
|
||||
|
||||
Args:
|
||||
template_string: System prompt template
|
||||
variables: Variables for substitution
|
||||
|
||||
Returns:
|
||||
Rendered system prompt
|
||||
"""
|
||||
return self.render_template(template_string, variables)
|
||||
|
||||
def render_user_prompt(self, template_string: str, variables: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Render user prompt template
|
||||
|
||||
Args:
|
||||
template_string: User prompt template
|
||||
variables: Variables for substitution
|
||||
|
||||
Returns:
|
||||
Rendered user prompt
|
||||
"""
|
||||
return self.render_template(template_string, variables)
|
||||
|
||||
|
||||
# Global template renderer instance
|
||||
template_renderer = TemplateRenderer()
|
||||
|
||||
|
||||
def render_prompt_template(template_string: str, variables: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Convenience function to render prompt templates
|
||||
|
||||
Args:
|
||||
template_string: Template string with Jinja2 syntax
|
||||
variables: Dictionary of variables to substitute
|
||||
|
||||
Returns:
|
||||
Rendered template string
|
||||
"""
|
||||
return template_renderer.render_template(template_string, variables)
|
||||
Reference in New Issue
Block a user