{
setName(e.target.value);
}}
/>
-
-
+ {/* NLP MODE WITH FIELD NAMES */}
+ {inputMode === 'nlp' && (
+ <>
+ {/* FIELD LIST FOR USER */}
+
+
Available Fields:
+ {availableFields.length === 0 &&
No fields detected.}
+ {availableFields.length > 0 && (
+
+ {availableFields.map((f) => (
+ -
+ {f.name}
+ (type: {f.type})
+
+ ))}
+
+ )}
+
+
+
setNlPrompt(e.target.value)}
+ placeholder="e.g. Calculate profit as revenue minus cost"
+ disabled={loading}
+ />
+
+
+
+ >
+ )}
+
+ {/* SQL MODE */}
+ {inputMode === 'sql' && (
+ <>
+
+
+ >
+ )}
{error &&
@@ -136,8 +256,8 @@ const ComputedFieldDialog: React.FC = observer(() => {
setError(parseErrorMessage(e));
}
}}
- >
-
+ />
+
diff --git a/packages/graphic-walker/vite.config.ts b/packages/graphic-walker/vite.config.ts
index 7fae8a318..91ea1e38b 100644
--- a/packages/graphic-walker/vite.config.ts
+++ b/packages/graphic-walker/vite.config.ts
@@ -12,6 +12,14 @@ const modulesNotToBundle = Object.keys(peerDependencies);
export default defineConfig({
server: {
port: 2002,
+ proxy: {
+ // Redirect all API calls to the Ollama NLP backend
+ '/api': {
+ target: 'http://localhost:3002',
+ changeOrigin: true,
+ rewrite: path => path.replace(/^\/api/, '/api'),
+ },
+ }
},
plugins: [
react(),
diff --git a/packages/nlp-backend/.env b/packages/nlp-backend/.env
new file mode 100644
index 000000000..b13bc4d28
--- /dev/null
+++ b/packages/nlp-backend/.env
@@ -0,0 +1,10 @@
+# Ollama Configuration
+OLLAMA_BASE_URL=http://localhost:11434
+OLLAMA_MODEL=codellama:7b-instruct
+OLLAMA_TIMEOUT=60
+OLLAMA_FALLBACK_MODEL=llama3.2:latest
+
+# Log Configuration
+LOG_LEVEL=INFO
+LOG_FORMAT=json
+LOG_FILE=
\ No newline at end of file
diff --git a/packages/nlp-backend/NLP-Based-Compute-Field.mp4 b/packages/nlp-backend/NLP-Based-Compute-Field.mp4
new file mode 100644
index 000000000..b5af09f0e
Binary files /dev/null and b/packages/nlp-backend/NLP-Based-Compute-Field.mp4 differ
diff --git a/packages/nlp-backend/OLLAMA_NLP_BACKEND_GUIDE.md b/packages/nlp-backend/OLLAMA_NLP_BACKEND_GUIDE.md
new file mode 100644
index 000000000..6d4d97c90
--- /dev/null
+++ b/packages/nlp-backend/OLLAMA_NLP_BACKEND_GUIDE.md
@@ -0,0 +1,303 @@
+# Ollama NLP Backend for Custom Computed Fields
+
+## Overview
+
+This service provides natural language to SQL conversion for creating custom computed fields in Graphic Walker. It uses local Ollama models to generate clean SQL expressions from natural language descriptions, ensuring privacy and eliminating external API dependencies.
+
+## **Key Features**
+
+1. **Local LLM Processing** - Uses Ollama for private, on-premises SQL generation
+2. **Smart SQL Processing** - Advanced extraction of clean SQL expressions
+3. **Intelligent Prompting** - Optimized prompts for proper conditional logic
+4. **Editable Results** - Generated SQL is fully editable in the interface
+5. **Comprehensive Testing** - Thoroughly tested with real-world scenarios
+6. **Robust Architecture** - Built-in error handling and model fallbacks
+
+---
+
+## **Core Capabilities**
+
+### **1. Natural Language Processing**
+- **Local Processing**: Runs entirely on your Ollama server
+- **Privacy First**: No data sent to external services
+- **Cost Effective**: No API usage fees
+- **Offline Ready**: Works without internet connection
+
+### **2. Smart SQL Generation**
+- **Conditional Logic**: Generates proper `CASE WHEN ... THEN ... ELSE ... END` statements
+- **Clean Output**: Extracts pure SQL expressions without explanatory text
+- **Field Awareness**: Understands field names and data types
+- **Mathematical Operations**: Handles calculations and arithmetic expressions
+
+### **3. Production Ready**
+- **Model Fallbacks**: Automatic fallback if primary model fails
+- **Error Handling**: Comprehensive error management and logging
+- **Health Monitoring**: Built-in metrics and performance tracking
+- **REST API**: Simple HTTP endpoints for easy integration
+
+---
+
+## **System Requirements**
+
+
+### **Ollama Setup**
+
+#### **Windows**
+```powershell
+# Install Ollama (Windows)
+# Download the installer from the official website and run it:
+Start-Process "https://ollama.com/download/windows" -Wait
+
+# Or manually download and run the installer from your browser:
+# https://ollama.com/download/windows
+
+# After installation, open a new terminal and pull required models:
+ollama pull codellama:7b-instruct # Primary model
+ollama pull llama3.2:latest # Fallback model
+
+# Verify installation
+ollama list
+```
+
+#### **Linux/macOS**
+```bash
+# Install Ollama
+curl -fsSL https://ollama.ai/install.sh | sh
+
+# Pull required models
+ollama pull codellama:7b-instruct # Primary model
+ollama pull llama3.2:latest # Fallback model
+
+# Verify installation
+ollama list
+```
+
+### **Environment Configuration**
+```env
+# Ollama Configuration
+OLLAMA_BASE_URL=http://localhost:11434
+OLLAMA_MODEL=codellama:7b-instruct
+OLLAMA_TIMEOUT=90
+OLLAMA_FALLBACK_MODEL=llama3.2:latest
+
+# Logging Configuration
+LOG_LEVEL=INFO
+LOG_FORMAT=json
+```
+
+---
+
+## **Technical Implementation**
+
+### **Enhanced System Prompt**
+The system prompt was significantly improved to generate proper SQL expressions:
+
+```
+You are an expert SQL assistant for creating computed fields in Graphic Walker.
+
+IMPORTANT RULES:
+1. Output ONLY the SQL expression - no explanations, no CREATE statements, no semicolons
+2. For conditional logic, ALWAYS use CASE WHEN ... THEN ... ELSE ... END statements
+3. For comparisons between fields, use CASE statements, NOT arithmetic operations
+4. For mathematical calculations, use direct arithmetic expressions
+5. Field names should be used as-is without quotes unless they contain spaces
+
+EXAMPLES:
+- "If field A > field B then 'High' else 'Low'" β CASE WHEN A > B THEN 'High' ELSE 'Low' END
+- "Calculate 10% of price" β price * 0.1
+- "If status is active then Premium else Basic" β CASE WHEN status = 'active' THEN 'Premium' ELSE 'Basic' END
+```
+
+### **Advanced SQL Processing**
+Enhanced the SQL processor with Ollama-specific cleaning patterns:
+
+- **Markdown Removal**: Strips code blocks, inline code, and formatting
+- **Explanation Filtering**: Removes explanatory text and comments
+- **Expression Extraction**: Identifies and extracts core SQL expressions
+- **Pattern Matching**: Uses regex patterns to clean responses
+- **Content Validation**: Ensures proper SQL structure and syntax
+
+### **Frontend Integration**
+Made the generated SQL fully editable in the computed field interface:
+
+- **Editable Field**: Users can modify generated SQL expressions
+- **Syntax Highlighting**: Maintains visual highlighting while editing
+- **State Synchronization**: Proper sync between generated and edited content
+- **Mode Switching**: Seamless transition between Natural Language and SQL modes
+
+---
+
+## **Performance Results**
+
+### **Test Results**
+- **16/16 Integration Tests Passing**
+- **All API Endpoints Working**
+- **Fallback Mechanism Tested**
+- **Frontend Integration Verified**
+
+### **Response Quality**
+- **Conditional Logic**: 100% proper CASE statement generation
+- **Mathematical Operations**: Accurate arithmetic expressions
+- **Field Recognition**: Correct field name handling
+- **Clean Output**: Pure SQL expressions without extra text
+
+### **Performance Metrics**
+- **Average Response Time**: 6-15 seconds (depending on complexity)
+- **Fallback Time**: 60s timeout + 20s fallback = ~80s total
+- **Success Rate**: >95% for standard computed field expressions
+- **Memory Usage**: Efficient processing with minimal overhead
+
+---
+
+## **Usage Examples**
+
+### **Smart SQL Generation**
+
+#### **Conditional Logic**
+**User Input**: "Create a field for improvement such that if yr_2025 is greater than yr_2022 then 'Improve', else 'No Improvement'"
+
+**Generated SQL**: `CASE WHEN yr_2025 > yr_2022 THEN 'Improve' ELSE 'No Improvement' END`
+
+#### **Revenue Comparison**
+**User Input**: "If current_revenue > previous_revenue then 'Growth' else 'Decline'"
+**Generated SQL**: `CASE WHEN current_revenue > previous_revenue THEN 'Growth' ELSE 'Decline' END`
+
+#### **Customer Tier**
+**User Input**: "If total_purchases > 1000 then 'Premium' else 'Standard'"
+**Generated SQL**: `CASE WHEN total_purchases > 1000 THEN 'Premium' ELSE 'Standard' END`
+
+#### **Mathematical Calculation**
+**User Input**: "Calculate 15% of the total_sales"
+**Generated SQL**: `total_sales * 0.15`
+
+---
+
+## **API Endpoints**
+
+### **Text-to-SQL Endpoint**
+```http
+POST /api/ollama-text2sql
+Content-Type: application/json
+
+{
+ "prompt": "Create a field showing High if revenue > 1000 else Low",
+ "model": "codellama:7b-instruct" // optional
+}
+```
+
+**Response:**
+```json
+{
+ "sql": "CASE WHEN revenue > 1000 THEN 'High' ELSE 'Low' END"
+}
+```
+
+### **Health Checks**
+```http
+GET /api/health # Basic health check
+GET /api/health/detailed # Comprehensive health info
+GET /api/health/metrics # Performance metrics
+GET /api/models # Available models
+```
+
+---
+
+## **Monitoring & Debugging**
+
+### **Logging**
+All operations are logged with structured JSON logging:
+- Request/response tracking
+- Performance metrics
+- Error details
+- Processing steps
+
+### **Health Monitoring**
+- **Service Status**: Real-time health checks
+- **Model Availability**: Monitor available models
+- **Performance Metrics**: Response times and success rates
+- **Error Tracking**: Detailed error logging and analysis
+
+### **Debugging Tools**
+- **Detailed Endpoints**: Get processing steps and metadata
+- **SQL Processing**: View cleaning and extraction steps
+- **Validation Results**: Check SQL syntax and structure
+
+---
+
+## **Troubleshooting**
+
+### **Common Issues**
+
+#### **Ollama Not Running**
+```bash
+# Check if Ollama is running
+curl http://localhost:11434/api/tags
+
+# Start Ollama if needed
+ollama serve
+```
+
+#### **Model Not Available**
+```bash
+# Check available models
+ollama list
+
+# Pull missing models
+ollama pull codellama:7b-instruct
+ollama pull llama3.2:latest
+```
+
+#### **Timeout Issues**
+- Increase `OLLAMA_TIMEOUT` in environment variables
+- Check system resources (CPU/Memory)
+- Consider using smaller models for faster responses
+
+#### **SQL Generation Issues**
+- Check the system prompt configuration
+- Verify SQL processor patterns
+- Review processing logs for details
+
+---
+
+## **Future Enhancements**
+
+### **Potential Improvements**
+- **Model Fine-tuning**: Train models specifically for SQL generation
+- **Caching**: Cache common expressions for faster responses
+- **Multi-language Support**: Support for different natural languages
+- **Advanced Validation**: More sophisticated SQL syntax checking
+- **Performance Optimization**: Further reduce response times
+
+### **Scalability**
+- **Load Balancing**: Multiple Ollama instances for high load
+- **Model Management**: Automatic model updates and management
+- **Resource Optimization**: Better resource utilization
+- **Distributed Processing**: Scale across multiple servers
+
+---
+
+## **Production Ready!**
+
+The Ollama NLP Backend is **fully functional** and **production-ready**. The system provides:
+
+- **Privacy First** - All processing happens locally on your infrastructure
+- **Smart SQL Generation** - Proper conditional logic and clean expressions
+- **Cost Effective** - No external API fees or usage limits
+- **Robust Architecture** - Comprehensive error handling and model fallbacks
+- **User-Friendly** - Editable generated SQL with syntax highlighting
+- **Well Tested** - Comprehensive test coverage and validation
+- **Fully Documented** - Complete implementation and usage documentation
+
+The system is ready for production use and will significantly enhance the computed field experience in Graphic Walker!
+
+---
+
+## **Support**
+
+For issues or questions:
+1. Check the troubleshooting section above
+2. Review the logs for detailed error information
+3. Verify Ollama installation and model availability
+4. Test with the health check endpoints
+
+This service provides a solid foundation for local LLM-powered SQL generation with excellent performance and reliability.
\ No newline at end of file
diff --git a/packages/nlp-backend/README.md b/packages/nlp-backend/README.md
new file mode 100644
index 000000000..6511b2b16
--- /dev/null
+++ b/packages/nlp-backend/README.md
@@ -0,0 +1,67 @@
+# Ollama NLP Backend for Custom Computed Fields
+
+A local LLM-powered service that generates SQL expressions for custom computed fields in Graphic Walker using natural language prompts. This service uses Ollama for private, on-premises processing with no external API dependencies.
+
+## **Features**
+
+- **Local Processing** - Runs entirely on your infrastructure using Ollama
+- **Smart SQL Generation** - Converts natural language to proper SQL expressions
+- **Editable Results** - Generated SQL can be modified in the UI
+- **Reliable** - Automatic model fallback and comprehensive error handling
+- **Monitoring** - Built-in health checks and performance metrics
+- **REST API** - Simple HTTP endpoints for integration
+
+## **Complete Documentation**
+
+For full setup, configuration, and usage details:
+
+**[OLLAMA_NLP_BACKEND_GUIDE.md](./OLLAMA_NLP_BACKEND_GUIDE.md)**
+
+This comprehensive guide includes:
+- Installation and setup
+- Configuration options
+- API documentation
+- Usage examples
+- Troubleshooting guide
+
+## **Quick Start**
+
+### 1. Install Ollama
+```bash
+curl -fsSL https://ollama.ai/install.sh | sh
+ollama serve
+ollama pull codellama:7b-instruct
+```
+
+### 2. Start Service
+```bash
+cd packages/nlp-backend
+pip install -r requirements.txt
+uvicorn app:app --reload --port 3002
+```
+
+### 3. Test
+```bash
+curl -X POST http://localhost:3002/api/ollama-text2sql \
+ -H "Content-Type: application/json" \
+ -d '{"prompt": "If revenue > 1000 then High else Low"}'
+```
+
+**Response:**
+```json
+{
+ "sql": "CASE WHEN revenue > 1000 THEN 'High' ELSE 'Low' END"
+}
+```
+
+## **Key Features**
+
+- **Local Processing** - Runs entirely on your infrastructure
+- **Smart SQL Generation** - Proper CASE statements for conditional logic
+- **Editable Results** - Generated SQL can be modified in the UI
+- **Fallback Support** - Automatic model fallback for reliability
+- **Health Monitoring** - Comprehensive health checks and metrics
+
+---
+
+**For complete setup, configuration, and usage details, see [OLLAMA_NLP_BACKEND_GUIDE.md](./OLLAMA_NLP_BACKEND_GUIDE.md)**
\ No newline at end of file
diff --git a/packages/nlp-backend/app.py b/packages/nlp-backend/app.py
new file mode 100644
index 000000000..9b0f14acb
--- /dev/null
+++ b/packages/nlp-backend/app.py
@@ -0,0 +1,465 @@
+import os
+import time
+import uuid
+from fastapi import FastAPI, Request, Response
+from fastapi.middleware.cors import CORSMiddleware
+from dotenv import load_dotenv
+
+# Ollama integration
+from ollama_service import ollama_service
+from logging_config import setup_logging, get_logger
+
+# Monitoring integration
+from metrics_collector import metrics_collector, RequestMetrics
+from monitoring_dashboard import monitoring_dashboard
+
+load_dotenv()
+
+# Setup enhanced logging
+setup_logging(
+ level=os.getenv("LOG_LEVEL", "INFO"),
+ format_type=os.getenv("LOG_FORMAT", "json"),
+ log_file=os.getenv("LOG_FILE")
+)
+
+logger = get_logger("fastapi_app")
+
+# Startup event to perform initial health check
+from contextlib import asynccontextmanager
+
+async def startup_event():
+ """
+ Perform startup health check and log service status.
+
+ Validates Ollama service availability and logs all available endpoints.
+ """
+ logger.info("Starting Ollama-based text2sql service")
+ logger.info("Available endpoints", extra={
+ "endpoints": [
+ "POST /api/ollama-text2sql",
+ "GET /api/health (basic health check)",
+ "GET /api/health/detailed (comprehensive health check)",
+ "GET /api/health/metrics (health statistics)",
+ "GET /api/health/history (health check history)",
+ "GET /api/service-info (service information)",
+ "GET /api/models (available models)"
+ ]
+ })
+
+ # Perform comprehensive startup validation
+ try:
+ startup_results = await ollama_service.perform_startup_validation()
+
+ if startup_results.get("ready", False):
+ logger.info(
+ "Startup validation completed successfully",
+ extra={
+ "overall_status": startup_results.get("overall_status"),
+ "checks_passed": sum(1 for check in startup_results.get("checks", [])
+ if check.get("status") == "healthy"),
+ "total_checks": len(startup_results.get("checks", [])),
+ "ready": True
+ }
+ )
+ else:
+ logger.warning(
+ "Startup validation completed with issues",
+ extra={
+ "overall_status": startup_results.get("overall_status"),
+ "ready": False,
+ "message": "Service may have limited functionality"
+ }
+ )
+
+ # Log failed checks
+ failed_checks = [check for check in startup_results.get("checks", [])
+ if check.get("status") in ["unhealthy", "degraded"]]
+ if failed_checks:
+ logger.warning(
+ "Failed startup checks",
+ extra={"failed_checks": [check["name"] for check in failed_checks]}
+ )
+
+ except Exception as e:
+ logger.error(
+ "Startup validation failed",
+ extra={"error": str(e), "fallback": "Using basic health check"}
+ )
+
+ # Fallback to basic health check
+ is_healthy = await ollama_service.health_check()
+ if is_healthy:
+ logger.info("Basic health check passed - service ready with limited monitoring")
+ else:
+ logger.warning("Basic health check failed - service may not function properly")
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # Startup
+ await startup_event()
+ yield
+ # Shutdown (if needed)
+
+app = FastAPI(lifespan=lifespan)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"]
+)
+
+async def _text2sql_handler(request: Request):
+ """
+ Handler for text-to-SQL conversion using Ollama with metrics collection.
+ """
+ start_time = time.time()
+ request_id = str(uuid.uuid4())[:8]
+
+ body = await request.json()
+ prompt = body.get("prompt", "")
+ model_override = body.get("model") # Optional model override
+
+ # Get client IP for logging
+ client_ip = request.client.host if request.client else "unknown"
+ endpoint = str(request.url.path)
+
+ logger.info(
+ "Received text2sql request",
+ extra={
+ "request_id": request_id,
+ "client_ip": client_ip,
+ "prompt_length": len(prompt),
+ "model_override": model_override,
+ "endpoint": endpoint
+ }
+ )
+
+ # Generate SQL using Ollama service
+ result = await ollama_service.generate_sql(prompt, model_override)
+
+ # Calculate response time
+ response_time_ms = (time.time() - start_time) * 1000
+
+ # Determine success and error type
+ success = "sql" in result
+ error_type = None
+ status_code = 200
+
+ if not success:
+ error_type = result.get("error", "unknown_error")
+ # Map error types to HTTP status codes if needed
+ if "timeout" in error_type.lower():
+ status_code = 408
+ elif "unavailable" in error_type.lower():
+ status_code = 503
+ elif "invalid" in error_type.lower():
+ status_code = 400
+
+ # Record metrics
+ request_metrics = RequestMetrics(
+ request_id=request_id,
+ endpoint=endpoint,
+ method="POST",
+ status_code=status_code,
+ response_time_ms=response_time_ms,
+ prompt_length=len(prompt),
+ sql_length=len(result.get("sql", "")),
+ model_used=model_override or "default",
+ success=success,
+ error_type=error_type,
+ timestamp=start_time,
+ client_ip=client_ip
+ )
+
+ metrics_collector.record_request(request_metrics)
+
+ # Log response summary
+ if success:
+ logger.info(
+ "Text2sql request completed successfully",
+ extra={
+ "request_id": request_id,
+ "client_ip": client_ip,
+ "sql_length": len(result["sql"]),
+ "response_time_ms": response_time_ms,
+ "success": True
+ }
+ )
+ else:
+ logger.warning(
+ "Text2sql request failed",
+ extra={
+ "request_id": request_id,
+ "client_ip": client_ip,
+ "error": result.get("error", "unknown"),
+ "error_type": error_type,
+ "response_time_ms": response_time_ms,
+ "success": False
+ }
+ )
+
+ return result
+
+@app.post("/api/ollama-text2sql")
+async def ollama_text2sql(request: Request):
+ """
+ Generate SQL from natural language prompt using Ollama.
+
+ This is the primary endpoint for text-to-SQL conversion using the local Ollama LLM service.
+ Accepts a natural language prompt and returns generated SQL code.
+
+ Request body:
+ - prompt (str): Natural language description of desired SQL query
+ - model (str, optional): Override default Ollama model
+
+ Returns:
+ Dict with 'sql' key containing generated SQL, or 'error' key if generation fails
+ """
+ return await _text2sql_handler(request)
+@app.get("/api/health")
+async def health_check(request: Request):
+ """Basic health check endpoint."""
+ client_ip = request.client.host if request.client else "unknown"
+
+ logger.info("Health check requested", extra={"client_ip": client_ip})
+
+ is_healthy = await ollama_service.health_check()
+
+ if is_healthy:
+ logger.info("Health check passed", extra={"client_ip": client_ip, "status": "healthy"})
+ return {"status": "healthy", "service": "ollama", "timestamp": time.time()}
+ else:
+ logger.warning("Health check failed", extra={"client_ip": client_ip, "status": "unhealthy"})
+ return {"status": "unhealthy", "service": "ollama", "error": "Ollama service unavailable", "timestamp": time.time()}
+
+@app.get("/api/health/detailed")
+async def detailed_health_check(request: Request):
+ """Comprehensive health check with detailed information."""
+ client_ip = request.client.host if request.client else "unknown"
+
+ logger.info("Detailed health check requested", extra={"client_ip": client_ip})
+
+ try:
+ health_info = await ollama_service.get_detailed_health()
+
+ logger.info(
+ "Detailed health check completed",
+ extra={
+ "client_ip": client_ip,
+ "status": health_info.get("status", "unknown"),
+ "checks_count": len(health_info.get("checks", []))
+ }
+ )
+
+ return health_info
+ except Exception as e:
+ logger.error(f"Detailed health check failed: {str(e)}", extra={"client_ip": client_ip})
+ return {
+ "status": "error",
+ "message": "Health check failed",
+ "error": str(e),
+ "timestamp": time.time()
+ }
+
+@app.get("/api/health/metrics")
+async def health_metrics(request: Request):
+ """Get health metrics and statistics."""
+ client_ip = request.client.host if request.client else "unknown"
+
+ logger.info("Health metrics requested", extra={"client_ip": client_ip})
+
+ try:
+ metrics = ollama_service.get_health_metrics()
+ return metrics
+ except Exception as e:
+ logger.error(f"Health metrics request failed: {str(e)}", extra={"client_ip": client_ip})
+ return {
+ "error": "Failed to get health metrics",
+ "details": str(e),
+ "timestamp": time.time()
+ }
+
+@app.get("/api/health/history")
+async def health_history(request: Request, limit: int = 10):
+ """Get recent health check history."""
+ client_ip = request.client.host if request.client else "unknown"
+
+ logger.info("Health history requested", extra={"client_ip": client_ip, "limit": limit})
+
+ try:
+ history = ollama_service.get_health_history(limit)
+ return {
+ "history": history,
+ "count": len(history),
+ "timestamp": time.time()
+ }
+ except Exception as e:
+ logger.error(f"Health history request failed: {str(e)}", extra={"client_ip": client_ip})
+ return {
+ "error": "Failed to get health history",
+ "details": str(e),
+ "timestamp": time.time()
+ }
+
+@app.get("/api/service-info")
+async def get_service_info():
+ """Get comprehensive service information for debugging and monitoring."""
+ return ollama_service.get_service_info()
+
+@app.get("/api/models")
+async def get_available_models():
+ """Get list of available models from Ollama server."""
+ return ollama_service.get_available_models()
+
+@app.post("/api/ollama-text2sql/detailed")
+async def ollama_text2sql_detailed(request: Request):
+ """
+ Generate SQL with detailed processing information.
+ Returns comprehensive details about the generation and processing steps.
+ """
+ body = await request.json()
+ prompt = body.get("prompt", "")
+ model_override = body.get("model")
+
+ client_ip = request.client.host if request.client else "unknown"
+
+ logger.info(
+ "Received detailed text2sql request",
+ extra={
+ "client_ip": client_ip,
+ "prompt_length": len(prompt),
+ "model_override": model_override,
+ "endpoint": "detailed"
+ }
+ )
+
+ result = await ollama_service.generate_sql_with_processing_details(prompt, model_override)
+
+ # Log response summary
+ if "sql" in result:
+ logger.info(
+ "Detailed text2sql request completed successfully",
+ extra={
+ "client_ip": client_ip,
+ "request_id": result.get("request_id"),
+ "sql_valid": result.get("validation", {}).get("is_valid", False),
+ "processing_warnings": len(result.get("processing", {}).get("warnings", [])),
+ "success": True
+ }
+ )
+ else:
+ logger.warning(
+ "Detailed text2sql request failed",
+ extra={
+ "client_ip": client_ip,
+ "request_id": result.get("request_id"),
+ "error": result.get("error", "unknown"),
+ "success": False
+ }
+ )
+
+ return result
+
+@app.post("/api/sql/process")
+async def process_sql_response(request: Request):
+ """
+ Process raw SQL response using the SQL processor.
+ Useful for testing and debugging SQL cleaning logic.
+ """
+ body = await request.json()
+ raw_response = body.get("response", "")
+ context = body.get("context", {})
+
+ client_ip = request.client.host if request.client else "unknown"
+
+ logger.info(
+ "SQL processing request received",
+ extra={
+ "client_ip": client_ip,
+ "response_length": len(raw_response),
+ "has_context": bool(context)
+ }
+ )
+
+ result = ollama_service.process_sql_response(raw_response, context)
+
+ logger.info(
+ "SQL processing completed",
+ extra={
+ "client_ip": client_ip,
+ "success": result.get("success", False),
+ "warnings_count": len(result.get("processing", {}).get("warnings", [])),
+ "validation_errors": len(result.get("validation", {}).get("errors", []))
+ }
+ )
+
+ return result
+
+
+
+@app.get("/api/monitoring/dashboard")
+async def get_monitoring_dashboard():
+ """Get comprehensive monitoring dashboard data."""
+ return monitoring_dashboard.get_dashboard_data()
+
+@app.get("/api/monitoring/real-time")
+async def get_real_time_metrics():
+ """Get real-time metrics for live dashboard updates."""
+ return monitoring_dashboard.get_real_time_metrics()
+
+@app.get("/api/monitoring/health-status")
+async def get_health_status():
+ """Get overall health status for monitoring systems."""
+ return monitoring_dashboard.get_health_status()
+
+@app.get("/api/monitoring/performance-trends")
+async def get_performance_trends(hours: int = 24):
+ """Get performance trends over specified time period."""
+ return monitoring_dashboard.get_performance_trends(hours)
+
+@app.get("/api/monitoring/report/{report_type}")
+async def generate_monitoring_report(report_type: str):
+ """Generate comprehensive monitoring report."""
+ if report_type not in ["hourly", "daily", "weekly"]:
+ return {"error": "Invalid report type. Use: hourly, daily, or weekly"}
+
+ return monitoring_dashboard.generate_report(report_type)
+
+@app.get("/api/metrics/summary")
+async def get_metrics_summary():
+ """Get comprehensive metrics summary."""
+ return metrics_collector.get_metrics_summary()
+
+@app.get("/api/metrics/export")
+async def export_metrics(format: str = "json"):
+ """Export metrics in various formats."""
+ try:
+ exported_data = metrics_collector.export_metrics(format)
+
+ if format == "prometheus":
+ return Response(content=exported_data, media_type="text/plain")
+ else:
+ return Response(content=exported_data, media_type="application/json")
+ except ValueError as e:
+ return {"error": str(e)}
+
+@app.get("/api/metrics/time-series/{metric_name}")
+async def get_metric_time_series(metric_name: str, hours: int = 1):
+ """Get time series data for a specific metric."""
+ return {
+ "metric_name": metric_name,
+ "hours": hours,
+ "data": metrics_collector.get_time_series(metric_name, hours)
+ }
+
+@app.get("/api/metrics/requests")
+async def get_request_metrics(hours: int = 1, limit: int = 100):
+ """Get recent request history with metrics."""
+ return {
+ "hours": hours,
+ "limit": limit,
+ "requests": metrics_collector.get_request_history(hours, limit)
+ }
+
+# To run: uvicorn app:app --reload --port 3002
diff --git a/packages/nlp-backend/config.py b/packages/nlp-backend/config.py
new file mode 100644
index 000000000..68a8ba689
--- /dev/null
+++ b/packages/nlp-backend/config.py
@@ -0,0 +1,26 @@
+import os
+from typing import Optional
+from dotenv import load_dotenv
+
+# Load environment variables from .env file
+load_dotenv()
+
+# Ollama Configuration - reads from .env file
+OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
+OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "codellama:7b-instruct")
+OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "90"))
+OLLAMA_FALLBACK_MODEL = os.getenv("OLLAMA_FALLBACK_MODEL", "llama3.2:latest")
+
+def get_config_summary() -> dict:
+ """
+ Get all configuration values for debugging and logging.
+
+ Returns:
+ dict: Configuration summary including Ollama settings
+ """
+ return {
+ "ollama_base_url": OLLAMA_BASE_URL,
+ "ollama_model": OLLAMA_MODEL,
+ "ollama_timeout": OLLAMA_TIMEOUT,
+ "ollama_fallback_model": OLLAMA_FALLBACK_MODEL,
+ }
\ No newline at end of file
diff --git a/packages/nlp-backend/health_monitor.py b/packages/nlp-backend/health_monitor.py
new file mode 100644
index 000000000..0b4afa3ad
--- /dev/null
+++ b/packages/nlp-backend/health_monitor.py
@@ -0,0 +1,567 @@
+import asyncio
+import time
+from typing import Dict, Any, List, Optional
+from dataclasses import dataclass, asdict
+from enum import Enum
+from logging_config import get_logger, log_error, ErrorTypes
+
+logger = get_logger("health_monitor")
+
+class HealthStatus(Enum):
+ """Health status enumeration."""
+ HEALTHY = "healthy"
+ DEGRADED = "degraded"
+ UNHEALTHY = "unhealthy"
+ UNKNOWN = "unknown"
+
+@dataclass
+class HealthCheckResult:
+ """Result of a health check operation."""
+ name: str
+ status: HealthStatus
+ message: str
+ duration_ms: float
+ timestamp: float
+ details: Optional[Dict[str, Any]] = None
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert to dictionary for JSON serialization."""
+ result = asdict(self)
+ result['status'] = self.status.value
+ return result
+
+class HealthMonitor:
+ """
+ Comprehensive health monitoring system for Ollama service.
+ Provides startup validation, periodic health checks, and detailed status reporting.
+ """
+
+ def __init__(self, ollama_service):
+ """
+ Initialize health monitor.
+
+ Args:
+ ollama_service: OllamaService instance to monitor
+ """
+ self.ollama_service = ollama_service
+ self.startup_completed = False
+ self.last_health_check = None
+ self.health_history: List[HealthCheckResult] = []
+ self.max_history_size = 50
+
+ async def perform_startup_validation(self) -> Dict[str, Any]:
+ """
+ Perform comprehensive startup validation.
+
+ Returns:
+ Dict containing startup validation results
+ """
+ logger.info("Starting comprehensive startup validation")
+
+ validation_results = {
+ "startup_time": time.time(),
+ "checks": [],
+ "overall_status": HealthStatus.UNKNOWN.value,
+ "ready": False
+ }
+
+ # 1. Configuration validation
+ config_result = await self._check_configuration()
+ validation_results["checks"].append(config_result.to_dict())
+
+ # 2. Ollama client initialization
+ client_result = await self._check_client_initialization()
+ validation_results["checks"].append(client_result.to_dict())
+
+ # 3. Ollama server connectivity
+ connectivity_result = await self._check_ollama_connectivity()
+ validation_results["checks"].append(connectivity_result.to_dict())
+
+ # 4. Model availability
+ models_result = await self._check_model_availability()
+ validation_results["checks"].append(models_result.to_dict())
+
+ # 5. Basic functionality test
+ functionality_result = await self._check_basic_functionality()
+ validation_results["checks"].append(functionality_result.to_dict())
+
+ # Determine overall status
+ overall_status = self._determine_overall_status(validation_results["checks"])
+ validation_results["overall_status"] = overall_status.value
+ validation_results["ready"] = overall_status in [HealthStatus.HEALTHY, HealthStatus.DEGRADED]
+
+ self.startup_completed = True
+
+ logger.info(
+ "Startup validation completed",
+ extra={
+ "overall_status": overall_status.value,
+ "ready": validation_results["ready"],
+ "checks_passed": sum(1 for check in validation_results["checks"]
+ if check["status"] == HealthStatus.HEALTHY.value),
+ "total_checks": len(validation_results["checks"])
+ }
+ )
+
+ return validation_results
+
+ async def perform_health_check(self, include_detailed: bool = False) -> Dict[str, Any]:
+ """
+ Perform comprehensive health check.
+
+ Args:
+ include_detailed: Whether to include detailed check results
+
+ Returns:
+ Dict containing health check results
+ """
+ start_time = time.time()
+
+ health_result = {
+ "timestamp": start_time,
+ "status": HealthStatus.UNKNOWN.value,
+ "checks": [],
+ "summary": {},
+ "uptime_seconds": start_time - (self.health_history[0].timestamp if self.health_history else start_time)
+ }
+
+ # Perform health checks
+ checks = [
+ self._check_ollama_connectivity(),
+ self._check_model_availability(),
+ ]
+
+ if include_detailed:
+ checks.extend([
+ self._check_response_times(),
+ self._check_resource_usage()
+ ])
+
+ # Execute all checks concurrently
+ check_results = await asyncio.gather(*checks, return_exceptions=True)
+
+ # Process results
+ for result in check_results:
+ if isinstance(result, Exception):
+ error_result = HealthCheckResult(
+ name="unknown_check",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Health check failed: {str(result)}",
+ duration_ms=0,
+ timestamp=time.time()
+ )
+ health_result["checks"].append(error_result.to_dict())
+ else:
+ health_result["checks"].append(result.to_dict())
+
+ # Determine overall status
+ overall_status = self._determine_overall_status(health_result["checks"])
+ health_result["status"] = overall_status.value
+
+ # Create summary
+ health_result["summary"] = self._create_health_summary(health_result["checks"])
+
+ # Store in history
+ overall_result = HealthCheckResult(
+ name="overall_health",
+ status=overall_status,
+ message=f"Overall health: {overall_status.value}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details=health_result["summary"]
+ )
+
+ self._add_to_history(overall_result)
+ self.last_health_check = overall_result
+
+ return health_result
+
+ async def _check_configuration(self) -> HealthCheckResult:
+ """Check configuration validity."""
+ start_time = time.time()
+
+ try:
+ from config import get_config_summary
+ config = get_config_summary()
+
+ # Validate required configuration
+ required_fields = ["ollama_base_url", "ollama_model", "ollama_timeout"]
+ missing_fields = [field for field in required_fields if not config.get(field)]
+
+ if missing_fields:
+ return HealthCheckResult(
+ name="configuration",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Missing required configuration: {', '.join(missing_fields)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details={"missing_fields": missing_fields}
+ )
+
+ return HealthCheckResult(
+ name="configuration",
+ status=HealthStatus.HEALTHY,
+ message="Configuration is valid",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details=config
+ )
+
+ except Exception as e:
+ return HealthCheckResult(
+ name="configuration",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Configuration check failed: {str(e)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ async def _check_client_initialization(self) -> HealthCheckResult:
+ """Check if Ollama client is properly initialized."""
+ start_time = time.time()
+
+ if not self.ollama_service.client:
+ return HealthCheckResult(
+ name="client_initialization",
+ status=HealthStatus.UNHEALTHY,
+ message="Ollama client not initialized",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ return HealthCheckResult(
+ name="client_initialization",
+ status=HealthStatus.HEALTHY,
+ message="Ollama client initialized successfully",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ async def _check_ollama_connectivity(self) -> HealthCheckResult:
+ """Check connectivity to Ollama server."""
+ start_time = time.time()
+
+ try:
+ is_healthy = await self.ollama_service.health_check()
+
+ if is_healthy:
+ return HealthCheckResult(
+ name="ollama_connectivity",
+ status=HealthStatus.HEALTHY,
+ message="Ollama server is reachable and responsive",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+ else:
+ return HealthCheckResult(
+ name="ollama_connectivity",
+ status=HealthStatus.UNHEALTHY,
+ message="Ollama server is not responding",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ except Exception as e:
+ return HealthCheckResult(
+ name="ollama_connectivity",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Connectivity check failed: {str(e)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ async def _check_model_availability(self) -> HealthCheckResult:
+ """Check if required models are available."""
+ start_time = time.time()
+
+ try:
+ models_info = self.ollama_service.get_available_models()
+
+ if "error" in models_info:
+ return HealthCheckResult(
+ name="model_availability",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Failed to get models: {models_info['error']}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ available_models = models_info.get("available_models", [])
+ recommended_models = models_info.get("recommended_models", [])
+
+ if not available_models:
+ return HealthCheckResult(
+ name="model_availability",
+ status=HealthStatus.UNHEALTHY,
+ message="No models available on Ollama server",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ # Check if at least one recommended model is available
+ if not recommended_models:
+ return HealthCheckResult(
+ name="model_availability",
+ status=HealthStatus.DEGRADED,
+ message=f"Models available but none recommended for SQL tasks. Available: {len(available_models)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details={
+ "available_count": len(available_models),
+ "recommended_count": len(recommended_models),
+ "available_models": available_models[:5] # First 5 for brevity
+ }
+ )
+
+ return HealthCheckResult(
+ name="model_availability",
+ status=HealthStatus.HEALTHY,
+ message=f"Models available: {len(available_models)}, recommended: {len(recommended_models)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details={
+ "available_count": len(available_models),
+ "recommended_count": len(recommended_models),
+ "recommended_models": recommended_models
+ }
+ )
+
+ except Exception as e:
+ return HealthCheckResult(
+ name="model_availability",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Model availability check failed: {str(e)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ async def _check_basic_functionality(self) -> HealthCheckResult:
+ """Test basic SQL generation functionality."""
+ start_time = time.time()
+
+ try:
+ # Simple test prompt
+ test_prompt = "SELECT 1"
+ result = await self.ollama_service.generate_sql(test_prompt)
+
+ if "error" in result:
+ return HealthCheckResult(
+ name="basic_functionality",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Basic functionality test failed: {result['error']}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details={"test_prompt": test_prompt, "error": result.get("details")}
+ )
+
+ if "sql" in result and result["sql"].strip():
+ return HealthCheckResult(
+ name="basic_functionality",
+ status=HealthStatus.HEALTHY,
+ message="Basic SQL generation is working",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details={"test_prompt": test_prompt, "generated_sql": result["sql"][:50]}
+ )
+ else:
+ return HealthCheckResult(
+ name="basic_functionality",
+ status=HealthStatus.DEGRADED,
+ message="SQL generation returned empty result",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details={"test_prompt": test_prompt}
+ )
+
+ except Exception as e:
+ return HealthCheckResult(
+ name="basic_functionality",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Functionality test failed: {str(e)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ async def _check_response_times(self) -> HealthCheckResult:
+ """Check response time performance."""
+ start_time = time.time()
+
+ try:
+ # Test with a simple prompt and measure response time
+ test_start = time.time()
+ result = await self.ollama_service.generate_sql("SELECT current_timestamp")
+ response_time = (time.time() - test_start) * 1000 # Convert to ms
+
+ if "error" in result:
+ return HealthCheckResult(
+ name="response_times",
+ status=HealthStatus.DEGRADED,
+ message=f"Response time check failed: {result['error']}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ # Categorize response times
+ if response_time < 1000: # < 1 second
+ status = HealthStatus.HEALTHY
+ message = f"Response time excellent: {response_time:.0f}ms"
+ elif response_time < 5000: # < 5 seconds
+ status = HealthStatus.HEALTHY
+ message = f"Response time good: {response_time:.0f}ms"
+ elif response_time < 15000: # < 15 seconds
+ status = HealthStatus.DEGRADED
+ message = f"Response time slow: {response_time:.0f}ms"
+ else:
+ status = HealthStatus.UNHEALTHY
+ message = f"Response time too slow: {response_time:.0f}ms"
+
+ return HealthCheckResult(
+ name="response_times",
+ status=status,
+ message=message,
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details={"response_time_ms": response_time}
+ )
+
+ except Exception as e:
+ return HealthCheckResult(
+ name="response_times",
+ status=HealthStatus.UNHEALTHY,
+ message=f"Response time check failed: {str(e)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ async def _check_resource_usage(self) -> HealthCheckResult:
+ """Check basic resource usage (if available)."""
+ start_time = time.time()
+
+ try:
+ import psutil
+
+ # Get basic system metrics
+ cpu_percent = psutil.cpu_percent(interval=0.1)
+ memory = psutil.virtual_memory()
+
+ details = {
+ "cpu_percent": cpu_percent,
+ "memory_percent": memory.percent,
+ "memory_available_gb": memory.available / (1024**3)
+ }
+
+ # Simple thresholds
+ if cpu_percent > 90 or memory.percent > 90:
+ status = HealthStatus.DEGRADED
+ message = f"High resource usage - CPU: {cpu_percent:.1f}%, Memory: {memory.percent:.1f}%"
+ elif cpu_percent > 70 or memory.percent > 70:
+ status = HealthStatus.DEGRADED
+ message = f"Moderate resource usage - CPU: {cpu_percent:.1f}%, Memory: {memory.percent:.1f}%"
+ else:
+ status = HealthStatus.HEALTHY
+ message = f"Resource usage normal - CPU: {cpu_percent:.1f}%, Memory: {memory.percent:.1f}%"
+
+ return HealthCheckResult(
+ name="resource_usage",
+ status=status,
+ message=message,
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time,
+ details=details
+ )
+
+ except ImportError:
+ return HealthCheckResult(
+ name="resource_usage",
+ status=HealthStatus.UNKNOWN,
+ message="Resource monitoring not available (psutil not installed)",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+ except Exception as e:
+ return HealthCheckResult(
+ name="resource_usage",
+ status=HealthStatus.UNKNOWN,
+ message=f"Resource check failed: {str(e)}",
+ duration_ms=(time.time() - start_time) * 1000,
+ timestamp=start_time
+ )
+
+ def _determine_overall_status(self, checks: List[Dict[str, Any]]) -> HealthStatus:
+ """Determine overall status from individual check results."""
+ if not checks:
+ return HealthStatus.UNKNOWN
+
+ statuses = [HealthStatus(check["status"]) for check in checks]
+
+ # If any check is unhealthy, overall is unhealthy
+ if HealthStatus.UNHEALTHY in statuses:
+ return HealthStatus.UNHEALTHY
+
+ # If any check is degraded, overall is degraded
+ if HealthStatus.DEGRADED in statuses:
+ return HealthStatus.DEGRADED
+
+ # If all checks are healthy, overall is healthy
+ if all(status == HealthStatus.HEALTHY for status in statuses):
+ return HealthStatus.HEALTHY
+
+ # If we have unknown statuses but no unhealthy/degraded, it's degraded
+ return HealthStatus.DEGRADED
+
+ def _create_health_summary(self, checks: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Create a summary of health check results."""
+ status_counts = {}
+ total_duration = 0
+
+ for check in checks:
+ status = check["status"]
+ status_counts[status] = status_counts.get(status, 0) + 1
+ total_duration += check.get("duration_ms", 0)
+
+ return {
+ "total_checks": len(checks),
+ "status_counts": status_counts,
+ "total_duration_ms": total_duration,
+ "average_duration_ms": total_duration / len(checks) if checks else 0
+ }
+
+ def _add_to_history(self, result: HealthCheckResult):
+ """Add result to health history."""
+ self.health_history.append(result)
+
+ # Keep only recent history
+ if len(self.health_history) > self.max_history_size:
+ self.health_history = self.health_history[-self.max_history_size:]
+
+ def get_health_history(self, limit: int = 10) -> List[Dict[str, Any]]:
+ """Get recent health check history."""
+ recent_history = self.health_history[-limit:] if self.health_history else []
+ return [result.to_dict() for result in recent_history]
+
+ def get_health_metrics(self) -> Dict[str, Any]:
+ """Get health metrics and statistics."""
+ if not self.health_history:
+ return {"message": "No health history available"}
+
+ recent_checks = self.health_history[-10:] # Last 10 checks
+
+ # Calculate success rate
+ healthy_count = sum(1 for check in recent_checks
+ if check.status == HealthStatus.HEALTHY)
+ success_rate = (healthy_count / len(recent_checks)) * 100
+
+ # Calculate average response time
+ avg_duration = sum(check.duration_ms for check in recent_checks) / len(recent_checks)
+
+ # Get current status
+ current_status = self.last_health_check.status.value if self.last_health_check else "unknown"
+
+ return {
+ "current_status": current_status,
+ "success_rate_percent": round(success_rate, 1),
+ "average_response_time_ms": round(avg_duration, 1),
+ "total_checks_performed": len(self.health_history),
+ "startup_completed": self.startup_completed,
+ "last_check_timestamp": self.last_health_check.timestamp if self.last_health_check else None
+ }
\ No newline at end of file
diff --git a/packages/nlp-backend/logging_config.py b/packages/nlp-backend/logging_config.py
new file mode 100644
index 000000000..d2450387a
--- /dev/null
+++ b/packages/nlp-backend/logging_config.py
@@ -0,0 +1,152 @@
+import logging
+import sys
+from typing import Optional
+from datetime import datetime
+import json
+
+class JSONFormatter(logging.Formatter):
+ """
+ Custom JSON formatter for structured logging.
+ """
+
+ def format(self, record):
+ log_entry = {
+ "timestamp": datetime.utcnow().isoformat() + "Z",
+ "level": record.levelname,
+ "logger": record.name,
+ "message": record.getMessage(),
+ "module": record.module,
+ "function": record.funcName,
+ "line": record.lineno
+ }
+
+ # Add exception info if present
+ if record.exc_info:
+ log_entry["exception"] = self.formatException(record.exc_info)
+
+ # Add extra fields if present
+ if hasattr(record, 'request_id'):
+ log_entry["request_id"] = record.request_id
+ if hasattr(record, 'prompt_length'):
+ log_entry["prompt_length"] = record.prompt_length
+ if hasattr(record, 'model_used'):
+ log_entry["model_used"] = record.model_used
+ if hasattr(record, 'response_time'):
+ log_entry["response_time"] = record.response_time
+ if hasattr(record, 'error_type'):
+ log_entry["error_type"] = record.error_type
+
+ return json.dumps(log_entry)
+
+class OllamaLoggerAdapter(logging.LoggerAdapter):
+ """
+ Logger adapter that adds context information to log records.
+ """
+
+ def process(self, msg, kwargs):
+ # Add context from the adapter's extra dict
+ if self.extra:
+ for key, value in self.extra.items():
+ kwargs.setdefault('extra', {})[key] = value
+ return msg, kwargs
+
+def setup_logging(
+ level: str = "INFO",
+ format_type: str = "json",
+ log_file: Optional[str] = None
+) -> logging.Logger:
+ """
+ Set up comprehensive logging configuration for the Ollama service.
+
+ Args:
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+ format_type: Format type ("json" or "text")
+ log_file: Optional log file path
+
+ Returns:
+ Configured logger instance
+ """
+
+ # Convert string level to logging constant
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
+
+ # Create root logger
+ logger = logging.getLogger("ollama_service")
+ logger.setLevel(numeric_level)
+
+ # Clear existing handlers
+ logger.handlers.clear()
+
+ # Create console handler
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setLevel(numeric_level)
+
+ # Set formatter based on format type
+ if format_type.lower() == "json":
+ formatter = JSONFormatter()
+ else:
+ formatter = logging.Formatter(
+ '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
+ )
+
+ console_handler.setFormatter(formatter)
+ logger.addHandler(console_handler)
+
+ # Add file handler if specified
+ if log_file:
+ file_handler = logging.FileHandler(log_file)
+ file_handler.setLevel(numeric_level)
+ file_handler.setFormatter(formatter)
+ logger.addHandler(file_handler)
+
+ # Set up other loggers
+ logging.getLogger("ollama").setLevel(numeric_level)
+ logging.getLogger("httpx").setLevel(logging.WARNING) # Reduce httpx noise
+ logging.getLogger("uvicorn").setLevel(logging.INFO)
+
+ logger.info(f"Logging configured - Level: {level}, Format: {format_type}")
+
+ return logger
+
+def get_logger(name: str, **context) -> OllamaLoggerAdapter:
+ """
+ Get a logger adapter with context information.
+
+ Args:
+ name: Logger name
+ **context: Additional context to include in log records
+
+ Returns:
+ Logger adapter with context
+ """
+ base_logger = logging.getLogger(f"ollama_service.{name}")
+ return OllamaLoggerAdapter(base_logger, context)
+
+# Error type constants for consistent error categorization
+class ErrorTypes:
+ CONNECTION_ERROR = "connection_error"
+ TIMEOUT_ERROR = "timeout_error"
+ MODEL_ERROR = "model_error"
+ VALIDATION_ERROR = "validation_error"
+ CONFIGURATION_ERROR = "configuration_error"
+ UNKNOWN_ERROR = "unknown_error"
+
+def log_error(logger: logging.Logger, error: Exception, error_type: str, **context):
+ """
+ Log an error with consistent formatting and context.
+
+ Args:
+ logger: Logger instance
+ error: Exception that occurred
+ error_type: Type of error (use ErrorTypes constants)
+ **context: Additional context information
+ """
+ logger.error(
+ f"{error_type}: {str(error)}",
+ extra={
+ "error_type": error_type,
+ "exception_class": error.__class__.__name__,
+ **context
+ },
+ exc_info=True
+ )
\ No newline at end of file
diff --git a/packages/nlp-backend/metrics_collector.py b/packages/nlp-backend/metrics_collector.py
new file mode 100644
index 000000000..da130a553
--- /dev/null
+++ b/packages/nlp-backend/metrics_collector.py
@@ -0,0 +1,553 @@
+import time
+import threading
+from typing import Dict, Any, List, Optional
+from dataclasses import dataclass, asdict
+from collections import defaultdict, deque
+from datetime import datetime, timedelta
+import json
+import os
+from logging_config import get_logger
+
+logger = get_logger("metrics_collector")
+
+@dataclass
+class MetricPoint:
+ """Individual metric data point."""
+ timestamp: float
+ value: float
+ labels: Dict[str, str]
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert to dictionary for serialization."""
+ return {
+ "timestamp": self.timestamp,
+ "value": self.value,
+ "labels": self.labels
+ }
+
+@dataclass
+class RequestMetrics:
+ """Metrics for a single request."""
+ request_id: str
+ endpoint: str
+ method: str
+ status_code: int
+ response_time_ms: float
+ prompt_length: int
+ sql_length: int
+ model_used: str
+ success: bool
+ error_type: Optional[str]
+ timestamp: float
+ client_ip: str
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert to dictionary for serialization."""
+ return asdict(self)
+
+class MetricsCollector:
+ """
+ Advanced metrics collection system for monitoring Ollama service performance,
+ health, and usage patterns.
+ """
+
+ def __init__(self, retention_hours: int = 24, max_points_per_metric: int = 10000):
+ """
+ Initialize metrics collector.
+
+ Args:
+ retention_hours: How long to keep metrics data
+ max_points_per_metric: Maximum data points per metric
+ """
+ self.retention_hours = retention_hours
+ self.max_points_per_metric = max_points_per_metric
+
+ # Thread-safe storage
+ self._lock = threading.RLock()
+
+ # Metric storage
+ self._metrics: Dict[str, deque] = defaultdict(lambda: deque(maxlen=max_points_per_metric))
+ self._request_metrics: deque = deque(maxlen=max_points_per_metric)
+
+ # Counters
+ self._counters: Dict[str, int] = defaultdict(int)
+
+ # Gauges (current values)
+ self._gauges: Dict[str, float] = {}
+
+ # Histograms (for response time distribution)
+ self._histograms: Dict[str, List[float]] = defaultdict(list)
+
+ # Start cleanup thread
+ self._cleanup_thread = threading.Thread(target=self._cleanup_old_metrics, daemon=True)
+ self._cleanup_thread.start()
+
+ logger.info("Metrics collector initialized", extra={
+ "retention_hours": retention_hours,
+ "max_points_per_metric": max_points_per_metric
+ })
+
+ def record_counter(self, name: str, value: int = 1, labels: Optional[Dict[str, str]] = None):
+ """
+ Record a counter metric (monotonically increasing).
+
+ Args:
+ name: Metric name
+ value: Value to add to counter
+ labels: Optional labels for the metric
+ """
+ with self._lock:
+ metric_key = self._get_metric_key(name, labels or {})
+ self._counters[metric_key] += value
+
+ # Also store as time series
+ point = MetricPoint(
+ timestamp=time.time(),
+ value=self._counters[metric_key],
+ labels=labels or {}
+ )
+ self._metrics[name].append(point)
+
+ def record_gauge(self, name: str, value: float, labels: Optional[Dict[str, str]] = None):
+ """
+ Record a gauge metric (current value).
+
+ Args:
+ name: Metric name
+ value: Current value
+ labels: Optional labels for the metric
+ """
+ with self._lock:
+ metric_key = self._get_metric_key(name, labels or {})
+ self._gauges[metric_key] = value
+
+ # Also store as time series
+ point = MetricPoint(
+ timestamp=time.time(),
+ value=value,
+ labels=labels or {}
+ )
+ self._metrics[name].append(point)
+
+ def record_histogram(self, name: str, value: float, labels: Optional[Dict[str, str]] = None):
+ """
+ Record a histogram metric (for distribution analysis).
+
+ Args:
+ name: Metric name
+ value: Value to add to histogram
+ labels: Optional labels for the metric
+ """
+ with self._lock:
+ metric_key = self._get_metric_key(name, labels or {})
+ self._histograms[metric_key].append(value)
+
+ # Keep only recent values for histogram
+ if len(self._histograms[metric_key]) > 1000:
+ self._histograms[metric_key] = self._histograms[metric_key][-1000:]
+
+ # Also store as time series
+ point = MetricPoint(
+ timestamp=time.time(),
+ value=value,
+ labels=labels or {}
+ )
+ self._metrics[name].append(point)
+
+ def record_request(self, request_metrics: RequestMetrics):
+ """
+ Record comprehensive request metrics.
+
+ Args:
+ request_metrics: Request metrics data
+ """
+ with self._lock:
+ self._request_metrics.append(request_metrics)
+
+ # Update derived metrics
+ self.record_counter("requests_total", 1, {
+ "endpoint": request_metrics.endpoint,
+ "method": request_metrics.method,
+ "status": str(request_metrics.status_code)
+ })
+
+ self.record_histogram("request_duration_ms", request_metrics.response_time_ms, {
+ "endpoint": request_metrics.endpoint
+ })
+
+ self.record_histogram("prompt_length", request_metrics.prompt_length)
+ self.record_histogram("sql_length", request_metrics.sql_length)
+
+ if request_metrics.model_used:
+ self.record_counter("model_usage", 1, {
+ "model": request_metrics.model_used
+ })
+
+ if not request_metrics.success:
+ self.record_counter("errors_total", 1, {
+ "error_type": request_metrics.error_type or "unknown",
+ "endpoint": request_metrics.endpoint
+ })
+
+ def get_metrics_summary(self) -> Dict[str, Any]:
+ """
+ Get comprehensive metrics summary.
+
+ Returns:
+ Dict containing all metrics data
+ """
+ with self._lock:
+ current_time = time.time()
+
+ # Calculate time windows
+ last_hour = current_time - 3600
+ last_day = current_time - 86400
+
+ # Recent requests
+ recent_requests = [
+ req for req in self._request_metrics
+ if req.timestamp > last_hour
+ ]
+
+ daily_requests = [
+ req for req in self._request_metrics
+ if req.timestamp > last_day
+ ]
+
+ # Calculate success rates
+ hourly_success_rate = self._calculate_success_rate(recent_requests)
+ daily_success_rate = self._calculate_success_rate(daily_requests)
+
+ # Calculate response time percentiles
+ recent_response_times = [req.response_time_ms for req in recent_requests]
+ response_time_percentiles = self._calculate_percentiles(recent_response_times)
+
+ # Model usage statistics
+ model_usage = self._calculate_model_usage(daily_requests)
+
+ # Error statistics
+ error_stats = self._calculate_error_stats(daily_requests)
+
+ return {
+ "timestamp": current_time,
+ "collection_period_hours": self.retention_hours,
+ "total_requests": len(self._request_metrics),
+ "requests_last_hour": len(recent_requests),
+ "requests_last_day": len(daily_requests),
+ "success_rates": {
+ "last_hour": hourly_success_rate,
+ "last_day": daily_success_rate
+ },
+ "response_times": {
+ "last_hour_percentiles": response_time_percentiles,
+ "average_ms": sum(recent_response_times) / len(recent_response_times) if recent_response_times else 0
+ },
+ "model_usage": model_usage,
+ "error_statistics": error_stats,
+ "counters": dict(self._counters),
+ "gauges": dict(self._gauges)
+ }
+
+ def get_time_series(self, metric_name: str, hours: int = 1) -> List[Dict[str, Any]]:
+ """
+ Get time series data for a specific metric.
+
+ Args:
+ metric_name: Name of the metric
+ hours: Number of hours of data to return
+
+ Returns:
+ List of metric points
+ """
+ with self._lock:
+ cutoff_time = time.time() - (hours * 3600)
+
+ if metric_name not in self._metrics:
+ return []
+
+ return [
+ point.to_dict() for point in self._metrics[metric_name]
+ if point.timestamp > cutoff_time
+ ]
+
+ def get_request_history(self, hours: int = 1, limit: int = 100) -> List[Dict[str, Any]]:
+ """
+ Get recent request history.
+
+ Args:
+ hours: Number of hours of history to return
+ limit: Maximum number of requests to return
+
+ Returns:
+ List of request metrics
+ """
+ with self._lock:
+ cutoff_time = time.time() - (hours * 3600)
+
+ recent_requests = [
+ req.to_dict() for req in self._request_metrics
+ if req.timestamp > cutoff_time
+ ]
+
+ # Sort by timestamp (most recent first) and limit
+ recent_requests.sort(key=lambda x: x['timestamp'], reverse=True)
+ return recent_requests[:limit]
+
+ def get_performance_report(self) -> Dict[str, Any]:
+ """
+ Generate comprehensive performance report.
+
+ Returns:
+ Dict containing performance analysis
+ """
+ with self._lock:
+ current_time = time.time()
+
+ # Get requests from different time windows
+ last_hour_requests = [
+ req for req in self._request_metrics
+ if req.timestamp > current_time - 3600
+ ]
+
+ last_day_requests = [
+ req for req in self._request_metrics
+ if req.timestamp > current_time - 86400
+ ]
+
+ # Performance analysis
+ report = {
+ "generated_at": current_time,
+ "time_windows": {
+ "last_hour": self._analyze_requests(last_hour_requests),
+ "last_day": self._analyze_requests(last_day_requests)
+ },
+ "trends": self._calculate_trends(),
+ "recommendations": self._generate_recommendations(last_hour_requests)
+ }
+
+ return report
+
+ def export_metrics(self, format_type: str = "json") -> str:
+ """
+ Export metrics in various formats.
+
+ Args:
+ format_type: Export format (json, prometheus)
+
+ Returns:
+ Formatted metrics string
+ """
+ if format_type == "json":
+ return json.dumps(self.get_metrics_summary(), indent=2)
+ elif format_type == "prometheus":
+ return self._export_prometheus_format()
+ else:
+ raise ValueError(f"Unsupported format: {format_type}")
+
+ def _get_metric_key(self, name: str, labels: Dict[str, str]) -> str:
+ """Generate unique key for metric with labels."""
+ if not labels:
+ return name
+
+ label_str = ",".join(f"{k}={v}" for k, v in sorted(labels.items()))
+ return f"{name}{{{label_str}}}"
+
+ def _calculate_success_rate(self, requests: List[RequestMetrics]) -> float:
+ """Calculate success rate for a list of requests."""
+ if not requests:
+ return 0.0
+
+ successful = sum(1 for req in requests if req.success)
+ return (successful / len(requests)) * 100
+
+ def _calculate_percentiles(self, values: List[float]) -> Dict[str, float]:
+ """Calculate percentiles for a list of values."""
+ if not values:
+ return {"p50": 0, "p90": 0, "p95": 0, "p99": 0}
+
+ sorted_values = sorted(values)
+ n = len(sorted_values)
+
+ return {
+ "p50": sorted_values[int(n * 0.5)],
+ "p90": sorted_values[int(n * 0.9)],
+ "p95": sorted_values[int(n * 0.95)],
+ "p99": sorted_values[int(n * 0.99)]
+ }
+
+ def _calculate_model_usage(self, requests: List[RequestMetrics]) -> Dict[str, Any]:
+ """Calculate model usage statistics."""
+ model_counts = defaultdict(int)
+ model_response_times = defaultdict(list)
+
+ for req in requests:
+ if req.model_used:
+ model_counts[req.model_used] += 1
+ model_response_times[req.model_used].append(req.response_time_ms)
+
+ usage_stats = {}
+ for model, count in model_counts.items():
+ response_times = model_response_times[model]
+ usage_stats[model] = {
+ "request_count": count,
+ "percentage": (count / len(requests)) * 100 if requests else 0,
+ "avg_response_time_ms": sum(response_times) / len(response_times) if response_times else 0,
+ "percentiles": self._calculate_percentiles(response_times)
+ }
+
+ return usage_stats
+
+ def _calculate_error_stats(self, requests: List[RequestMetrics]) -> Dict[str, Any]:
+ """Calculate error statistics."""
+ error_counts = defaultdict(int)
+ total_errors = 0
+
+ for req in requests:
+ if not req.success:
+ error_type = req.error_type or "unknown"
+ error_counts[error_type] += 1
+ total_errors += 1
+
+ return {
+ "total_errors": total_errors,
+ "error_rate": (total_errors / len(requests)) * 100 if requests else 0,
+ "error_types": dict(error_counts)
+ }
+
+ def _analyze_requests(self, requests: List[RequestMetrics]) -> Dict[str, Any]:
+ """Analyze a list of requests for performance metrics."""
+ if not requests:
+ return {"request_count": 0}
+
+ response_times = [req.response_time_ms for req in requests]
+ prompt_lengths = [req.prompt_length for req in requests]
+ sql_lengths = [req.sql_length for req in requests]
+
+ return {
+ "request_count": len(requests),
+ "success_rate": self._calculate_success_rate(requests),
+ "response_times": {
+ "average_ms": sum(response_times) / len(response_times),
+ "percentiles": self._calculate_percentiles(response_times)
+ },
+ "prompt_stats": {
+ "average_length": sum(prompt_lengths) / len(prompt_lengths),
+ "percentiles": self._calculate_percentiles(prompt_lengths)
+ },
+ "sql_stats": {
+ "average_length": sum(sql_lengths) / len(sql_lengths),
+ "percentiles": self._calculate_percentiles(sql_lengths)
+ }
+ }
+
+ def _calculate_trends(self) -> Dict[str, Any]:
+ """Calculate performance trends over time."""
+ # This is a simplified trend calculation
+ # In a real implementation, you might use more sophisticated time series analysis
+
+ current_time = time.time()
+ hour_ago = current_time - 3600
+ two_hours_ago = current_time - 7200
+
+ recent_requests = [req for req in self._request_metrics if req.timestamp > hour_ago]
+ previous_requests = [req for req in self._request_metrics if two_hours_ago < req.timestamp <= hour_ago]
+
+ recent_avg_response = sum(req.response_time_ms for req in recent_requests) / len(recent_requests) if recent_requests else 0
+ previous_avg_response = sum(req.response_time_ms for req in previous_requests) / len(previous_requests) if previous_requests else 0
+
+ response_time_trend = "stable"
+ if recent_avg_response > previous_avg_response * 1.1:
+ response_time_trend = "increasing"
+ elif recent_avg_response < previous_avg_response * 0.9:
+ response_time_trend = "decreasing"
+
+ return {
+ "response_time_trend": response_time_trend,
+ "request_volume_trend": "stable", # Simplified
+ "error_rate_trend": "stable" # Simplified
+ }
+
+ def _generate_recommendations(self, recent_requests: List[RequestMetrics]) -> List[str]:
+ """Generate performance recommendations based on recent data."""
+ recommendations = []
+
+ if not recent_requests:
+ return ["No recent requests to analyze"]
+
+ # Analyze response times
+ response_times = [req.response_time_ms for req in recent_requests]
+ avg_response_time = sum(response_times) / len(response_times)
+
+ if avg_response_time > 10000: # 10 seconds
+ recommendations.append("Consider using a smaller/faster model for better response times")
+
+ # Analyze error rate
+ error_rate = (1 - self._calculate_success_rate(recent_requests) / 100)
+ if error_rate > 0.05: # 5% error rate
+ recommendations.append("High error rate detected - check Ollama server health and model availability")
+
+ # Analyze prompt lengths
+ prompt_lengths = [req.prompt_length for req in recent_requests]
+ avg_prompt_length = sum(prompt_lengths) / len(prompt_lengths)
+
+ if avg_prompt_length > 1000:
+ recommendations.append("Long prompts detected - consider prompt optimization for better performance")
+
+ if not recommendations:
+ recommendations.append("System performance is within normal parameters")
+
+ return recommendations
+
+ def _export_prometheus_format(self) -> str:
+ """Export metrics in Prometheus format."""
+ lines = []
+
+ # Export counters
+ for metric_key, value in self._counters.items():
+ lines.append(f"ollama_counter_{metric_key} {value}")
+
+ # Export gauges
+ for metric_key, value in self._gauges.items():
+ lines.append(f"ollama_gauge_{metric_key} {value}")
+
+ return "\n".join(lines)
+
+ def _cleanup_old_metrics(self):
+ """Background thread to clean up old metrics data."""
+ while True:
+ try:
+ time.sleep(3600) # Run every hour
+
+ cutoff_time = time.time() - (self.retention_hours * 3600)
+
+ with self._lock:
+ # Clean up time series data
+ for metric_name in list(self._metrics.keys()):
+ metric_data = self._metrics[metric_name]
+ # Remove old points
+ while metric_data and metric_data[0].timestamp < cutoff_time:
+ metric_data.popleft()
+
+ # Clean up request metrics
+ while self._request_metrics and self._request_metrics[0].timestamp < cutoff_time:
+ self._request_metrics.popleft()
+
+ # Clean up histograms
+ for metric_key in list(self._histograms.keys()):
+ # Keep only recent histogram data
+ self._histograms[metric_key] = self._histograms[metric_key][-1000:]
+
+ logger.info("Metrics cleanup completed", extra={
+ "cutoff_time": cutoff_time,
+ "retention_hours": self.retention_hours
+ })
+
+ except Exception as e:
+ logger.error(f"Error during metrics cleanup: {e}")
+
+
+# Global metrics collector instance
+metrics_collector = MetricsCollector(
+ retention_hours=int(os.getenv("METRICS_RETENTION_HOURS", "24")),
+ max_points_per_metric=int(os.getenv("MAX_POINTS_PER_METRIC", "10000"))
+)
\ No newline at end of file
diff --git a/packages/nlp-backend/model_manager.py b/packages/nlp-backend/model_manager.py
new file mode 100644
index 000000000..7f575ee4a
--- /dev/null
+++ b/packages/nlp-backend/model_manager.py
@@ -0,0 +1,198 @@
+import logging
+from typing import List, Optional, Dict, Any
+from config import OLLAMA_MODEL, OLLAMA_FALLBACK_MODEL
+
+logger = logging.getLogger(__name__)
+
+class ModelManager:
+ """
+ Manages model selection, validation, and fallback logic for Ollama client.
+ """
+
+ def __init__(self, ollama_client):
+ """
+ Initialize ModelManager with an OllamaClient instance.
+
+ Args:
+ ollama_client: Instance of OllamaClient for model operations
+ """
+ self.ollama_client = ollama_client
+ self.primary_model = OLLAMA_MODEL
+ self.fallback_model = OLLAMA_FALLBACK_MODEL
+ self._available_models_cache = None
+ self._cache_valid = False
+
+ def get_primary_model(self) -> str:
+ """Get the primary model name."""
+ return self.primary_model
+
+ def get_fallback_model(self) -> Optional[str]:
+ """Get the fallback model name."""
+ return self.fallback_model
+
+ def set_primary_model(self, model_name: str) -> None:
+ """
+ Set the primary model name.
+
+ Args:
+ model_name: Name of the model to set as primary
+ """
+ self.primary_model = model_name
+ logger.info(f"Primary model set to: {model_name}")
+
+ def set_fallback_model(self, model_name: Optional[str]) -> None:
+ """
+ Set the fallback model name.
+
+ Args:
+ model_name: Name of the model to set as fallback, or None to disable fallback
+ """
+ self.fallback_model = model_name
+ logger.info(f"Fallback model set to: {model_name}")
+
+ def get_available_models(self, use_cache: bool = True) -> List[str]:
+ """
+ Get list of available models, with optional caching.
+
+ Args:
+ use_cache: Whether to use cached results if available
+
+ Returns:
+ List[str]: List of available model names
+ """
+ if use_cache and self._cache_valid and self._available_models_cache:
+ return self._available_models_cache
+
+ try:
+ models = self.ollama_client.get_available_models()
+ self._available_models_cache = models
+ self._cache_valid = True
+ return models
+ except Exception as e:
+ logger.error(f"Failed to get available models: {str(e)}")
+ # Return cached models if available, otherwise empty list
+ return self._available_models_cache or []
+
+ def validate_model_availability(self, model_name: str) -> bool:
+ """
+ Validate if a model is available on the server.
+
+ Args:
+ model_name: Name of the model to validate
+
+ Returns:
+ bool: True if model is available, False otherwise
+ """
+ try:
+ available_models = self.get_available_models()
+ return model_name in available_models
+ except Exception as e:
+ logger.error(f"Failed to validate model {model_name}: {str(e)}")
+ return False
+
+ def get_best_available_model(self, preferred_models: List[str]) -> Optional[str]:
+ """
+ Get the best available model from a list of preferred models.
+
+ Args:
+ preferred_models: List of model names in order of preference
+
+ Returns:
+ Optional[str]: First available model from the list, or None if none available
+ """
+ available_models = self.get_available_models()
+
+ for model in preferred_models:
+ if model in available_models:
+ logger.info(f"Selected model: {model}")
+ return model
+
+ logger.warning(f"None of the preferred models {preferred_models} are available")
+ return None
+
+ def get_model_for_request(self, model_override: Optional[str] = None) -> str:
+ """
+ Determine which model to use for a request, considering overrides and availability.
+
+ Args:
+ model_override: Optional model override for this specific request
+
+ Returns:
+ str: Model name to use for the request
+
+ Raises:
+ Exception: If no suitable model is available
+ """
+ # Priority order: override -> primary -> fallback
+ candidates = []
+
+ if model_override:
+ candidates.append(model_override)
+
+ candidates.append(self.primary_model)
+
+ if self.fallback_model:
+ candidates.append(self.fallback_model)
+
+ # Find first available model
+ selected_model = self.get_best_available_model(candidates)
+
+ if not selected_model:
+ available_models = self.get_available_models()
+ if available_models:
+ # Use any available model as last resort
+ selected_model = available_models[0]
+ logger.warning(f"Using fallback to first available model: {selected_model}")
+ else:
+ raise Exception("No models available on Ollama server")
+
+ return selected_model
+
+ def get_model_info(self) -> Dict[str, Any]:
+ """
+ Get comprehensive information about model configuration and availability.
+
+ Returns:
+ Dict containing model configuration and availability info
+ """
+ available_models = self.get_available_models()
+
+ return {
+ "primary_model": self.primary_model,
+ "primary_available": self.primary_model in available_models,
+ "fallback_model": self.fallback_model,
+ "fallback_available": self.fallback_model in available_models if self.fallback_model else None,
+ "available_models": available_models,
+ "total_available": len(available_models),
+ "cache_valid": self._cache_valid
+ }
+
+ def refresh_model_cache(self) -> None:
+ """Force refresh of the available models cache."""
+ self._cache_valid = False
+ self.get_available_models(use_cache=False)
+ logger.info("Model cache refreshed")
+
+ def recommend_models_for_sql(self) -> List[str]:
+ """
+ Get recommended models for SQL generation tasks, in order of preference.
+
+ Returns:
+ List[str]: Recommended model names for SQL tasks
+ """
+ # Models known to be good for code/SQL generation
+ sql_models = [
+ "codellama:7b-instruct",
+ "codellama:13b-instruct",
+ "codellama:34b-instruct",
+ "llama3:8b-instruct",
+ "llama3:70b-instruct",
+ "mistral:7b-instruct",
+ "mixtral:8x7b-instruct"
+ ]
+
+ available_models = self.get_available_models()
+ recommended = [model for model in sql_models if model in available_models]
+
+ logger.info(f"Recommended SQL models: {recommended}")
+ return recommended
\ No newline at end of file
diff --git a/packages/nlp-backend/monitoring_dashboard.py b/packages/nlp-backend/monitoring_dashboard.py
new file mode 100644
index 000000000..5d8bb92ea
--- /dev/null
+++ b/packages/nlp-backend/monitoring_dashboard.py
@@ -0,0 +1,579 @@
+import time
+import json
+from typing import Dict, Any, List, Optional
+from datetime import datetime, timedelta
+from metrics_collector import metrics_collector, RequestMetrics
+from logging_config import get_logger
+
+logger = get_logger("monitoring_dashboard")
+
+class MonitoringDashboard:
+ """
+ Real-time monitoring dashboard for Ollama service.
+ Provides formatted data for web dashboards and monitoring systems.
+ """
+
+ def __init__(self):
+ """Initialize monitoring dashboard."""
+ self.start_time = time.time()
+ logger.info("Monitoring dashboard initialized")
+
+ def get_dashboard_data(self) -> Dict[str, Any]:
+ """
+ Get comprehensive dashboard data.
+
+ Returns:
+ Dict containing all dashboard metrics and visualizations
+ """
+ current_time = time.time()
+ uptime_seconds = current_time - self.start_time
+
+ # Get metrics summary
+ metrics_summary = metrics_collector.get_metrics_summary()
+
+ # Get performance report
+ performance_report = metrics_collector.get_performance_report()
+
+ # Get recent request history
+ recent_requests = metrics_collector.get_request_history(hours=1, limit=50)
+
+ dashboard_data = {
+ "timestamp": current_time,
+ "uptime_seconds": uptime_seconds,
+ "uptime_formatted": self._format_uptime(uptime_seconds),
+ "overview": self._get_overview_metrics(metrics_summary),
+ "performance": self._get_performance_metrics(metrics_summary, performance_report),
+ "requests": self._get_request_metrics(recent_requests),
+ "models": self._get_model_metrics(metrics_summary),
+ "errors": self._get_error_metrics(metrics_summary),
+ "system": self._get_system_metrics(),
+ "alerts": self._get_active_alerts(metrics_summary),
+ "charts": self._get_chart_data()
+ }
+
+ return dashboard_data
+
+ def get_real_time_metrics(self) -> Dict[str, Any]:
+ """
+ Get real-time metrics for live updates.
+
+ Returns:
+ Dict containing current metrics
+ """
+ current_time = time.time()
+
+ # Get last 5 minutes of data
+ recent_requests = metrics_collector.get_request_history(hours=0.083, limit=100) # 5 minutes
+
+ if recent_requests:
+ latest_request = recent_requests[0]
+ avg_response_time = sum(req['response_time_ms'] for req in recent_requests) / len(recent_requests)
+ success_rate = sum(1 for req in recent_requests if req['success']) / len(recent_requests) * 100
+ else:
+ latest_request = None
+ avg_response_time = 0
+ success_rate = 0
+
+ return {
+ "timestamp": current_time,
+ "requests_last_5min": len(recent_requests),
+ "avg_response_time_ms": round(avg_response_time, 2),
+ "success_rate_percent": round(success_rate, 2),
+ "latest_request": latest_request,
+ "active_connections": self._get_active_connections(),
+ "memory_usage_mb": self._get_memory_usage(),
+ "cpu_usage_percent": self._get_cpu_usage()
+ }
+
+ def get_health_status(self) -> Dict[str, Any]:
+ """
+ Get overall health status for monitoring systems.
+
+ Returns:
+ Dict containing health status and key metrics
+ """
+ metrics_summary = metrics_collector.get_metrics_summary()
+
+ # Determine overall health
+ health_status = "healthy"
+ health_issues = []
+
+ # Check success rate
+ hourly_success_rate = metrics_summary.get("success_rates", {}).get("last_hour", 0)
+ if hourly_success_rate < 95:
+ health_status = "degraded"
+ health_issues.append(f"Low success rate: {hourly_success_rate:.1f}%")
+
+ # Check response times
+ avg_response_time = metrics_summary.get("response_times", {}).get("average_ms", 0)
+ if avg_response_time > 10000: # 10 seconds
+ health_status = "degraded"
+ health_issues.append(f"High response time: {avg_response_time:.0f}ms")
+
+ # Check error rate
+ error_stats = metrics_summary.get("error_statistics", {})
+ error_rate = error_stats.get("error_rate", 0)
+ if error_rate > 5: # 5% error rate
+ health_status = "unhealthy"
+ health_issues.append(f"High error rate: {error_rate:.1f}%")
+
+ return {
+ "status": health_status,
+ "timestamp": time.time(),
+ "issues": health_issues,
+ "key_metrics": {
+ "success_rate": hourly_success_rate,
+ "avg_response_time_ms": avg_response_time,
+ "error_rate": error_rate,
+ "requests_last_hour": metrics_summary.get("requests_last_hour", 0)
+ }
+ }
+
+ def get_performance_trends(self, hours: int = 24) -> Dict[str, Any]:
+ """
+ Get performance trends over time.
+
+ Args:
+ hours: Number of hours to analyze
+
+ Returns:
+ Dict containing trend analysis
+ """
+ # Get time series data for key metrics
+ response_time_series = metrics_collector.get_time_series("request_duration_ms", hours)
+ request_count_series = metrics_collector.get_time_series("requests_total", hours)
+
+ # Calculate trends
+ trends = {
+ "period_hours": hours,
+ "response_time_trend": self._calculate_trend(response_time_series),
+ "request_volume_trend": self._calculate_trend(request_count_series),
+ "peak_hours": self._identify_peak_hours(request_count_series),
+ "performance_summary": self._summarize_performance_trends(hours)
+ }
+
+ return trends
+
+ def generate_report(self, report_type: str = "daily") -> Dict[str, Any]:
+ """
+ Generate comprehensive monitoring report.
+
+ Args:
+ report_type: Type of report (hourly, daily, weekly)
+
+ Returns:
+ Dict containing formatted report
+ """
+ if report_type == "hourly":
+ hours = 1
+ elif report_type == "daily":
+ hours = 24
+ elif report_type == "weekly":
+ hours = 168
+ else:
+ hours = 24
+
+ current_time = time.time()
+ start_time = current_time - (hours * 3600)
+
+ # Get comprehensive data
+ metrics_summary = metrics_collector.get_metrics_summary()
+ performance_report = metrics_collector.get_performance_report()
+ request_history = metrics_collector.get_request_history(hours=hours, limit=1000)
+
+ report = {
+ "report_type": report_type,
+ "generated_at": current_time,
+ "period": {
+ "start_time": start_time,
+ "end_time": current_time,
+ "duration_hours": hours
+ },
+ "executive_summary": self._generate_executive_summary(metrics_summary, request_history),
+ "performance_analysis": self._analyze_performance(request_history),
+ "usage_patterns": self._analyze_usage_patterns(request_history),
+ "error_analysis": self._analyze_errors(request_history),
+ "recommendations": self._generate_recommendations(metrics_summary, request_history),
+ "detailed_metrics": metrics_summary
+ }
+
+ return report
+
+ def _format_uptime(self, uptime_seconds: float) -> str:
+ """Format uptime in human-readable format."""
+ days = int(uptime_seconds // 86400)
+ hours = int((uptime_seconds % 86400) // 3600)
+ minutes = int((uptime_seconds % 3600) // 60)
+
+ if days > 0:
+ return f"{days}d {hours}h {minutes}m"
+ elif hours > 0:
+ return f"{hours}h {minutes}m"
+ else:
+ return f"{minutes}m"
+
+ def _get_overview_metrics(self, metrics_summary: Dict[str, Any]) -> Dict[str, Any]:
+ """Get overview metrics for dashboard."""
+ return {
+ "total_requests": metrics_summary.get("total_requests", 0),
+ "requests_last_hour": metrics_summary.get("requests_last_hour", 0),
+ "requests_last_day": metrics_summary.get("requests_last_day", 0),
+ "success_rate_hour": metrics_summary.get("success_rates", {}).get("last_hour", 0),
+ "success_rate_day": metrics_summary.get("success_rates", {}).get("last_day", 0),
+ "avg_response_time": metrics_summary.get("response_times", {}).get("average_ms", 0)
+ }
+
+ def _get_performance_metrics(self, metrics_summary: Dict[str, Any], performance_report: Dict[str, Any]) -> Dict[str, Any]:
+ """Get performance metrics for dashboard."""
+ response_times = metrics_summary.get("response_times", {})
+ percentiles = response_times.get("last_hour_percentiles", {})
+
+ return {
+ "response_time_percentiles": percentiles,
+ "average_response_time": response_times.get("average_ms", 0),
+ "trends": performance_report.get("trends", {}),
+ "recommendations": performance_report.get("recommendations", [])
+ }
+
+ def _get_request_metrics(self, recent_requests: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Get request metrics for dashboard."""
+ if not recent_requests:
+ return {"count": 0, "recent_requests": []}
+
+ # Group by endpoint
+ endpoint_counts = {}
+ for req in recent_requests:
+ endpoint = req.get("endpoint", "unknown")
+ endpoint_counts[endpoint] = endpoint_counts.get(endpoint, 0) + 1
+
+ return {
+ "count": len(recent_requests),
+ "endpoint_distribution": endpoint_counts,
+ "recent_requests": recent_requests[:10] # Last 10 requests
+ }
+
+ def _get_model_metrics(self, metrics_summary: Dict[str, Any]) -> Dict[str, Any]:
+ """Get model usage metrics for dashboard."""
+ model_usage = metrics_summary.get("model_usage", {})
+
+ return {
+ "usage_distribution": model_usage,
+ "total_models": len(model_usage),
+ "most_used_model": max(model_usage.keys(), key=lambda k: model_usage[k]["request_count"]) if model_usage else None
+ }
+
+ def _get_error_metrics(self, metrics_summary: Dict[str, Any]) -> Dict[str, Any]:
+ """Get error metrics for dashboard."""
+ error_stats = metrics_summary.get("error_statistics", {})
+
+ return {
+ "total_errors": error_stats.get("total_errors", 0),
+ "error_rate": error_stats.get("error_rate", 0),
+ "error_types": error_stats.get("error_types", {}),
+ "top_error": max(error_stats.get("error_types", {}).keys(),
+ key=lambda k: error_stats["error_types"][k]) if error_stats.get("error_types") else None
+ }
+
+ def _get_system_metrics(self) -> Dict[str, Any]:
+ """Get system metrics for dashboard."""
+ return {
+ "memory_usage_mb": self._get_memory_usage(),
+ "cpu_usage_percent": self._get_cpu_usage(),
+ "disk_usage_percent": self._get_disk_usage(),
+ "active_connections": self._get_active_connections()
+ }
+
+ def _get_active_alerts(self, metrics_summary: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Get active alerts for dashboard."""
+ alerts = []
+
+ # Check for high error rate
+ error_rate = metrics_summary.get("error_statistics", {}).get("error_rate", 0)
+ if error_rate > 5:
+ alerts.append({
+ "level": "warning",
+ "message": f"High error rate: {error_rate:.1f}%",
+ "timestamp": time.time()
+ })
+
+ # Check for slow response times
+ avg_response_time = metrics_summary.get("response_times", {}).get("average_ms", 0)
+ if avg_response_time > 10000:
+ alerts.append({
+ "level": "warning",
+ "message": f"Slow response times: {avg_response_time:.0f}ms average",
+ "timestamp": time.time()
+ })
+
+ # Check for low success rate
+ success_rate = metrics_summary.get("success_rates", {}).get("last_hour", 0)
+ if success_rate < 95:
+ alerts.append({
+ "level": "critical",
+ "message": f"Low success rate: {success_rate:.1f}%",
+ "timestamp": time.time()
+ })
+
+ return alerts
+
+ def _get_chart_data(self) -> Dict[str, Any]:
+ """Get data for dashboard charts."""
+ # Get time series data for charts
+ response_time_data = metrics_collector.get_time_series("request_duration_ms", hours=1)
+ request_count_data = metrics_collector.get_time_series("requests_total", hours=1)
+
+ return {
+ "response_time_chart": self._format_chart_data(response_time_data, "Response Time (ms)"),
+ "request_count_chart": self._format_chart_data(request_count_data, "Request Count"),
+ "success_rate_chart": self._calculate_success_rate_chart()
+ }
+
+ def _format_chart_data(self, time_series: List[Dict[str, Any]], label: str) -> Dict[str, Any]:
+ """Format time series data for charts."""
+ if not time_series:
+ return {"labels": [], "data": [], "label": label}
+
+ # Sort by timestamp
+ sorted_data = sorted(time_series, key=lambda x: x["timestamp"])
+
+ # Format for chart
+ labels = [datetime.fromtimestamp(point["timestamp"]).strftime("%H:%M") for point in sorted_data]
+ data = [point["value"] for point in sorted_data]
+
+ return {
+ "labels": labels,
+ "data": data,
+ "label": label
+ }
+
+ def _calculate_success_rate_chart(self) -> Dict[str, Any]:
+ """Calculate success rate chart data."""
+ # Get request history for last hour
+ requests = metrics_collector.get_request_history(hours=1, limit=1000)
+
+ if not requests:
+ return {"labels": [], "data": [], "label": "Success Rate (%)"}
+
+ # Group requests by 5-minute intervals
+ intervals = {}
+ for req in requests:
+ # Round timestamp to 5-minute intervals
+ interval_time = int(req["timestamp"] // 300) * 300
+ if interval_time not in intervals:
+ intervals[interval_time] = {"total": 0, "successful": 0}
+
+ intervals[interval_time]["total"] += 1
+ if req["success"]:
+ intervals[interval_time]["successful"] += 1
+
+ # Calculate success rates
+ labels = []
+ data = []
+ for interval_time in sorted(intervals.keys()):
+ interval_data = intervals[interval_time]
+ success_rate = (interval_data["successful"] / interval_data["total"]) * 100
+
+ labels.append(datetime.fromtimestamp(interval_time).strftime("%H:%M"))
+ data.append(round(success_rate, 1))
+
+ return {
+ "labels": labels,
+ "data": data,
+ "label": "Success Rate (%)"
+ }
+
+ def _get_memory_usage(self) -> float:
+ """Get current memory usage in MB."""
+ try:
+ import psutil
+ import os
+ process = psutil.Process(os.getpid())
+ return round(process.memory_info().rss / 1024 / 1024, 1)
+ except ImportError:
+ return 0.0
+
+ def _get_cpu_usage(self) -> float:
+ """Get current CPU usage percentage."""
+ try:
+ import psutil
+ return round(psutil.cpu_percent(interval=0.1), 1)
+ except ImportError:
+ return 0.0
+
+ def _get_disk_usage(self) -> float:
+ """Get current disk usage percentage."""
+ try:
+ import psutil
+ return round(psutil.disk_usage('/').percent, 1)
+ except ImportError:
+ return 0.0
+
+ def _get_active_connections(self) -> int:
+ """Get number of active connections."""
+ # This is a placeholder - in a real implementation,
+ # you would track active connections
+ return 0
+
+ def _calculate_trend(self, time_series: List[Dict[str, Any]]) -> str:
+ """Calculate trend direction from time series data."""
+ if len(time_series) < 2:
+ return "stable"
+
+ # Simple trend calculation based on first and last values
+ first_value = time_series[0]["value"]
+ last_value = time_series[-1]["value"]
+
+ if last_value > first_value * 1.1:
+ return "increasing"
+ elif last_value < first_value * 0.9:
+ return "decreasing"
+ else:
+ return "stable"
+
+ def _identify_peak_hours(self, request_series: List[Dict[str, Any]]) -> List[int]:
+ """Identify peak usage hours."""
+ # Group requests by hour
+ hourly_counts = {}
+ for point in request_series:
+ hour = datetime.fromtimestamp(point["timestamp"]).hour
+ hourly_counts[hour] = hourly_counts.get(hour, 0) + point["value"]
+
+ # Find top 3 peak hours
+ sorted_hours = sorted(hourly_counts.items(), key=lambda x: x[1], reverse=True)
+ return [hour for hour, count in sorted_hours[:3]]
+
+ def _summarize_performance_trends(self, hours: int) -> Dict[str, Any]:
+ """Summarize performance trends."""
+ # This is a simplified implementation
+ return {
+ "overall_trend": "stable",
+ "performance_score": 85, # Out of 100
+ "key_insights": [
+ "Response times are within acceptable range",
+ "Success rate is above target threshold",
+ "No significant performance degradation detected"
+ ]
+ }
+
+ def _generate_executive_summary(self, metrics_summary: Dict[str, Any], request_history: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Generate executive summary for reports."""
+ total_requests = len(request_history)
+ successful_requests = sum(1 for req in request_history if req["success"])
+ success_rate = (successful_requests / total_requests * 100) if total_requests > 0 else 0
+
+ avg_response_time = sum(req["response_time_ms"] for req in request_history) / total_requests if total_requests > 0 else 0
+
+ return {
+ "total_requests": total_requests,
+ "success_rate": round(success_rate, 2),
+ "average_response_time_ms": round(avg_response_time, 2),
+ "key_highlights": [
+ f"Processed {total_requests} requests",
+ f"Achieved {success_rate:.1f}% success rate",
+ f"Average response time: {avg_response_time:.0f}ms"
+ ]
+ }
+
+ def _analyze_performance(self, request_history: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Analyze performance from request history."""
+ if not request_history:
+ return {"message": "No requests to analyze"}
+
+ response_times = [req["response_time_ms"] for req in request_history]
+
+ return {
+ "response_time_analysis": {
+ "min_ms": min(response_times),
+ "max_ms": max(response_times),
+ "avg_ms": sum(response_times) / len(response_times),
+ "median_ms": sorted(response_times)[len(response_times) // 2]
+ },
+ "performance_grade": self._calculate_performance_grade(response_times)
+ }
+
+ def _analyze_usage_patterns(self, request_history: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Analyze usage patterns from request history."""
+ if not request_history:
+ return {"message": "No requests to analyze"}
+
+ # Analyze by hour
+ hourly_usage = {}
+ for req in request_history:
+ hour = datetime.fromtimestamp(req["timestamp"]).hour
+ hourly_usage[hour] = hourly_usage.get(hour, 0) + 1
+
+ # Analyze by endpoint
+ endpoint_usage = {}
+ for req in request_history:
+ endpoint = req.get("endpoint", "unknown")
+ endpoint_usage[endpoint] = endpoint_usage.get(endpoint, 0) + 1
+
+ return {
+ "hourly_distribution": hourly_usage,
+ "endpoint_distribution": endpoint_usage,
+ "peak_hour": max(hourly_usage.keys(), key=lambda k: hourly_usage[k]) if hourly_usage else None,
+ "most_used_endpoint": max(endpoint_usage.keys(), key=lambda k: endpoint_usage[k]) if endpoint_usage else None
+ }
+
+ def _analyze_errors(self, request_history: List[Dict[str, Any]]) -> Dict[str, Any]:
+ """Analyze errors from request history."""
+ errors = [req for req in request_history if not req["success"]]
+
+ if not errors:
+ return {"message": "No errors to analyze", "error_count": 0}
+
+ # Group by error type
+ error_types = {}
+ for error in errors:
+ error_type = error.get("error_type", "unknown")
+ error_types[error_type] = error_types.get(error_type, 0) + 1
+
+ return {
+ "error_count": len(errors),
+ "error_rate": (len(errors) / len(request_history)) * 100,
+ "error_types": error_types,
+ "most_common_error": max(error_types.keys(), key=lambda k: error_types[k]) if error_types else None
+ }
+
+ def _generate_recommendations(self, metrics_summary: Dict[str, Any], request_history: List[Dict[str, Any]]) -> List[str]:
+ """Generate recommendations based on analysis."""
+ recommendations = []
+
+ # Check response times
+ avg_response_time = metrics_summary.get("response_times", {}).get("average_ms", 0)
+ if avg_response_time > 5000:
+ recommendations.append("Consider optimizing model selection or upgrading hardware for better response times")
+
+ # Check error rate
+ error_rate = metrics_summary.get("error_statistics", {}).get("error_rate", 0)
+ if error_rate > 2:
+ recommendations.append("Investigate and address high error rate")
+
+ # Check success rate
+ success_rate = metrics_summary.get("success_rates", {}).get("last_hour", 0)
+ if success_rate < 98:
+ recommendations.append("Monitor service health and consider implementing additional error handling")
+
+ if not recommendations:
+ recommendations.append("System is performing well - continue monitoring")
+
+ return recommendations
+
+ def _calculate_performance_grade(self, response_times: List[float]) -> str:
+ """Calculate performance grade based on response times."""
+ avg_time = sum(response_times) / len(response_times)
+
+ if avg_time < 1000: # < 1 second
+ return "A"
+ elif avg_time < 3000: # < 3 seconds
+ return "B"
+ elif avg_time < 5000: # < 5 seconds
+ return "C"
+ elif avg_time < 10000: # < 10 seconds
+ return "D"
+ else:
+ return "F"
+
+
+# Global monitoring dashboard instance
+monitoring_dashboard = MonitoringDashboard()
\ No newline at end of file
diff --git a/packages/nlp-backend/ollama_client.py b/packages/nlp-backend/ollama_client.py
new file mode 100644
index 000000000..26c697e65
--- /dev/null
+++ b/packages/nlp-backend/ollama_client.py
@@ -0,0 +1,467 @@
+import asyncio
+import time
+from typing import List, Optional, Dict, Any
+import ollama
+from config import OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_TIMEOUT, OLLAMA_FALLBACK_MODEL
+from logging_config import get_logger, log_error, ErrorTypes
+from sql_processor import sql_processor
+
+logger = get_logger("ollama_client")
+
+class OllamaClient:
+ """
+ Ollama client wrapper for handling local LLM inference with proper error handling,
+ health checking, and configuration management.
+ """
+
+ def __init__(
+ self,
+ base_url: Optional[str] = None,
+ model: Optional[str] = None,
+ timeout: Optional[int] = None,
+ fallback_model: Optional[str] = None
+ ):
+ """
+ Initialize Ollama client with configuration from environment variables or parameters.
+
+ Args:
+ base_url: Ollama server URL (defaults to OLLAMA_BASE_URL env var or localhost:11434)
+ model: Primary model name (defaults to OLLAMA_MODEL env var or codellama:7b-instruct)
+ timeout: Request timeout in seconds (defaults to OLLAMA_TIMEOUT env var or 60)
+ fallback_model: Backup model if primary fails (defaults to OLLAMA_FALLBACK_MODEL env var)
+ """
+ self.base_url = base_url or OLLAMA_BASE_URL
+ self.model = model or OLLAMA_MODEL
+ self.timeout = timeout or OLLAMA_TIMEOUT
+ self.fallback_model = fallback_model or OLLAMA_FALLBACK_MODEL
+
+ # Configure ollama client
+ self.client = ollama.Client(host=self.base_url)
+
+ # Initialize model manager (lazy loading to avoid circular imports)
+ self._model_manager = None
+
+ logger.info(
+ "OllamaClient initialized successfully",
+ extra={
+ "base_url": self.base_url,
+ "primary_model": self.model,
+ "fallback_model": self.fallback_model,
+ "timeout": self.timeout
+ }
+ )
+
+ async def health_check(self) -> bool:
+ """
+ Check if Ollama server is available and responsive.
+
+ Returns:
+ bool: True if server is healthy, False otherwise
+ """
+ try:
+ # Run in thread pool since ollama client is synchronous
+ loop = asyncio.get_event_loop()
+
+ # Apply timeout to health check as well
+ health_check_task = loop.run_in_executor(None, self.client.list)
+ models = await asyncio.wait_for(health_check_task, timeout=10) # 10 second timeout for health check
+
+ logger.info("Ollama health check passed")
+ return True
+ except asyncio.TimeoutError as e:
+ log_error(logger, e, ErrorTypes.TIMEOUT_ERROR, operation="health_check", timeout=10)
+ return False
+ except ConnectionError as e:
+ log_error(logger, e, ErrorTypes.CONNECTION_ERROR, operation="health_check", base_url=self.base_url)
+ return False
+ except Exception as e:
+ log_error(logger, e, ErrorTypes.UNKNOWN_ERROR, operation="health_check")
+ return False
+
+ def get_available_models(self) -> List[str]:
+ """
+ Get list of available models from Ollama server.
+
+ Returns:
+ List[str]: List of available model names
+
+ Raises:
+ ConnectionError: If unable to connect to Ollama server
+ Exception: For other unexpected errors
+ """
+ try:
+ models_response = self.client.list()
+ # Handle both old dict format and new object format
+ if hasattr(models_response, 'models'):
+ # New format: models_response is an object with models attribute
+ models = models_response.models
+ else:
+ # Old format: models_response is a dict
+ models = models_response.get('models', [])
+
+ model_names = []
+ for model in models:
+ if hasattr(model, 'model'):
+ # New format: model object with 'model' attribute
+ model_names.append(model.model)
+ elif hasattr(model, 'name'):
+ # Alternative format: model object with 'name' attribute
+ model_names.append(model.name)
+ elif isinstance(model, dict):
+ # Old format: model is a dict
+ model_names.append(model.get('name', str(model)))
+ else:
+ # Fallback: convert to string
+ model_names.append(str(model))
+
+ logger.info(f"Available models: {model_names}")
+ return model_names
+ except ConnectionError as e:
+ log_error(logger, e, ErrorTypes.CONNECTION_ERROR, operation="get_available_models", base_url=self.base_url)
+ raise ConnectionError(f"Unable to connect to Ollama server at {self.base_url}: {str(e)}")
+ except Exception as e:
+ log_error(logger, e, ErrorTypes.UNKNOWN_ERROR, operation="get_available_models")
+ raise Exception(f"Error retrieving models from Ollama server: {str(e)}")
+
+ async def generate_sql(self, prompt: str, model_override: Optional[str] = None) -> str:
+ """
+ Generate SQL from natural language prompt using Ollama.
+
+ Args:
+ prompt: Natural language description of desired SQL
+ model_override: Optional model to use instead of default
+
+ Returns:
+ str: Generated SQL expression
+
+ Raises:
+ Exception: If generation fails or times out
+ """
+ target_model = model_override or self.model
+
+ # Enhanced system prompt for computed field expressions
+ system_prompt = """You are an expert SQL assistant for creating computed fields in Graphic Walker.
+
+IMPORTANT RULES:
+1. Output ONLY the SQL expression - no explanations, no CREATE statements, no semicolons
+2. For conditional logic, ALWAYS use CASE WHEN ... THEN ... ELSE ... END statements
+3. For comparisons between fields, use CASE statements, NOT arithmetic operations
+4. For mathematical calculations, use direct arithmetic expressions
+5. Field names should be used as-is without quotes unless they contain spaces
+
+EXAMPLES:
+- "If field A > field B then 'High' else 'Low'" β CASE WHEN A > B THEN 'High' ELSE 'Low' END
+- "Calculate 10% of price" β price * 0.1
+- "If status is active then Premium else Basic" β CASE WHEN status = 'active' THEN 'Premium' ELSE 'Basic' END
+
+Output ONLY the expression:"""
+
+ start_time = time.time()
+
+ try:
+ logger.info(
+ "Starting SQL generation",
+ extra={
+ "model": target_model,
+ "prompt_length": len(prompt),
+ "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt
+ }
+ )
+
+ # Run in thread pool since ollama client is synchronous
+ loop = asyncio.get_event_loop()
+
+ # Create the generation task with timeout
+ generation_task = loop.run_in_executor(
+ None,
+ self._generate_with_model,
+ target_model,
+ system_prompt,
+ prompt
+ )
+
+ # Apply timeout
+ response = await asyncio.wait_for(generation_task, timeout=self.timeout)
+
+ # Process the response using advanced SQL processor
+ processing_result = sql_processor.process_response(
+ response,
+ context={
+ "model": target_model,
+ "prompt_length": len(prompt),
+ "request_timestamp": start_time
+ }
+ )
+ cleaned_sql = processing_result.cleaned
+
+ # Log processing details
+ if processing_result.warnings:
+ logger.warning(
+ "SQL processing warnings",
+ extra={
+ "model_used": target_model,
+ "warnings": processing_result.warnings,
+ "processing_steps": len(processing_result.processing_steps)
+ }
+ )
+
+ response_time = time.time() - start_time
+
+ logger.info(
+ "SQL generation completed successfully",
+ extra={
+ "model_used": target_model,
+ "response_time": round(response_time, 3),
+ "sql_length": len(cleaned_sql),
+ "sql_preview": cleaned_sql[:100] + "..." if len(cleaned_sql) > 100 else cleaned_sql,
+ "processing_steps": len(processing_result.processing_steps),
+ "sql_complexity": processing_result.metadata.get("estimated_complexity", "unknown")
+ }
+ )
+
+ return cleaned_sql
+
+ except asyncio.TimeoutError as e:
+ response_time = time.time() - start_time
+ log_error(
+ logger, e, ErrorTypes.TIMEOUT_ERROR,
+ operation="generate_sql",
+ model=target_model,
+ timeout=self.timeout,
+ response_time=round(response_time, 3),
+ prompt_length=len(prompt)
+ )
+
+ # Try fallback model if available and different from primary
+ if self.fallback_model and self.fallback_model != target_model:
+ logger.info(
+ "Attempting fallback model due to timeout",
+ extra={"fallback_model": self.fallback_model, "failed_model": target_model}
+ )
+ try:
+ return await self.generate_sql(prompt, self.fallback_model)
+ except Exception as fallback_error:
+ log_error(
+ logger, fallback_error, ErrorTypes.MODEL_ERROR,
+ operation="generate_sql_fallback",
+ fallback_model=self.fallback_model,
+ original_error="timeout"
+ )
+
+ raise Exception(f"Request timeout after {self.timeout} seconds")
+
+ except Exception as e:
+ response_time = time.time() - start_time
+ log_error(
+ logger, e, ErrorTypes.MODEL_ERROR,
+ operation="generate_sql",
+ model=target_model,
+ response_time=round(response_time, 3),
+ prompt_length=len(prompt)
+ )
+
+ # Try fallback model if available and different from primary
+ if self.fallback_model and self.fallback_model != target_model:
+ logger.info(
+ "Attempting fallback model due to generation error",
+ extra={"fallback_model": self.fallback_model, "failed_model": target_model}
+ )
+ try:
+ return await self.generate_sql(prompt, self.fallback_model)
+ except Exception as fallback_error:
+ log_error(
+ logger, fallback_error, ErrorTypes.MODEL_ERROR,
+ operation="generate_sql_fallback",
+ fallback_model=self.fallback_model,
+ original_error=str(e)
+ )
+
+ raise Exception(f"Model inference failed: {str(e)}")
+
+ def _generate_with_model(self, model: str, system_prompt: str, user_prompt: str) -> str:
+ """
+ Synchronous generation method to be run in thread pool.
+
+ Args:
+ model: Model name to use
+ system_prompt: System instruction
+ user_prompt: User's natural language prompt
+
+ Returns:
+ str: Raw response from model
+ """
+ response = self.client.chat(
+ model=model,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt}
+ ]
+ )
+
+ return response['message']['content']
+
+ def _clean_sql_response(self, response: str) -> str:
+ """
+ Clean SQL response by removing markdown formatting and code blocks.
+
+ Args:
+ response: Raw response from model
+
+ Returns:
+ str: Cleaned SQL expression
+ """
+ # Remove markdown code blocks and language tags
+ cleaned = response.strip()
+
+ # Remove code block markers
+ if cleaned.startswith("```"):
+ lines = cleaned.split('\n')
+ # Remove first line (```sql or ```)
+ lines = lines[1:]
+ # Remove last line if it's just ```
+ if lines and lines[-1].strip() == "```":
+ lines = lines[:-1]
+ cleaned = '\n'.join(lines)
+
+ # Remove inline code markers
+ cleaned = cleaned.strip("`")
+
+ # Remove common language tags
+ if cleaned.lower().startswith("sql"):
+ cleaned = cleaned[3:].strip()
+
+ return cleaned.strip()
+
+ def is_model_available(self, model_name: str) -> bool:
+ """
+ Check if a specific model is available on the Ollama server.
+
+ Args:
+ model_name: Name of the model to check
+
+ Returns:
+ bool: True if model is available, False otherwise
+ """
+ try:
+ available_models = self.get_available_models()
+ return model_name in available_models
+ except Exception as e:
+ logger.error(f"Failed to check model availability: {str(e)}")
+ return False
+
+ @property
+ def model_manager(self):
+ """Get or create ModelManager instance."""
+ if self._model_manager is None:
+ from model_manager import ModelManager
+ self._model_manager = ModelManager(self)
+ return self._model_manager
+
+ def get_model_info(self) -> Dict[str, Any]:
+ """
+ Get comprehensive model information including availability and configuration.
+
+ Returns:
+ Dict containing model configuration and availability info
+ """
+ return self.model_manager.get_model_info()
+
+ def get_recommended_models(self) -> List[str]:
+ """
+ Get list of recommended models for SQL generation tasks.
+
+ Returns:
+ List[str]: Recommended model names available on the server
+ """
+ return self.model_manager.recommend_models_for_sql()
+
+ async def generate_sql_with_details(self, prompt: str, model_override: Optional[str] = None) -> Dict[str, Any]:
+ """
+ Generate SQL with detailed processing information.
+
+ Args:
+ prompt: Natural language description of desired SQL
+ model_override: Optional model to use instead of default
+
+ Returns:
+ Dict containing SQL and detailed processing information
+ """
+ target_model = model_override or self.model
+ system_prompt = "You are an expert SQL assistant for creating computed fields in Graphic Walker. Output ONLY the SQL expression."
+
+ start_time = time.time()
+
+ try:
+ logger.info(
+ "Starting detailed SQL generation",
+ extra={
+ "model": target_model,
+ "prompt_length": len(prompt),
+ "include_details": True
+ }
+ )
+
+ # Run in thread pool since ollama client is synchronous
+ loop = asyncio.get_event_loop()
+ generation_task = loop.run_in_executor(
+ None,
+ self._generate_with_model,
+ target_model,
+ system_prompt,
+ prompt
+ )
+
+ # Apply timeout
+ response = await asyncio.wait_for(generation_task, timeout=self.timeout)
+
+ # Process the response with detailed information
+ processing_result = sql_processor.process_response(
+ response,
+ context={
+ "model": target_model,
+ "prompt_length": len(prompt),
+ "request_timestamp": start_time
+ }
+ )
+
+ response_time = time.time() - start_time
+
+ # Validate SQL syntax
+ validation_result = sql_processor.validate_sql_syntax(processing_result.cleaned)
+
+ result = {
+ "sql": processing_result.cleaned,
+ "model_used": target_model,
+ "response_time_ms": round(response_time * 1000, 2),
+ "processing": processing_result.to_dict(),
+ "validation": validation_result,
+ "metadata": {
+ "prompt_length": len(prompt),
+ "original_response_length": len(response),
+ "cleaned_sql_length": len(processing_result.cleaned),
+ "timestamp": start_time
+ }
+ }
+
+ logger.info(
+ "Detailed SQL generation completed",
+ extra={
+ "model_used": target_model,
+ "response_time": round(response_time, 3),
+ "sql_valid": validation_result["is_valid"],
+ "processing_warnings": len(processing_result.warnings),
+ "validation_errors": len(validation_result["errors"])
+ }
+ )
+
+ return result
+
+ except Exception as e:
+ response_time = time.time() - start_time
+ log_error(
+ logger, e, ErrorTypes.MODEL_ERROR,
+ operation="generate_sql_with_details",
+ model=target_model,
+ response_time=round(response_time, 3)
+ )
+ raise
\ No newline at end of file
diff --git a/packages/nlp-backend/ollama_service.py b/packages/nlp-backend/ollama_service.py
new file mode 100644
index 000000000..f5c5c19bc
--- /dev/null
+++ b/packages/nlp-backend/ollama_service.py
@@ -0,0 +1,461 @@
+import time
+import uuid
+from typing import Dict, Any, Optional, List
+from fastapi import HTTPException
+from ollama_client import OllamaClient
+from config import get_config_summary
+from logging_config import get_logger, log_error, ErrorTypes
+
+logger = get_logger("ollama_service")
+
+class OllamaService:
+ """
+ Service layer for Ollama integration with FastAPI.
+
+ Provides text-to-SQL conversion using local Ollama LLM models. This service manages
+ the Ollama client connection, health monitoring, and request processing with comprehensive
+ error handling and logging.
+ """
+
+ def __init__(self):
+ """
+ Initialize Ollama service with client and health checking.
+
+ Sets up the Ollama client connection and health monitoring system.
+ """
+ self.client = None
+ self.is_healthy = False
+ self.health_monitor = None
+ self._initialize_client()
+ self._initialize_health_monitor()
+
+ def _initialize_client(self) -> None:
+ """
+ Initialize Ollama client and perform initial setup.
+
+ Creates the OllamaClient instance and logs configuration details.
+ """
+ try:
+ self.client = OllamaClient()
+ config = get_config_summary()
+ logger.info(
+ "Ollama client initialized successfully",
+ extra={"configuration": config}
+ )
+ except Exception as e:
+ log_error(
+ logger, e, ErrorTypes.CONFIGURATION_ERROR,
+ operation="initialize_client"
+ )
+ self.client = None
+
+ def _initialize_health_monitor(self) -> None:
+ """Initialize health monitoring system."""
+ try:
+ from health_monitor import HealthMonitor
+ self.health_monitor = HealthMonitor(self)
+ logger.info("Health monitor initialized successfully")
+ except Exception as e:
+ log_error(
+ logger, e, ErrorTypes.CONFIGURATION_ERROR,
+ operation="initialize_health_monitor"
+ )
+ self.health_monitor = None
+
+ async def health_check(self) -> bool:
+ """
+ Perform basic health check on Ollama service.
+
+ Verifies that the Ollama client is initialized and the Ollama server is responding.
+
+ Returns:
+ bool: True if service is healthy and ready to process requests, False otherwise
+ """
+ if not self.client:
+ logger.warning("Ollama client not initialized")
+ return False
+
+ try:
+ self.is_healthy = await self.client.health_check()
+ return self.is_healthy
+ except Exception as e:
+ logger.error(f"Health check failed: {str(e)}")
+ self.is_healthy = False
+ return False
+
+ async def generate_sql(self, prompt: str, model_override: Optional[str] = None) -> Dict[str, Any]:
+ """
+ Generate SQL from natural language prompt using Ollama.
+
+ Processes a natural language prompt and generates corresponding SQL code using
+ the local Ollama LLM service. Includes comprehensive error handling, logging,
+ and validation.
+
+ Args:
+ prompt: Natural language description of desired SQL query
+ model_override: Optional model name to use instead of the configured default
+
+ Returns:
+ Dict containing either:
+ - 'sql' key with generated SQL string on success
+ - 'error' and 'details' keys with error information on failure
+ """
+ request_id = str(uuid.uuid4())[:8]
+ start_time = time.time()
+
+ # Create context logger for this request
+ request_logger = get_logger("ollama_service.request", request_id=request_id)
+
+ if not self.client:
+ log_error(
+ request_logger, Exception("Client not initialized"), ErrorTypes.CONFIGURATION_ERROR,
+ operation="generate_sql", request_id=request_id
+ )
+ return {"error": "Local LLM service unavailable", "details": "Ollama client initialization failed"}
+
+ if not prompt or not prompt.strip():
+ request_logger.warning(
+ "Empty prompt provided",
+ extra={"request_id": request_id, "prompt_length": len(prompt) if prompt else 0}
+ )
+ return {"error": "Invalid request", "details": "Prompt cannot be empty"}
+
+ try:
+ request_logger.info(
+ "Processing SQL generation request",
+ extra={
+ "request_id": request_id,
+ "prompt_length": len(prompt),
+ "model_override": model_override,
+ "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt
+ }
+ )
+
+ # Generate SQL using Ollama client
+ sql = await self.client.generate_sql(prompt.strip(), model_override)
+
+ if not sql or not sql.strip():
+ request_logger.warning(
+ "Generated SQL is empty",
+ extra={"request_id": request_id, "sql_length": len(sql) if sql else 0}
+ )
+ return {"error": "Invalid model response", "details": "Generated SQL is empty"}
+
+ response_time = time.time() - start_time
+ request_logger.info(
+ "SQL generation completed successfully",
+ extra={
+ "request_id": request_id,
+ "response_time": round(response_time, 3),
+ "sql_length": len(sql),
+ "sql_preview": sql[:100] + "..." if len(sql) > 100 else sql
+ }
+ )
+
+ return {"sql": sql}
+
+ except Exception as e:
+ response_time = time.time() - start_time
+
+ # Categorize error types for better user experience and logging
+ error_type = self._categorize_error(e)
+
+ log_error(
+ request_logger, e, error_type,
+ operation="generate_sql",
+ request_id=request_id,
+ response_time=round(response_time, 3),
+ prompt_length=len(prompt),
+ model_override=model_override
+ )
+
+ return self._format_error_response(e, error_type)
+
+ def get_service_info(self) -> Dict[str, Any]:
+ """
+ Get comprehensive service information for debugging and monitoring.
+
+ Returns:
+ Dict containing service status and configuration info
+ """
+ if not self.client:
+ return {
+ "status": "unavailable",
+ "client_initialized": False,
+ "is_healthy": False,
+ "error": "Ollama client not initialized"
+ }
+
+ try:
+ model_info = self.client.get_model_info()
+ return {
+ "status": "available" if self.is_healthy else "unhealthy",
+ "client_initialized": True,
+ "is_healthy": self.is_healthy,
+ "configuration": get_config_summary(),
+ "model_info": model_info
+ }
+ except Exception as e:
+ return {
+ "status": "error",
+ "client_initialized": True,
+ "is_healthy": False,
+ "error": str(e)
+ }
+
+ def get_available_models(self) -> Dict[str, Any]:
+ """
+ Get list of available models from Ollama server.
+
+ Returns:
+ Dict containing available models or error information
+ """
+ if not self.client:
+ return {"error": "Local LLM service unavailable", "details": "Ollama client not initialized"}
+
+ try:
+ models = self.client.get_available_models()
+ recommended = self.client.get_recommended_models()
+
+ return {
+ "available_models": models,
+ "recommended_models": recommended,
+ "total_available": len(models),
+ "total_recommended": len(recommended)
+ }
+ except Exception as e:
+ logger.error(f"Failed to get available models: {str(e)}")
+ return {"error": "Failed to retrieve models", "details": str(e)}
+
+ def _categorize_error(self, error: Exception) -> str:
+ """
+ Categorize error for consistent handling and logging.
+
+ Args:
+ error: Exception that occurred
+
+ Returns:
+ Error type string from ErrorTypes
+ """
+ error_str = str(error).lower()
+
+ if "timeout" in error_str:
+ return ErrorTypes.TIMEOUT_ERROR
+ elif "connection" in error_str or "unavailable" in error_str:
+ return ErrorTypes.CONNECTION_ERROR
+ elif "model" in error_str and ("not" in error_str or "available" in error_str):
+ return ErrorTypes.MODEL_ERROR
+ elif "validation" in error_str or "invalid" in error_str:
+ return ErrorTypes.VALIDATION_ERROR
+ else:
+ return ErrorTypes.UNKNOWN_ERROR
+
+ def _format_error_response(self, error: Exception, error_type: str) -> Dict[str, Any]:
+ """
+ Format error response based on error type.
+
+ Args:
+ error: Exception that occurred
+ error_type: Categorized error type
+
+ Returns:
+ Formatted error response dict
+ """
+ error_responses = {
+ ErrorTypes.TIMEOUT_ERROR: {
+ "error": "Request timeout",
+ "details": "Model inference took too long"
+ },
+ ErrorTypes.CONNECTION_ERROR: {
+ "error": "Local LLM service unavailable",
+ "details": "Connection to Ollama server failed"
+ },
+ ErrorTypes.MODEL_ERROR: {
+ "error": "Model not available",
+ "details": str(error)
+ },
+ ErrorTypes.VALIDATION_ERROR: {
+ "error": "Invalid request",
+ "details": str(error)
+ },
+ ErrorTypes.UNKNOWN_ERROR: {
+ "error": "Model inference failed",
+ "details": str(error)
+ }
+ }
+
+ return error_responses.get(error_type, {
+ "error": "Unknown error occurred",
+ "details": str(error)
+ })
+
+ async def perform_startup_validation(self) -> Dict[str, Any]:
+ """
+ Perform comprehensive startup validation.
+
+ Returns:
+ Dict containing startup validation results
+ """
+ if not self.health_monitor:
+ return {
+ "error": "Health monitor not available",
+ "details": "Health monitoring system not initialized"
+ }
+
+ return await self.health_monitor.perform_startup_validation()
+
+ async def get_detailed_health(self) -> Dict[str, Any]:
+ """
+ Get detailed health information including all checks.
+
+ Returns:
+ Dict containing comprehensive health information
+ """
+ if not self.health_monitor:
+ return {
+ "status": "unknown",
+ "message": "Health monitor not available",
+ "timestamp": time.time()
+ }
+
+ return await self.health_monitor.perform_health_check(include_detailed=True)
+
+ def get_health_metrics(self) -> Dict[str, Any]:
+ """
+ Get health metrics and statistics.
+
+ Returns:
+ Dict containing health metrics
+ """
+ if not self.health_monitor:
+ return {
+ "error": "Health monitor not available",
+ "details": "Health monitoring system not initialized"
+ }
+
+ return self.health_monitor.get_health_metrics()
+
+ def get_health_history(self, limit: int = 10) -> List[Dict[str, Any]]:
+ """
+ Get recent health check history.
+
+ Args:
+ limit: Maximum number of history entries to return
+
+ Returns:
+ List of health check results
+ """
+ if not self.health_monitor:
+ return []
+
+ return self.health_monitor.get_health_history(limit)
+
+ async def generate_sql_with_processing_details(self, prompt: str, model_override: Optional[str] = None) -> Dict[str, Any]:
+ """
+ Generate SQL with detailed processing information.
+
+ Similar to generate_sql() but returns comprehensive details about the generation
+ process including validation results, processing steps, and timing information.
+ Useful for debugging and monitoring.
+
+ Args:
+ prompt: Natural language description of desired SQL query
+ model_override: Optional model name to use instead of the configured default
+
+ Returns:
+ Dict containing SQL, validation results, processing details, and metadata
+ """
+ request_id = str(uuid.uuid4())[:8]
+ request_logger = get_logger("ollama_service.detailed_request", request_id=request_id)
+
+ if not self.client:
+ return {
+ "error": "Local LLM service unavailable",
+ "details": "Ollama client initialization failed",
+ "request_id": request_id
+ }
+
+ if not prompt or not prompt.strip():
+ return {
+ "error": "Invalid request",
+ "details": "Prompt cannot be empty",
+ "request_id": request_id
+ }
+
+ try:
+ request_logger.info(
+ "Processing detailed SQL generation request",
+ extra={
+ "request_id": request_id,
+ "prompt_length": len(prompt),
+ "model_override": model_override
+ }
+ )
+
+ result = await self.client.generate_sql_with_details(prompt.strip(), model_override)
+ result["request_id"] = request_id
+
+ request_logger.info(
+ "Detailed SQL generation completed successfully",
+ extra={
+ "request_id": request_id,
+ "sql_valid": result.get("validation", {}).get("is_valid", False),
+ "processing_warnings": len(result.get("processing", {}).get("warnings", [])),
+ "response_time": result.get("response_time_ms", 0)
+ }
+ )
+
+ return result
+
+ except Exception as e:
+ error_type = self._categorize_error(e)
+ log_error(
+ request_logger, e, error_type,
+ operation="generate_sql_with_details",
+ request_id=request_id,
+ prompt_length=len(prompt)
+ )
+
+ error_response = self._format_error_response(e, error_type)
+ error_response["request_id"] = request_id
+ return error_response
+
+ def process_sql_response(self, raw_response: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ """
+ Process raw SQL response using the SQL processor.
+
+ Cleans and validates raw SQL output from the Ollama model. Useful for testing
+ and debugging the SQL processing pipeline independently.
+
+ Args:
+ raw_response: Raw SQL response text from Ollama to process
+ context: Optional context information for processing
+
+ Returns:
+ Dict containing processing results, validation status, and cleaned SQL
+ """
+ from sql_processor import sql_processor
+
+ try:
+ processing_result = sql_processor.process_response(raw_response, context)
+ validation_result = sql_processor.validate_sql_syntax(processing_result.cleaned)
+
+ return {
+ "success": True,
+ "processing": processing_result.to_dict(),
+ "validation": validation_result,
+ "cleaned_sql": processing_result.cleaned
+ }
+
+ except Exception as e:
+ logger.error(f"SQL processing failed: {str(e)}")
+ return {
+ "success": False,
+ "error": "SQL processing failed",
+ "details": str(e),
+ "original_response": raw_response
+ }
+
+
+# Global service instance
+ollama_service = OllamaService()
\ No newline at end of file
diff --git a/packages/nlp-backend/requirements.txt b/packages/nlp-backend/requirements.txt
new file mode 100644
index 000000000..997c74ae3
--- /dev/null
+++ b/packages/nlp-backend/requirements.txt
@@ -0,0 +1,8 @@
+fastapi
+uvicorn
+python-dotenv
+ollama
+pytest
+pytest-asyncio
+pytest-cov
+psutil
diff --git a/packages/nlp-backend/sql_processor.py b/packages/nlp-backend/sql_processor.py
new file mode 100644
index 000000000..0956b8004
--- /dev/null
+++ b/packages/nlp-backend/sql_processor.py
@@ -0,0 +1,499 @@
+import re
+import logging
+from typing import Optional, Dict, Any, List
+from dataclasses import dataclass
+from logging_config import get_logger
+
+logger = get_logger("sql_processor")
+
+@dataclass
+class ProcessingResult:
+ """Result of SQL processing operation."""
+ original: str
+ cleaned: str
+ processing_steps: List[str]
+ warnings: List[str]
+ metadata: Dict[str, Any]
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert to dictionary for JSON serialization."""
+ return {
+ "original": self.original,
+ "cleaned": self.cleaned,
+ "processing_steps": self.processing_steps,
+ "warnings": self.warnings,
+ "metadata": self.metadata
+ }
+
+class SQLProcessor:
+ """
+ Advanced SQL response processor for cleaning and validating Ollama model outputs.
+ Handles various markdown formats, code blocks, and common model response patterns.
+ """
+
+ def __init__(self):
+ """Initialize SQL processor with cleaning patterns."""
+ self.markdown_patterns = [
+ # Code blocks with language specification
+ (r'```sql\s*\n(.*?)\n```', r'\1', 'Removed SQL code block markers'),
+ (r'```SQL\s*\n(.*?)\n```', r'\1', 'Removed SQL code block markers (uppercase)'),
+ (r'```\s*\n(.*?)\n```', r'\1', 'Removed generic code block markers'),
+
+ # Inline code markers
+ (r'`([^`]+)`', r'\1', 'Removed inline code markers'),
+
+ # Language tags at the beginning
+ (r'^sql\s*\n?', '', 'Removed SQL language tag'),
+ (r'^SQL\s*\n?', '', 'Removed SQL language tag (uppercase)'),
+
+ # Common prefixes from model responses
+ (r'^(Here\'s the SQL|Here is the SQL|The SQL query is|SQL:)\s*:?\s*\n?', '', 'Removed response prefix'),
+ (r'^(Query|SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s*:', r'\1', 'Cleaned query prefix'),
+ ]
+
+ # Enhanced patterns for Ollama-specific response cleaning
+ self.ollama_patterns = [
+ # Remove explanatory text before SQL
+ (r'^.*?(?=SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH|CASE)', '', 'Removed explanatory text before SQL'),
+
+ # Remove explanatory text after SQL (common patterns)
+ (r'(;|\n)\s*(This query|This SQL|The above|Note:|Explanation:|Here\'s what|This will|This gives).*$', r'\1', 'Removed explanatory text after SQL'),
+ (r'\.\s*(This query|This SQL|The above|Note:|Explanation:|Here\'s what|This will|This gives).*$', '', 'Removed explanatory text after SQL'),
+
+ # Remove common Ollama response patterns
+ (r'^(Based on your request|To create|For this|You can use|Try this|To calculate).*?[:]\s*', '', 'Removed Ollama response prefix'),
+
+ # Remove markdown-style explanations
+ (r'\*\*.*?\*\*', '', 'Removed markdown bold text'),
+ (r'\n\s*\*.*$', '', 'Removed bullet point explanations'),
+
+ # Remove "CREATE COMPUTED FIELD" wrapper if present
+ (r'^CREATE\s+COMPUTED\s+FIELD\s+\w+\s+AS\s*\(\s*(.*?)\s*\)\s*$', r'\1', 'Extracted expression from computed field wrapper'),
+
+ # Clean up common expression patterns
+ (r'^CASE\s+WHEN\s+(.*?)\s+END\s*$', r'CASE WHEN \1 END', 'Cleaned CASE expression'),
+
+ # Remove trailing explanatory sentences
+ (r'\s+(This\s+\w+|It\s+\w+|The\s+\w+).*$', '', 'Removed trailing explanation'),
+ ]
+
+ self.cleanup_patterns = [
+ # Remove extra whitespace
+ (r'\n\s*\n', '\n', 'Removed extra blank lines'),
+ (r'^\s+', '', 'Removed leading whitespace'),
+ (r'\s+$', '', 'Removed trailing whitespace'),
+
+ # Fix common formatting issues
+ (r'\s+', ' ', 'Normalized whitespace'),
+ (r';\s*;+', ';', 'Removed duplicate semicolons'),
+
+ # Fix unmatched parentheses at the end
+ (r'\)\s*$', '', 'Removed trailing unmatched parenthesis'),
+ (r'^\s*\(', '', 'Removed leading unmatched parenthesis'),
+ ]
+
+ # SQL keywords for validation
+ self.sql_keywords = {
+ 'SELECT', 'FROM', 'WHERE', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER',
+ 'GROUP', 'BY', 'HAVING', 'ORDER', 'LIMIT', 'OFFSET', 'UNION', 'INTERSECT',
+ 'EXCEPT', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE',
+ 'TABLE', 'INDEX', 'VIEW', 'ALTER', 'DROP', 'TRUNCATE', 'WITH', 'AS',
+ 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'AND', 'OR', 'NOT', 'IN', 'EXISTS',
+ 'BETWEEN', 'LIKE', 'IS', 'NULL', 'DISTINCT', 'ALL', 'ANY', 'SOME'
+ }
+
+ def process_response(self, raw_response: str, context: Optional[Dict[str, Any]] = None) -> ProcessingResult:
+ """
+ Process raw model response into clean SQL.
+
+ Args:
+ raw_response: Raw response from the model
+ context: Optional context information for processing
+
+ Returns:
+ ProcessingResult with cleaned SQL and metadata
+ """
+ if not raw_response or not raw_response.strip():
+ return ProcessingResult(
+ original=raw_response or "",
+ cleaned="",
+ processing_steps=["Input was empty"],
+ warnings=["Empty or null input provided"],
+ metadata={"input_length": 0, "output_length": 0}
+ )
+
+ original = raw_response
+ current = raw_response
+ steps = []
+ warnings = []
+
+ logger.info(
+ "Starting SQL response processing",
+ extra={
+ "input_length": len(raw_response),
+ "input_preview": raw_response[:100] + "..." if len(raw_response) > 100 else raw_response
+ }
+ )
+
+ # Step 1: Remove markdown formatting
+ current, markdown_steps = self._remove_markdown_formatting(current)
+ steps.extend(markdown_steps)
+
+ # Step 2: Apply Ollama-specific cleaning
+ current, ollama_steps = self._apply_ollama_cleaning(current)
+ steps.extend(ollama_steps)
+
+ # Step 3: Clean up whitespace and formatting
+ current, cleanup_steps = self._cleanup_formatting(current)
+ steps.extend(cleanup_steps)
+
+ # Step 4: Validate and extract SQL
+ current, validation_warnings = self._validate_and_extract_sql(current)
+ warnings.extend(validation_warnings)
+
+ # Step 5: Final cleanup
+ current = current.strip()
+ if current != current.strip():
+ steps.append("Final whitespace cleanup")
+
+ # Generate metadata
+ metadata = self._generate_metadata(original, current, context)
+
+ # Log processing results
+ logger.info(
+ "SQL response processing completed",
+ extra={
+ "original_length": len(original),
+ "cleaned_length": len(current),
+ "steps_count": len(steps),
+ "warnings_count": len(warnings),
+ "cleaned_preview": current[:100] + "..." if len(current) > 100 else current
+ }
+ )
+
+ return ProcessingResult(
+ original=original,
+ cleaned=current,
+ processing_steps=steps,
+ warnings=warnings,
+ metadata=metadata
+ )
+
+ def _remove_markdown_formatting(self, text: str) -> tuple[str, List[str]]:
+ """Remove markdown formatting from text."""
+ current = text
+ steps = []
+
+ for pattern, replacement, description in self.markdown_patterns:
+ new_text = re.sub(pattern, replacement, current, flags=re.DOTALL | re.IGNORECASE)
+ if new_text != current:
+ steps.append(description)
+ current = new_text
+
+ return current, steps
+
+ def _apply_ollama_cleaning(self, text: str) -> tuple[str, List[str]]:
+ """Apply Ollama-specific cleaning patterns."""
+ current = text
+ steps = []
+
+ # First, try to extract SQL from common Ollama response patterns
+ for pattern, replacement, description in self.ollama_patterns:
+ new_text = re.sub(pattern, replacement, current, flags=re.DOTALL | re.IGNORECASE | re.MULTILINE)
+ if new_text != current:
+ steps.append(description)
+ current = new_text
+
+ # Special handling for computed field expressions
+ current = self._extract_computed_field_expression(current, steps)
+
+ # Remove any remaining non-SQL text at the beginning or end
+ current = self._extract_core_sql(current, steps)
+
+ return current, steps
+
+ def _extract_computed_field_expression(self, text: str, steps: List[str]) -> str:
+ """Extract the core expression from computed field responses."""
+ # Look for patterns like "CREATE COMPUTED FIELD ... AS (expression)"
+ computed_field_pattern = r'CREATE\s+COMPUTED\s+FIELD\s+\w+\s+AS\s*\(\s*(.*?)\s*\)'
+ match = re.search(computed_field_pattern, text, re.IGNORECASE | re.DOTALL)
+ if match:
+ steps.append("Extracted expression from CREATE COMPUTED FIELD wrapper")
+ return match.group(1).strip()
+
+ # Look for AS (expression) patterns
+ as_pattern = r'AS\s*\(\s*(.*?)\s*\)(?:\s*$|\s*;|\s*\n|$)'
+ match = re.search(as_pattern, text, re.IGNORECASE | re.DOTALL)
+ if match:
+ steps.append("Extracted expression from AS clause")
+ return match.group(1).strip()
+
+ return text
+
+ def _extract_core_sql(self, text: str, steps: List[str]) -> str:
+ """Extract the core SQL/expression from mixed content."""
+ if not text.strip():
+ return text
+
+ # First try to find SQL in code blocks or after common patterns
+ sql_patterns = [
+ r'```sql\s*\n(.*?)\n```',
+ r'```\s*\n(.*?)\n```',
+ r'AS\s*\(\s*(.*?)\s*\)',
+ r'(\bCASE\b.*?(?:\bEND\b|$))',
+ r'(\bSELECT\b.*)',
+ ]
+
+ for pattern in sql_patterns:
+ match = re.search(pattern, text, re.DOTALL | re.IGNORECASE)
+ if match:
+ extracted = match.group(1).strip()
+ if extracted and len(extracted) > 5: # Reasonable SQL length
+ steps.append("Extracted SQL from pattern match")
+ return extracted
+
+ # If no pattern match, filter line by line
+ lines = text.split('\n')
+ sql_lines = []
+
+ for line in lines:
+ line = line.strip()
+ if not line:
+ continue
+
+ # Skip obvious explanation lines
+ if any(line.lower().startswith(prefix) for prefix in [
+ 'this query', 'this sql', 'the above', 'note:', 'explanation:',
+ 'here\'s what', 'this will', 'to create', 'for this', 'you can use',
+ 'based on', 'try this', '*', '-', '#', 'this gives', 'this creates'
+ ]):
+ continue
+
+ # Skip lines that are clearly explanatory
+ if any(phrase in line.lower() for phrase in [
+ 'will create', 'categorizes', 'gives different', 'based on',
+ 'this field', 'the field', 'customers', 'revenue'
+ ]) and not any(keyword in line.upper() for keyword in ['CASE', 'WHEN', 'THEN', 'ELSE']):
+ continue
+
+ # Keep lines that look like SQL
+ if any(keyword in line.upper() for keyword in [
+ 'SELECT', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'AND', 'OR',
+ 'SUM', 'COUNT', 'AVG', 'MAX', 'MIN', 'COALESCE', 'NULLIF',
+ 'SUBSTRING', 'LENGTH', 'UPPER', 'LOWER', 'TRIM', 'CAST',
+ 'EXTRACT', 'DATE', 'YEAR', 'MONTH', 'DAY'
+ ]):
+ sql_lines.append(line)
+ elif re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*\s*[=<>!]+', line):
+ # Looks like a condition
+ sql_lines.append(line)
+ elif re.match(r'^[\(\)\s\w\d\+\-\*\/\.,\'\"=<>!]+$', line) and len(line) > 3:
+ # Looks like an expression
+ sql_lines.append(line)
+
+ if sql_lines and len(sql_lines) < len(lines):
+ steps.append("Filtered out non-SQL explanation lines")
+ return ' '.join(sql_lines)
+
+ return text
+
+ def _cleanup_formatting(self, text: str) -> tuple[str, List[str]]:
+ """Clean up whitespace and formatting issues."""
+ current = text
+ steps = []
+
+ for pattern, replacement, description in self.cleanup_patterns:
+ new_text = re.sub(pattern, replacement, current, flags=re.MULTILINE)
+ if new_text != current:
+ steps.append(description)
+ current = new_text
+
+ return current, steps
+
+ def _validate_and_extract_sql(self, text: str) -> tuple[str, List[str]]:
+ """Validate and extract SQL from processed text."""
+ warnings = []
+
+ if not text.strip():
+ warnings.append("Processed text is empty")
+ return text, warnings
+
+ # Check if it looks like SQL
+ text_upper = text.upper()
+ has_sql_keywords = any(keyword in text_upper for keyword in self.sql_keywords)
+
+ if not has_sql_keywords:
+ warnings.append("Text does not appear to contain SQL keywords")
+
+ # Check for common issues
+ if text.count('(') != text.count(')'):
+ warnings.append("Unmatched parentheses detected")
+
+ if text.count("'") % 2 != 0:
+ warnings.append("Unmatched single quotes detected")
+
+ if text.count('"') % 2 != 0:
+ warnings.append("Unmatched double quotes detected")
+
+ # Check for multiple statements
+ statements = [stmt.strip() for stmt in text.split(';') if stmt.strip()]
+ if len(statements) > 1:
+ warnings.append(f"Multiple SQL statements detected ({len(statements)} statements)")
+ # Return only the first statement for safety
+ text = statements[0]
+ if not text.endswith(';'):
+ text += ';'
+
+ # Check for dangerous keywords (basic safety)
+ dangerous_keywords = ['DROP', 'DELETE', 'TRUNCATE', 'ALTER', 'CREATE', 'INSERT', 'UPDATE']
+ found_dangerous = [kw for kw in dangerous_keywords if kw in text_upper]
+ if found_dangerous:
+ warnings.append(f"Potentially dangerous SQL keywords detected: {', '.join(found_dangerous)}")
+
+ return text, warnings
+
+ def _generate_metadata(self, original: str, cleaned: str, context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ """Generate metadata about the processing operation."""
+ metadata = {
+ "original_length": len(original),
+ "cleaned_length": len(cleaned),
+ "reduction_ratio": 1 - (len(cleaned) / len(original)) if original else 0,
+ "has_sql_keywords": self._has_sql_keywords(cleaned),
+ "estimated_complexity": self._estimate_sql_complexity(cleaned),
+ "processing_timestamp": __import__('time').time()
+ }
+
+ if context:
+ metadata["context"] = context
+
+ return metadata
+
+ def _has_sql_keywords(self, text: str) -> bool:
+ """Check if text contains SQL keywords."""
+ text_upper = text.upper()
+ return any(keyword in text_upper for keyword in self.sql_keywords)
+
+ def _estimate_sql_complexity(self, sql: str) -> str:
+ """Estimate SQL complexity based on keywords and structure."""
+ if not sql:
+ return "empty"
+
+ sql_upper = sql.upper()
+
+ # Count complexity indicators
+ complexity_score = 0
+
+ # Basic keywords
+ if 'SELECT' in sql_upper:
+ complexity_score += 1
+ if 'FROM' in sql_upper:
+ complexity_score += 1
+
+ # Joins
+ join_keywords = ['JOIN', 'INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'FULL JOIN']
+ complexity_score += sum(1 for kw in join_keywords if kw in sql_upper)
+
+ # Subqueries
+ complexity_score += sql.count('(') * 0.5
+
+ # Aggregations
+ agg_keywords = ['GROUP BY', 'HAVING', 'COUNT', 'SUM', 'AVG', 'MAX', 'MIN']
+ complexity_score += sum(1 for kw in agg_keywords if kw in sql_upper)
+
+ # Window functions
+ if 'OVER' in sql_upper:
+ complexity_score += 2
+
+ # CTEs
+ if 'WITH' in sql_upper:
+ complexity_score += 2
+
+ # Classify complexity
+ if complexity_score <= 2:
+ return "simple"
+ elif complexity_score <= 5:
+ return "moderate"
+ elif complexity_score <= 10:
+ return "complex"
+ else:
+ return "very_complex"
+
+ def clean_sql_simple(self, raw_response: str) -> str:
+ """
+ Simple SQL cleaning method for backward compatibility.
+
+ Args:
+ raw_response: Raw response from model
+
+ Returns:
+ Cleaned SQL string
+ """
+ result = self.process_response(raw_response)
+ return result.cleaned
+
+ def validate_sql_syntax(self, sql: str) -> Dict[str, Any]:
+ """
+ Basic SQL syntax validation.
+
+ Args:
+ sql: SQL string to validate
+
+ Returns:
+ Dict with validation results
+ """
+ validation_result = {
+ "is_valid": True,
+ "errors": [],
+ "warnings": [],
+ "suggestions": []
+ }
+
+ if not sql or not sql.strip():
+ validation_result["is_valid"] = False
+ validation_result["errors"].append("SQL is empty")
+ return validation_result
+
+ sql_upper = sql.upper().strip()
+
+ # Check for basic SQL structure
+ if not any(keyword in sql_upper for keyword in ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP']):
+ validation_result["warnings"].append("No recognized SQL statement type found")
+
+ # Check for SELECT without FROM (unless it's a simple expression)
+ if sql_upper.startswith('SELECT') and 'FROM' not in sql_upper and not re.match(r'SELECT\s+[\d\s\+\-\*\/\(\)]+$', sql_upper):
+ validation_result["warnings"].append("SELECT statement without FROM clause")
+
+ # Check for unmatched quotes and parentheses
+ if sql.count("'") % 2 != 0:
+ validation_result["errors"].append("Unmatched single quotes")
+ validation_result["is_valid"] = False
+
+ if sql.count('"') % 2 != 0:
+ validation_result["errors"].append("Unmatched double quotes")
+ validation_result["is_valid"] = False
+
+ if sql.count('(') != sql.count(')'):
+ validation_result["errors"].append("Unmatched parentheses")
+ validation_result["is_valid"] = False
+
+ # Check for SQL injection patterns (basic)
+ injection_patterns = [
+ r';\s*(DROP|DELETE|TRUNCATE|ALTER)',
+ r'UNION\s+SELECT',
+ r'--\s*\w+',
+ r'/\*.*\*/'
+ ]
+
+ for pattern in injection_patterns:
+ if re.search(pattern, sql_upper):
+ validation_result["warnings"].append("Potentially dangerous SQL pattern detected")
+
+ # Suggestions
+ if not sql.rstrip().endswith(';'):
+ validation_result["suggestions"].append("Consider adding semicolon at the end")
+
+ return validation_result
+
+
+# Global SQL processor instance
+sql_processor = SQLProcessor()
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 9c87aa585..42e6be89f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2380,6 +2380,14 @@ chalk-template@^0.4.0:
dependencies:
chalk "^4.1.2"
+chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+ integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
+ dependencies:
+ ansi-styles "^4.1.0"
+ supports-color "^7.1.0"
+
chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -2389,14 +2397,6 @@ chalk@^2.4.2:
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
-chalk@^4.0.0, chalk@^4.1.2:
- version "4.1.2"
- resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
- integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
- dependencies:
- ansi-styles "^4.1.0"
- supports-color "^7.1.0"
-
char-regex@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
@@ -2580,6 +2580,18 @@ concat-map@0.0.1:
resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
+concurrently@^9.2.1:
+ version "9.2.1"
+ resolved "https://registry.npmmirror.com/concurrently/-/concurrently-9.2.1.tgz#248ea21b95754947be2dad9c3e4b60f18ca4e44f"
+ integrity sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==
+ dependencies:
+ chalk "4.1.2"
+ rxjs "7.8.2"
+ shell-quote "1.8.3"
+ supports-color "8.1.1"
+ tree-kill "1.2.2"
+ yargs "17.7.2"
+
convert-source-map@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
@@ -5057,7 +5069,9 @@ redux@^4.2.1:
resolved "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
dependencies:
- "@babel/runtime" "^7.9.2"
+ hastscript "^6.0.0"
+ parse-entities "^2.0.0"
+ prismjs "~1.27.0"
refractor@^3.6.0:
version "3.6.0"
@@ -5174,6 +5188,13 @@ rw@1:
resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
+rxjs@7.8.2:
+ version "7.8.2"
+ resolved "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b"
+ integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
+ dependencies:
+ tslib "^2.1.0"
+
rxjs@^7.3.0:
version "7.8.1"
resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
@@ -5230,6 +5251,11 @@ shebang-regex@^3.0.0:
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+shell-quote@1.8.3:
+ version "1.8.3"
+ resolved "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b"
+ integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==
+
signal-exit@^3.0.3, signal-exit@^3.0.7:
version "3.0.7"
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
@@ -5374,6 +5400,13 @@ sucrase@^3.32.0:
pirates "^4.0.1"
ts-interface-checker "^0.1.9"
+supports-color@8.1.1, supports-color@^8.0.0:
+ version "8.1.1"
+ resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
+ integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
+ dependencies:
+ has-flag "^4.0.0"
+
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -5535,6 +5568,11 @@ tr46@~0.0.3:
resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+tree-kill@1.2.2:
+ version "1.2.2"
+ resolved "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
+ integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
+
triangulate-contours@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/triangulate-contours/-/triangulate-contours-1.0.2.tgz#c25ddc9f0e0031f3910764cf17f6842d2f8fc274"
@@ -6225,7 +6263,7 @@ yargs-parser@^21.0.1, yargs-parser@^21.1.1:
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
-yargs@^17.3.1, yargs@~17.7.2:
+yargs@17.7.2, yargs@^17.3.1, yargs@~17.7.2:
version "17.7.2"
resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==