diff --git a/.bandit b/.bandit new file mode 100644 index 000000000..1e37c04a6 --- /dev/null +++ b/.bandit @@ -0,0 +1,15 @@ +# Bandit security linter configuration for SAMO-DL project +# This file configures bandit to ignore false positives and focus on real security issues + +[bandit] +# Keep B104 enabled; use inline `# nosec B104` on intentional 0.0.0.0 bindings. +# This ensures we catch real security issues while allowing intentional production bindings. + +# B104: Binding to all interfaces - Use inline # nosec B104 only where 0.0.0.0 is strictly required +# (e.g., Cloud Run entrypoint) with proper justification in code comments. + +# Include specific files and directories +include = src/, scripts/, deployment/ + +# Exclude test files and build artifacts +exclude = tests/, build/, __pycache__/, .git/, .pytest_cache/ diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index ee0c49c31..38be6ec83 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -18,11 +18,11 @@ jobs: permissions: pages: write id-token: write - + steps: - name: Checkout uses: actions/checkout@v4 - + - name: Debug - Check current directory structure run: | echo "=== Current Directory Structure ===" @@ -31,12 +31,12 @@ jobs: ls -la website/ || echo "Website directory not found" echo "=== Root HTML Files ===" ls -la *.html 2>/dev/null || echo "No HTML files in root" - + - name: Create clean website directory run: | # Create a completely clean directory with only website files mkdir -p website-deploy - + # Copy website files from the website/ directory (primary source) if [ -d "website" ]; then cp -r website/* website-deploy/ 2>/dev/null || true @@ -45,7 +45,7 @@ jobs: echo "ERROR: website/ directory not found!" exit 1 fi - + # Copy essential files from root if they don't exist in website/ if [ ! -f "website-deploy/index.html" ]; then cp index.html website-deploy/ 2>/dev/null || true @@ -53,10 +53,10 @@ jobs: if [ ! -f "website-deploy/README.md" ]; then cp README.md website-deploy/ 2>/dev/null || true fi - + # Copy .nojekyll file cp .nojekyll website-deploy/ 2>/dev/null || true - + # Remove any problematic directories that might have been copied rm -rf website-deploy/data/ rm -rf website-deploy/models/ @@ -64,31 +64,31 @@ jobs: rm -rf website-deploy/test_checkpoints/ rm -rf website-deploy/__pycache__/ rm -rf website-deploy/*/__pycache__/ - + # Remove any lock files find website-deploy -name "*.lock" -delete 2>/dev/null || true find website-deploy -name "*.incomplete_info.lock" -delete 2>/dev/null || true - + # Remove large files find website-deploy -name "*.pt" -delete 2>/dev/null || true find website-deploy -name "*.pth" -delete 2>/dev/null || true find website-deploy -name "*.safetensors" -delete 2>/dev/null || true find website-deploy -name "*.bin" -delete 2>/dev/null || true find website-deploy -name "*.onnx" -delete 2>/dev/null || true - + echo "=== Clean website directory created ===" ls -la website-deploy/ echo "=== HTML files in website-deploy ===" ls -la website-deploy/*.html 2>/dev/null || echo "No HTML files found" - + # Validate that we have the required files if [ ! -f "website-deploy/index.html" ]; then echo "ERROR: index.html not found in website-deploy!" exit 1 fi - + echo "✅ Deployment files ready" - + - name: Check GitHub Pages settings run: | echo "=== GitHub Pages Configuration ===" @@ -96,16 +96,17 @@ jobs: echo "Event: ${{ github.event_name }}" echo "Actor: ${{ github.actor }}" echo "Repository: ${{ github.repository }}" - + - name: Setup Pages uses: actions/configure-pages@v4 - + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: 'website-deploy' retention-days: 1 - + - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 000000000..d48488fd9 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,98 @@ +name: Code Quality Checks + +permissions: + contents: read + +on: + push: + branches: [ main, develop, feat/dl-* ] + pull_request: + branches: [ main, develop ] + +jobs: + quality-check: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: | + pyproject.toml + requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + + - name: Run Black (Code Formatting) + run: black --check src/ tests/ scripts/ + + - name: Run isort (Import Sorting) + run: isort --check-only src/ tests/ scripts/ + + - name: Run Flake8 (Linting) + run: flake8 src/ tests/ scripts/ + + - name: Run Pylint + run: pylint src/ tests/ scripts/ || true + continue-on-error: true + + - name: Run MyPy (Type Checking) + run: mypy src/ tests/ scripts/ + + - name: Run Bandit (Security) + run: bandit -r src/ scripts/ -f json -o bandit-report.json + + - name: Run Safety (Vulnerability Check) + run: safety check --json --output safety-report.json || true + continue-on-error: true + + - name: Run Tests + run: pytest tests/ --cov=src --cov-report=xml --cov-report=html --tb=short + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + if: matrix.python-version == '3.11' + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Archive test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.python-version }} + path: | + bandit-report.json + safety-report.json + coverage.xml + htmlcov/ + retention-days: 30 + + quality-gate: + runs-on: ubuntu-latest + needs: quality-check + if: always() + + steps: + - name: Quality Gate Check + run: | + if [ "${{ needs.quality-check.result }}" = "failure" ]; then + echo "❌ Quality checks failed" + exit 1 + else + echo "✅ All quality checks passed" + fi diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..2dffbfb8a --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,61 @@ +# Gitleaks configuration for SAMO-DL project +# This file configures gitleaks to ignore false positives for ML tokenizer imports + +[allowlist] +# ML tokenizer imports - these are false positives for "generic-api-key" +# The word "tokenizer" in ML context refers to model components, not API keys +description = "ML tokenizer imports that are false positives for generic-api-key detection" + +[[allowlist.rules]] +description = "T5Tokenizer and T5ForConditionalGeneration imports" +regex = '''from transformers import T5Tokenizer, T5ForConditionalGeneration''' + +[[allowlist.rules]] +description = "AutoTokenizer and AutoModelForSequenceClassification imports" +regex = '''from transformers import AutoTokenizer, AutoModelForSequenceClassification''' + +[[allowlist.rules]] +description = "T5Tokenizer standalone import" +regex = '''from transformers import T5Tokenizer''' + +[[allowlist.rules]] +description = "AutoTokenizer standalone import" +regex = '''from transformers import AutoTokenizer''' + +[[allowlist.rules]] +description = "T5ForConditionalGeneration standalone import" +regex = '''from transformers import T5ForConditionalGeneration''' + +[[allowlist.rules]] +description = "AutoModelForSequenceClassification standalone import" +regex = '''from transformers import AutoModelForSequenceClassification''' + +# Additional ML-related patterns that might trigger false positives +[[allowlist.rules]] +description = "Model loading with tokenizer references" +regex = '''tokenizer.*=.*from_pretrained''' + +[[allowlist.rules]] +description = "Tokenizer initialization patterns" +regex = '''AutoTokenizer\.from_pretrained''' + +[[allowlist.rules]] +description = "T5 tokenizer initialization patterns" +regex = '''T5Tokenizer\.from_pretrained''' + +[[allowlist.rules]] +description = "Model loading patterns with tokenizer" +regex = '''\.from_pretrained.*tokenizer''' + +[[allowlist.rules]] +description = "Tokenizer variable assignments" +regex = '''tokenizer\s*=\s*.*from_pretrained''' + +# File-specific allowlist for known false positive files +[[allowlist.rules]] +description = "scripts/pre_download_models.py - ML model download script" +regex = '''scripts/pre_download_models\.py''' + +[[allowlist.rules]] +description = "src/startup_api.py - ML model loading in API" +regex = '''src/startup_api\.py''' diff --git a/.logs/code_quality_report.md b/.logs/code_quality_report.md index 6e5f342c6..85c457c97 100644 --- a/.logs/code_quality_report.md +++ b/.logs/code_quality_report.md @@ -4,7 +4,7 @@ Generated: 2025-07-22 20:21:52 UTC ## Pre-commit Hook Status ✅ Successfully implemented Ruff linting and formatting -✅ Security scanning with Bandit configured +✅ Security scanning with Bandit configured ✅ Secret detection active ✅ File quality checks working ✅ Automatic code formatting enabled diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e211822be..f9b49546b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -103,6 +103,7 @@ repos: language_version: python3 args: [--line-length=88, --target-version=py38] types: [python] + files: ^(src|tests|scripts)/ # Import sorting and organization - repo: https://github.com/pycqa/isort @@ -111,6 +112,7 @@ repos: - id: isort args: [--profile=black, --line-length=88, --py=38] types: [python] + files: ^(src|tests|scripts)/ # Python linting with Ruff (super fast) - repo: https://github.com/astral-sh/ruff-pre-commit @@ -119,6 +121,7 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] types: [python] + files: ^(src|tests|scripts)/ # Type checking with MyPy - repo: https://github.com/pre-commit/mirrors-mypy diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fd0cf7a..cd671efbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -307,4 +307,4 @@ All notable changes to this project will be documented in this file. --- -*This changelog follows the [Keep a Changelog](https://keepachangelog.com/) format and adheres to [Semantic Versioning](https://semver.org/).* \ No newline at end of file +*This changelog follows the [Keep a Changelog](https://keepachangelog.com/) format and adheres to [Semantic Versioning](https://semver.org/).* \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 035d359dc..dc68a21f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ Thank you for your interest in contributing to the SAMO-DL project! This guide w # Create virtual environment python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate - + # Install dependencies pip install -r requirements.txt ``` @@ -48,7 +48,7 @@ Thank you for your interest in contributing to the SAMO-DL project! This guide w ```bash # Run all tests pytest - + # Run with coverage pytest --cov=. ``` @@ -104,19 +104,19 @@ We follow **PEP 8** with some modifications: # ✅ Good def predict_emotion(text: str) -> Dict[str, Any]: """Predict emotion from text input. - + Args: text: Input text to analyze - + Returns: Dictionary containing emotion prediction and confidence - + Raises: ValueError: If text is empty or invalid """ if not text or not isinstance(text, str): raise ValueError("Text must be a non-empty string") - + # Implementation here return {"emotion": "happy", "confidence": 0.95} @@ -169,28 +169,28 @@ Use Google-style docstrings: ```python def process_text(text: str, max_length: int = 512) -> str: """Process and clean input text. - + Args: text: Raw input text max_length: Maximum allowed text length - + Returns: Processed and cleaned text - + Raises: ValueError: If text exceeds maximum length TypeError: If text is not a string - + Example: >>> process_text("Hello, world!", max_length=10) "Hello, wor" """ if not isinstance(text, str): raise TypeError("Text must be a string") - + if len(text) > max_length: text = text[:max_length] - + return text.strip() ``` @@ -232,26 +232,26 @@ from src.emotion_detector import EmotionDetector class TestEmotionDetector: """Test cases for EmotionDetector class.""" - + @pytest.fixture def detector(self): """Create EmotionDetector instance for testing.""" return EmotionDetector() - + def test_predict_happy_text(self, detector): """Test emotion prediction for happy text.""" text = "I'm feeling really happy today!" result = detector.predict(text) - + assert result["emotion"] == "happy" assert result["confidence"] > 0.8 assert "text" in result - + def test_predict_empty_text(self, detector): """Test emotion prediction with empty text.""" with pytest.raises(ValueError, match="Text cannot be empty"): detector.predict("") - + def test_predict_invalid_input(self, detector): """Test emotion prediction with invalid input.""" with pytest.raises(TypeError, match="Text must be a string"): @@ -420,7 +420,7 @@ Brief description of changes # ✅ Good - Use environment variables import os api_key = os.getenv('API_KEY') - + # ❌ Bad - Hardcoded secrets api_key = "your-api-key-here" # Never commit real API keys ``` @@ -429,7 +429,7 @@ Brief description of changes ```python # ✅ Good - Use parameterized queries cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) - + # ❌ Bad - String concatenation cursor.execute(f"SELECT * FROM users WHERE id = {user_id}") ``` @@ -527,4 +527,4 @@ By contributing to SAMO-DL, you agree that your contributions will be licensed u **Thank you for contributing to SAMO-DL!** 🚀 -Your contributions help make this project better for everyone in the community. \ No newline at end of file +Your contributions help make this project better for everyone in the community. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..76146f23b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Optimized CPU-only Dockerfile for Cloud Run deployment +# Minimal dependencies, no GPU packages, smaller image size +FROM python:3.10-slim-bookworm + +# Set environment variables for Python +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + HF_HOME=/app/models \ + TRANSFORMERS_CACHE=/app/models + +# Set working directory +WORKDIR /app + +# Install minimal system dependencies including audio processing +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates=20230311 \ + curl=7.88.1-10+deb12u6 \ + ffmpeg=7:5.1.2-7+deb12u1 \ + libsndfile1=1.2.0-3 \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Copy optimized requirements first for better caching +COPY deployment/docker/requirements-api-optimized.txt ./requirements.txt + +# Install Python dependencies with CPU-only PyTorch +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the unified SAMO API with voice processing and all dependencies +COPY src/ ./src/ +COPY scripts/ ./scripts/ + +# Pre-download the SAMO models and Whisper during build to avoid OOM during startup +RUN mkdir -p /app/models && \ + python scripts/pre_download_models.py + +# Create non-root user for security (Cloud Run best practice) +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Expose port (Cloud Run requirement) +EXPOSE 8080 + +# Health check following Cloud Run best practices +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Use exec form for CMD (Docker best practice) +# Run the unified SAMO API with FastAPI/Uvicorn +CMD ["sh", "-c", "exec python -m uvicorn src.unified_ai_api:app --host 0.0.0.0 --port $PORT --workers 1"] \ No newline at end of file diff --git a/Dockerfile.optimized b/Dockerfile.optimized new file mode 100644 index 000000000..e2fe2d6a3 --- /dev/null +++ b/Dockerfile.optimized @@ -0,0 +1,65 @@ +# Optimized Dockerfile for Cloud Run with pre-downloaded models +FROM python:3.11-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl=7.88.1-10+deb12u6 \ + git=1:2.39.2-1.1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Set environment variables for model caching +ENV HF_HOME=/app/models +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 + +# Copy requirements and install dependencies +COPY dependencies/requirements-api.txt . +RUN pip install --no-cache-dir -r requirements-api.txt + +# Create models directory +RUN mkdir -p /app/models + +# Copy the pre-download script +COPY scripts/pre_download_models.py . + +# Pre-download models during build (this will take time but ensures fast startup) +RUN python pre_download_models.py + +# Copy model validation script (will be run at startup, not during build) +COPY scripts/validate_models.py . +RUN chmod +x validate_models.py + +# Copy source code +COPY src/ ./src/ +COPY *.py ./ + +# Create non-root user for security +RUN groupadd -r samo && useradd -r -g samo -d /app -s /bin/bash samo + +# Set proper ownership and permissions +RUN chown -R samo:samo /app && \ + chmod -R 755 /app && \ + chmod +x /app/validate_models.py + +# Switch to non-root user +USER samo + +# Set home directory for the user +ENV HOME=/app + +# Expose port (using higher port for non-root user) +EXPOSE 8080 + +# Set production environment variables for secure containerized deployment +ENV PRODUCTION=true +ENV DOCKER_CONTAINER=true +ENV BIND_ALL_INTERFACES=true + +# Add healthcheck +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Run the optimized API +CMD ["python", "src/startup_api.py"] \ No newline at end of file diff --git a/Home.md b/Home.md index 544b5867d..3a83982b8 100644 --- a/Home.md +++ b/Home.md @@ -135,17 +135,17 @@ curl -X POST https://api.samo-brain.com/predict \ ## 🚀 **Production Status** -**SAMO Brain is production-ready!** +**SAMO Brain is production-ready!** -✅ **Core Features**: Complete and tested -✅ **Documentation**: Comprehensive guides available -✅ **Security**: Enterprise-grade security framework -✅ **Performance**: Optimized for production workloads -✅ **Monitoring**: Complete observability stack -✅ **Deployment**: Multi-cloud deployment support +✅ **Core Features**: Complete and tested +✅ **Documentation**: Comprehensive guides available +✅ **Security**: Enterprise-grade security framework +✅ **Performance**: Optimized for production workloads +✅ **Monitoring**: Complete observability stack +✅ **Deployment**: Multi-cloud deployment support **Ready to integrate SAMO Brain into your application?** Start with the [Backend Integration Guide](Backend-Integration-Guide) or [Data Science Integration Guide](Data-Science-Integration-Guide)! --- -*Last updated: August 2024 | Version: 1.0.0 | Status: Production Ready* 🚀 \ No newline at end of file +*Last updated: August 2024 | Version: 1.0.0 | Status: Production Ready* 🚀 \ No newline at end of file diff --git a/PYTHON38_COMPATIBILITY_PLAN.md b/PYTHON38_COMPATIBILITY_PLAN.md index 78e1d0a0e..f793b37fb 100644 --- a/PYTHON38_COMPATIBILITY_PLAN.md +++ b/PYTHON38_COMPATIBILITY_PLAN.md @@ -13,7 +13,7 @@ This branch focuses **exclusively** on fixing Python 3.8 compatibility issues th ### **2. Files with Issues:** - `src/api_rate_limiter.py` - ✅ **FIXED** -- `src/security/jwt_manager.py` - ✅ **FIXED** +- `src/security/jwt_manager.py` - ✅ **FIXED** - `src/unified_ai_api.py` - ✅ **FIXED** - `requirements-dev.txt` - ✅ **FIXED** (Flask dependency for legacy tests) diff --git a/QUICK_START.md b/QUICK_START.md index 9bcfa2a58..8937ba17e 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -34,7 +34,7 @@ import requests url = "https://samo-emotion-api-minimal-71517823771.us-central1.run.app" # Test your model! -response = requests.post(f"{url}/predict", +response = requests.post(f"{url}/predict", json={"text": "I am feeling excited about this project!"}) result = response.json() print(f"Primary emotion: {result['primary_emotion']['emotion']}") @@ -217,4 +217,4 @@ Your model is already deployed and operational at: --- -**Ready to build the next big thing with your emotion detection model!** 🚀 \ No newline at end of file +**Ready to build the next big thing with your emotion detection model!** 🚀 \ No newline at end of file diff --git a/README.md b/README.md index 088d5641a..2d9b18a0e 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ ## 🎯 Project Context & Scope -**Role**: Sole Deep Learning Engineer (originally 2-person team, now independent ownership) -**Responsibility**: End-to-end ML pipeline from research to production deployment +**Role**: Sole Deep Learning Engineer (originally 2-person team, now independent ownership) +**Responsibility**: End-to-end ML pipeline from research to production deployment ### Architecture Overview @@ -57,7 +57,7 @@ Voice Input → Whisper STT → DistilRoBERTa Emotion → T5 Summarization → E - **Optimization**: ONNX Runtime deployment with dynamic quantization - **Performance**: 90.70% F1 score, 100-600ms inference time -**2. Text Summarization Engine** +**2. Text Summarization Engine** - **Architecture**: T5-based transformer (60.5M parameters) - **Purpose**: Extract emotional core from journal conversations - **Integration**: Seamless pipeline with emotion detection API @@ -71,7 +71,7 @@ Voice Input → Whisper STT → DistilRoBERTa Emotion → T5 Summarization → E **MLOps Infrastructure** - **Deployment**: Dockerized microservices on Google Cloud Run -- **Monitoring**: Prometheus metrics + custom model drift detection +- **Monitoring**: Prometheus metrics + custom model drift detection - **Security**: Rate limiting, input validation, comprehensive error handling - **Testing**: Complete test suite (Unit, Integration, E2E, Performance) @@ -83,10 +83,10 @@ Voice Input → Whisper STT → DistilRoBERTa Emotion → T5 Summarization → E ## 🔧 Technical Stack -**ML Frameworks**: PyTorch, Transformers (Hugging Face), ONNX Runtime -**Model Architecture**: DistilRoBERTa, T5, Transformer-based NLP -**Production**: Docker, Kubernetes, Google Cloud Platform, Flask APIs -**MLOps**: Model monitoring, automated retraining, drift detection, CI/CD +**ML Frameworks**: PyTorch, Transformers (Hugging Face), ONNX Runtime +**Model Architecture**: DistilRoBERTa, T5, Transformer-based NLP +**Production**: Docker, Kubernetes, Google Cloud Platform, Flask APIs +**MLOps**: Model monitoring, automated retraining, drift detection, CI/CD ## 📊 Live Production System @@ -109,7 +109,7 @@ curl -X POST https://samo-emotion-api-[...].run.app/predict \ ### System Health - **Uptime**: >99.5% production availability -- **Latency**: 95th percentile under 500ms +- **Latency**: 95th percentile under 500ms - **Throughput**: 1000+ requests/minute capacity - **Error Rate**: <0.1% system errors @@ -124,7 +124,7 @@ SAMO--DL/ │ └── local/ # Development environment ├── scripts/ │ ├── testing/ # Comprehensive test suite -│ ├── deployment/ # Deployment automation +│ ├── deployment/ # Deployment automation │ └── optimization/ # Model optimization tools ├── docs/ │ ├── api/ # API documentation @@ -132,7 +132,7 @@ SAMO--DL/ │ └── architecture/ # System design documentation └── models/ ├── emotion_detection/ # Fine-tuned emotion models - ├── summarization/ # T5 summarization models + ├── summarization/ # T5 summarization models └── optimization/ # ONNX optimized models ``` @@ -203,10 +203,10 @@ def predict_emotion(text): **Model Performance** - Emotion detection accuracy: **90.70% F1 score** -- Voice transcription: **<10% Word Error Rate** +- Voice transcription: **<10% Word Error Rate** - Summarization quality: **>4.0/5.0 human evaluation** -**System Performance** +**System Performance** - Average response time: **287ms** - 95th percentile latency: **<500ms** - Production uptime: **>99.5%** diff --git a/artifacts/test-reports/mega_comprehensive_test_report_20250805_141116.json b/artifacts/test-reports/mega_comprehensive_test_report_20250805_141116.json index 93e83497e..99787e62e 100644 --- a/artifacts/test-reports/mega_comprehensive_test_report_20250805_141116.json +++ b/artifacts/test-reports/mega_comprehensive_test_report_20250805_141116.json @@ -1135,4 +1135,6 @@ "summary": { "model_status": "EXCELLENT", "confidence_status": "HIGH", - "deployment_ready": \ No newline at end of file + "deployment_ready": true + } +} \ No newline at end of file diff --git a/cloudbuild-optimized.yaml b/cloudbuild-optimized.yaml new file mode 100644 index 000000000..daa923f09 --- /dev/null +++ b/cloudbuild-optimized.yaml @@ -0,0 +1,59 @@ +# Cloud Build configuration for optimized SAMO Unified API +steps: + # Build the optimized Docker image with pre-downloaded models + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-f' + - 'Dockerfile.optimized' + - '--platform' + - 'linux/amd64' + - '-t' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-unified-api-optimized:${COMMIT_SHA}' + - '-t' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-unified-api-optimized:latest' + - '.' + timeout: '1200s' # 20 minutes for model downloads + + # Push the image to Artifact Registry + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-unified-api-optimized:${COMMIT_SHA}' + + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-unified-api-optimized:latest' + + # Deploy to Cloud Run with bulletproof optimized settings + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + entrypoint: 'gcloud' + args: + - 'run' + - 'deploy' + - 'samo-unified-api-optimized' + - '--image=us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-unified-api-optimized:${COMMIT_SHA}' + - '--platform=managed' + - '--region=us-central1' + - '--allow-unauthenticated' + - '--port=8080' + - '--timeout=600' # Request timeout (10 minutes) - reasonable for API + - '--cpu=2' + - '--memory=6Gi' # Increased memory for safe model loading + - '--max-instances=10' + - '--min-instances=0' + - '--concurrency=80' + - '--cpu-boost' # Faster cold starts + - '--set-env-vars=PYTHONUNBUFFERED=1,PRODUCTION=true,CLOUD_RUN_SERVICE=true,BIND_ALL_INTERFACES=true' # Production environment + +# Build options +options: + machineType: 'E2_HIGHCPU_8' # Use high-CPU machine for faster builds + diskSizeGb: 100 # Larger disk for model downloads + logging: CLOUD_LOGGING_ONLY + +# Substitution variables are provided by Cloud Build automatically + +# Build timeout +timeout: '1800s' # 30 minutes total diff --git a/cloudbuild-staging.yaml b/cloudbuild-staging.yaml new file mode 100644 index 000000000..4bf275682 --- /dev/null +++ b/cloudbuild-staging.yaml @@ -0,0 +1,79 @@ +# Cloud Build configuration for SAMO-DL Staging Deployment +steps: + # Build the optimized Docker image for staging + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-f' + - 'Dockerfile.optimized' + - '--platform' + - 'linux/amd64' + - '-t' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-dl-api-staging:${BUILD_ID}' + - '-t' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-dl-api-staging:latest' + - '.' + timeout: '1200s' # 20 minutes for model downloads + + # Push the image to Artifact Registry + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-dl-api-staging:${BUILD_ID}' + + - name: 'gcr.io/cloud-builders/docker' + args: + - 'push' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-dl-api-staging:latest' + + # Deploy to Cloud Run with staging-optimized settings + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + entrypoint: 'gcloud' + args: + - 'run' + - 'deploy' + - 'samo-dl-api-staging' + - '--image=us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-dl-api-staging:${BUILD_ID}' + - '--platform=managed' + - '--region=us-central1' + - '--allow-unauthenticated' + - '--port=8080' + - '--timeout=300' # 5 minutes timeout for staging + - '--cpu=2' + - '--memory=2Gi' # Staging-appropriate memory + - '--max-instances=5' # Lower max instances for staging + - '--min-instances=0' + - '--concurrency=40' + - '--cpu-boost' # Faster cold starts + - '--set-env-vars=ENVIRONMENT=staging,DEBUG=true,LOG_LEVEL=debug,PYTHONUNBUFFERED=1' + + # Run integration tests against the deployed staging service + - name: 'gcr.io/cloud-builders/gcloud' + entrypoint: 'bash' + args: + - '-c' + - | + # Get the service URL + SERVICE_URL=$$(gcloud run services describe samo-dl-api-staging --region=us-central1 --format='value(status.url)') + echo "Testing service at: $$SERVICE_URL" + + # Wait for service to be ready + sleep 30 + + # Run integration tests + export API_BASE_URL=$$SERVICE_URL + python scripts/testing/integration_test_suite.py + +# Build options +options: + machineType: 'E2_HIGHCPU_8' # Use high-CPU machine for faster builds + diskSizeGb: 100 # Larger disk for model downloads + logging: CLOUD_LOGGING_ONLY + +# Build timeout +timeout: '1800s' # 30 minutes total + +# Available images +images: + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-dl-api-staging:${BUILD_ID}' + - 'us-central1-docker.pkg.dev/${PROJECT_ID}/samo-dl/samo-dl-api-staging:latest' diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 8d73ecad6..e222f8df3 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -26,7 +26,7 @@ steps: 'us-central1-docker.pkg.dev/$PROJECT_ID/${_ARTIFACT_REPO}/emotion-detection-api:$BUILD_ID', '--region=us-central1' ] - + # Deploy to Cloud Run with parameterized configuration # IMPORTANT: secretEnv is only applied to this specific step. Other build steps # that need access to secrets would require their own secretEnv declarations. @@ -62,16 +62,16 @@ substitutions: _SERVICE_NAME: 'emotion-detection-api' _REGION: 'us-central1' _PORT: '8080' - + # Resource allocation _MEMORY: '2Gi' _CPU: '2' _MAX_INSTANCES: '10' - + # Build configuration _MACHINE_TYPE: 'E2_HIGHCPU_8' _DISK_SIZE: 100 - + # Artifact Registry configuration _ARTIFACT_REPO: 'samo-dl-repo' diff --git a/configs/samo_whisper_config.yaml b/configs/samo_whisper_config.yaml index 293a08562..19a4d20dd 100644 --- a/configs/samo_whisper_config.yaml +++ b/configs/samo_whisper_config.yaml @@ -13,25 +13,25 @@ whisper: transcription: task: "transcribe" # transcribe or translate temperature: 0.0 # Sampling temperature (0.0 = deterministic) - + # Beam search parameters beam_size: null # Beam search size (null = auto) best_of: null # Number of candidates to consider patience: null # Patience for beam search - + # Length and repetition control length_penalty: null # Length penalty (null = auto) suppress_tokens: "-1" # Tokens to suppress (comma-separated) - + # Context and prompts initial_prompt: null # Initial context prompt condition_on_previous_text: true # Use previous text as context - + # Quality thresholds - Optimized for journal entries compression_ratio_threshold: 2.4 # Higher = more compressed logprob_threshold: -1.0 # Lower = more confident no_speech_threshold: 0.6 # Higher = more speech required - + # Performance settings fp16: true # Use half precision for speed @@ -46,7 +46,7 @@ audio: - ".aac" - ".ogg" - ".flac" - + # Quality assessment thresholds quality_thresholds: excellent: 5 # Quality score >= 5 @@ -61,7 +61,7 @@ samo_optimizations: - "This is a personal journal entry about my thoughts and feelings." - "I'm recording my daily experiences and reflections." - "This is a voice note about my day and emotions." - + # Emotional context awareness emotional_keywords: - "feeling" @@ -70,7 +70,7 @@ samo_optimizations: - "thoughts" - "experience" - "reflection" - + # Quality expectations for journal entries expected_quality: "good" # good, fair, excellent min_confidence: 0.7 # Minimum confidence threshold diff --git a/configs/security.yaml b/configs/security.yaml index 951971f61..21f0fe236 100644 --- a/configs/security.yaml +++ b/configs/security.yaml @@ -15,7 +15,7 @@ api: db: 0 password: null # Set via environment variable in production ssl: false # Enable in production - + # CORS configuration cors: enabled: true @@ -33,14 +33,14 @@ api: - "Authorization" - "X-API-Key" max_age: 3600 - + # Authentication settings authentication: enabled: true api_key_required: true jwt_enabled: false # Future enhancement session_timeout: 3600 # 1 hour - + # Input validation input_validation: max_text_length: 1000 @@ -71,7 +71,7 @@ logging: level: "INFO" format: "json" include_pii: false - + # Request logging requests: enabled: true @@ -81,7 +81,7 @@ logging: - "api_key" - "token" - "secret" - + # Error logging errors: enabled: true @@ -100,7 +100,7 @@ environment: - "SECRET_KEY" - "API_KEY" - "ENVIRONMENT" - + # Sensitive variables (will be masked in logs) sensitive_vars: - "DATABASE_URL" @@ -108,18 +108,18 @@ environment: - "API_KEY" - "OPENAI_API_KEY" - "GOOGLE_CLOUD_CREDENTIALS" - + # Environment-specific settings production: debug: false log_level: "WARNING" enable_health_checks: true - + development: debug: true log_level: "DEBUG" enable_health_checks: true - + testing: debug: false log_level: "INFO" @@ -137,13 +137,13 @@ dependencies: auto_fix: false fail_on_critical: true fail_on_high: true # Fail on high-severity vulnerabilities for security - + # Update policy updates: auto_update: false security_updates_only: true test_after_update: true - + # Model Security model: # Model loading security @@ -151,14 +151,14 @@ model: validate_model_files: true check_model_signatures: true max_model_size_mb: 1000 - + # Inference security inference: max_input_length: 1000 max_batch_size: 50 timeout_seconds: 30 memory_limit_mb: 2048 - + # Model access control access_control: require_authentication: true @@ -173,13 +173,13 @@ database: verify_ssl: true connection_timeout: 30 max_connections: 20 - + # Query security queries: max_query_time: 30 # seconds log_slow_queries: true prevent_sql_injection: true - + # Data protection data_protection: encrypt_sensitive_data: true @@ -197,16 +197,16 @@ deployment: run_as_user: 1000 run_as_group: 1000 fs_group: 1000 - + # Network security network: use_https: true enable_tls_1_3: true disable_tls_1_0_1_1: true certificate_validation: true - + # Secrets management secrets: use_external_secrets: true rotate_secrets: true - secret_rotation_days: 90 \ No newline at end of file + secret_rotation_days: 90 \ No newline at end of file diff --git a/dependencies/requirements-api.txt b/dependencies/requirements-api.txt index f72d2f149..b161f0fe5 100644 --- a/dependencies/requirements-api.txt +++ b/dependencies/requirements-api.txt @@ -42,3 +42,7 @@ transformers==4.55.0 # Torch runtime (CPU by default; align with repo constraints) torch==2.8.0 +# Additional model dependencies +sentencepiece>=0.1.99 +openai-whisper>=20231117 + diff --git a/deployment/CUSTOM_MODEL_DEPLOYMENT_GUIDE.md b/deployment/CUSTOM_MODEL_DEPLOYMENT_GUIDE.md index 3e1d80601..4e9816604 100644 --- a/deployment/CUSTOM_MODEL_DEPLOYMENT_GUIDE.md +++ b/deployment/CUSTOM_MODEL_DEPLOYMENT_GUIDE.md @@ -20,7 +20,7 @@ You can customize where the script looks for models by setting an environment va ```bash # Option 1: Set base directory (script will add /deployment/models) -export SAMO_DL_BASE_DIR="/path/to/your/project" +export SAMO_DL_BASE_DIR="/path/to/your/project" # Option 2: Alternative environment variable name export MODEL_BASE_DIR="/path/to/your/project" @@ -36,7 +36,7 @@ export MODEL_BASE_DIR="/path/to/your/project" 2. Place them in your model directory: - **AUTO-DETECTED**: Script will find your `PROJECT_ROOT/deployment/models/` automatically - - **CUSTOM**: Set `SAMO_DL_BASE_DIR` environment variable to override location + - **CUSTOM**: Set `SAMO_DL_BASE_DIR` environment variable to override location - **FALLBACK**: `~/Downloads/`, `~/Desktop/`, `~/Documents/`, or project root directory ### Model files we're looking for: @@ -103,7 +103,7 @@ def predict_with_hf_api(text: str) -> dict: """Use HuggingFace Serverless Inference API""" API_URL = "https://api-inference.huggingface.co/models/your-username/samo-dl-emotion-model" headers = {"Authorization": f"Bearer {os.getenv('HF_TOKEN')}"} - + response = requests.post(API_URL, headers=headers, json={"inputs": text}) return response.json() ``` @@ -142,7 +142,7 @@ def predict_with_inference_endpoint(text: str) -> dict: """ ENDPOINT_URL = "https://..aws.endpoints.huggingface.cloud" headers = {"Authorization": f"Bearer {os.getenv('HF_TOKEN')}"} - + response = requests.post(ENDPOINT_URL, headers=headers, json={"inputs": text}) return response.json() ``` @@ -171,12 +171,12 @@ def predict_local(text: str) -> dict: outputs = model(**inputs) probabilities = torch.nn.functional.softmax(outputs.logits, dim=-1) predicted_class = torch.argmax(probabilities, dim=-1) - + return { "emotion": model.config.id2label[predicted_class.item()], "confidence": probabilities[0][predicted_class].item(), "all_emotions": { - model.config.id2label[i]: prob.item() + model.config.id2label[i]: prob.item() for i, prob in enumerate(probabilities[0]) } } @@ -194,7 +194,7 @@ DEPLOYMENT_TYPE=serverless ### For Inference Endpoints: ```bash -# Environment variables +# Environment variables HF_TOKEN=your_hf_token_here INFERENCE_ENDPOINT_URL=https://your-endpoint.aws.endpoints.huggingface.cloud DEPLOYMENT_TYPE=endpoint @@ -388,7 +388,7 @@ Your API → HF Hub → your-username/samo-dl-emotion-model → Accurate results - **Domain**: General text - **Cost**: Free but poor results -### Custom Model (After) +### Custom Model (After) - **Accuracy**: ~85% (your specific emotions) - **F1 Score**: ~0.75 - **Domain**: Journal/personal text @@ -403,7 +403,7 @@ Your API → HF Hub → your-username/samo-dl-emotion-model → Accurate results ### Inference Endpoints (Production) - **CPU instance**: ~$0.06-0.24/hour -- **GPU instance**: ~$0.60-1.20/hour +- **GPU instance**: ~$0.60-1.20/hour - **Storage**: Same as above - **No per-request charges** @@ -421,7 +421,7 @@ Your custom model will provide much better accuracy for your specific use case! If you encounter issues: 1. Check HuggingFace Hub status and quotas -2. Verify your model files exist and are accessible +2. Verify your model files exist and are accessible 3. Ensure HuggingFace authentication is working 4. Test with Serverless API before moving to Inference Endpoints 5. Monitor your usage at https://huggingface.co/settings/billing diff --git a/deployment/DOCKERFILE_SECURITY_GUIDE.md b/deployment/DOCKERFILE_SECURITY_GUIDE.md index 23657721f..1da0d1271 100644 --- a/deployment/DOCKERFILE_SECURITY_GUIDE.md +++ b/deployment/DOCKERFILE_SECURITY_GUIDE.md @@ -17,7 +17,7 @@ This document explains the security considerations and design decisions for diff - ✅ Health checks - ✅ Environment variable configuration -**CMD**: +**CMD**: ```dockerfile CMD ["sh", "-c", "gunicorn --bind ${HOST}:${PORT} --workers 2 --worker-class uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - src.unified_ai_api:app"] ``` diff --git a/deployment/HUGGINGFACE_DEPLOYMENT_CHECKLIST.md b/deployment/HUGGINGFACE_DEPLOYMENT_CHECKLIST.md index 3e688f4a9..daccce2d0 100644 --- a/deployment/HUGGINGFACE_DEPLOYMENT_CHECKLIST.md +++ b/deployment/HUGGINGFACE_DEPLOYMENT_CHECKLIST.md @@ -6,9 +6,9 @@ Based on practical deployment recommendations for DistilBERT emotion models. ### 📁 Required Files - [ ] **Model file**: `model.safetensors` (preferred) or `pytorch_model.bin` -- [ ] **Config**: `config.json` with proper `id2label`/`label2id` mappings +- [ ] **Config**: `config.json` with proper `id2label`/`label2id` mappings - [ ] **Tokenizer files**: - - [ ] `tokenizer.json` + - [ ] `tokenizer.json` - [ ] `tokenizer_config.json` - [ ] Vocabulary files (if needed) - [ ] **README.md** with proper metadata @@ -25,7 +25,7 @@ labels: ["anxious", "calm", "content", "excited", "frustrated", "grateful", "hap # Track large files (>100MB) git lfs track "*.bin" git lfs track "*.safetensors" -git lfs track "*.onnx" +git lfs track "*.onnx" git lfs track "*.pkl" git lfs track "*.pth" ``` @@ -35,7 +35,7 @@ git lfs track "*.pth" ### 📊 Public Repository (Recommended Start) **Choose if:** - [ ] Content is general emotion analysis -- [ ] No sensitive/health data involved +- [ ] No sensitive/health data involved - [ ] Want completely free hosting - [ ] Easy integration and sharing @@ -45,7 +45,7 @@ git lfs track "*.pth" - ✅ Better community discovery ### 🔒 Private Repository -**Choose if:** +**Choose if:** - [ ] Journal content includes mental health data - [ ] Therapy/counseling applications - [ ] PII (personally identifiable information) @@ -77,12 +77,12 @@ git lfs track "*.pth" **Benefits:** - ✅ No cold starts -- ✅ Consistent latency +- ✅ Consistent latency - ✅ VPC options for security - ✅ Custom containers if needed **Costs:** -- 💰 CPU: ~$0.06-0.24/hour +- 💰 CPU: ~$0.06-0.24/hour - 💰 GPU: ~$0.60-1.20/hour ### 🏠 Enterprise: Self-Hosted @@ -91,7 +91,7 @@ git lfs track "*.pth" **Choose when:** - [ ] Strict data residency requirements - [ ] Custom inference optimizations needed -- [ ] High volume makes endpoints expensive +- [ ] High volume makes endpoints expensive - [ ] Complete control over infrastructure ## Common Pitfalls Checklist @@ -102,7 +102,7 @@ git lfs track "*.pth" - [ ] **Large weights without LFS** → Push failures - [ ] **Wrong label mappings** → Client-side mapping breaks -### ❌ Runtime Issues +### ❌ Runtime Issues - [ ] **Token not set** → Authentication failures - [ ] **Wrong endpoint URL** → 404 errors - [ ] **Expecting wrong output format** → Parsing failures @@ -121,7 +121,7 @@ headers = {"Authorization": f"Bearer {os.getenv('HF_TOKEN')}"} # Test cases test_cases = [ "I felt calm after writing it all down.", - "I am frustrated but hopeful.", + "I am frustrated but hopeful.", "Today was overwhelming but I'm proud of getting through it.", "" # Edge case: empty input ] @@ -143,7 +143,7 @@ for text in test_cases: "score": 0.8234 }, { - "label": "hopeful", + "label": "hopeful", "score": 0.1123 } ] @@ -177,7 +177,7 @@ export HF_TOKEN='hf_your_token_here' ### 📈 Key Metrics to Track - [ ] **Response time** (p50, p95, p99) -- [ ] **Error rate** (4xx, 5xx responses) +- [ ] **Error rate** (4xx, 5xx responses) - [ ] **Cold start frequency** (Serverless only) - [ ] **Token usage** (if rate-limited) - [ ] **Prediction accuracy** (spot-check results) @@ -218,7 +218,7 @@ def health_check(): ### 🚀 Pre-Launch (Final Steps) - [ ] Model uploaded and validated -- [ ] Test with actual journal entries +- [ ] Test with actual journal entries - [ ] Error handling implemented - [ ] Monitoring set up - [ ] Security tokens configured @@ -255,7 +255,7 @@ def health_check(): Before going live, ensure: - [ ] ✅ All files validated and uploaded -- [ ] ✅ Privacy settings match data sensitivity +- [ ] ✅ Privacy settings match data sensitivity - [ ] ✅ Test API calls return expected format - [ ] ✅ Error handling works properly - [ ] ✅ Monitoring is active diff --git a/deployment/api_server.py b/deployment/api_server.py index d1f4f4b4c..a96f1eb33 100644 --- a/deployment/api_server.py +++ b/deployment/api_server.py @@ -1,17 +1,20 @@ #!/usr/bin/env python3 -""" -🚀 EMOTION DETECTION API SERVER +"""🚀 EMOTION DETECTION API SERVER =============================== REST API server for emotion detection with comprehensive security headers. """ # Import all modules first import logging +import os from flask import Flask, request, jsonify from inference import EmotionDetector # Import security setup using relative import -from ..src.security_setup import setup_security_middleware +try: + from ..src.security_setup import setup_security_middleware +except Exception: # fallback when executed as script + from src.security_setup import setup_security_middleware # Configure logging after all imports logging.basicConfig(level=logging.INFO) @@ -20,7 +23,7 @@ app = Flask(__name__) # Initialize security headers middleware -security_middleware = setup_security_middleware(app, "development") +security_middleware = setup_security_middleware(app, os.environ.get("FLASK_ENV", "development")) # Initialize emotion detector try: @@ -30,76 +33,121 @@ logger.error(f"❌ Failed to initialize emotion detector: {e}") detector = None -@app.route('/health', methods=['GET']) + +@app.route("/health", methods=["GET"]) def health_check(): """Health check endpoint""" - return jsonify({ - 'status': 'healthy', - 'model_loaded': detector is not None, - 'emotions': list(detector.label_encoder.classes_) if detector else [] - }) + # Safe access to detector's emotion mapping + emotions = [] + if detector: + # Try to get mapping from detector safely + mapping = getattr(detector, "mapping", {}) + if isinstance(mapping, dict): + emotions = list(mapping.values()) + elif hasattr(mapping, "__iter__") and not isinstance(mapping, str): + emotions = list(mapping) + else: + # Fallback to empty list if mapping is not accessible + emotions = [] -@app.route('/predict', methods=['POST']) + return jsonify( + { + "status": "healthy", + "model_loaded": detector is not None, + "emotions": emotions, + } + ) + + +@app.route("/predict", methods=["POST"]) def predict_emotion(): """Predict emotion for given text""" if detector is None: - return jsonify({'error': 'Model not loaded'}), 500 - + return jsonify({"error": "Model not loaded"}), 500 + try: - data = request.get_json() - text = data.get('text', '') - + data = request.get_json(silent=True) or {} + text = data.get("text", "") + if not text: - return jsonify({'error': 'No text provided'}), 400 - + return jsonify({"error": "No text provided"}), 400 + result = detector.predict(text) return jsonify(result) - + except Exception as e: - logger.error(f"Prediction error: {e}") - return jsonify({'error': str(e)}), 500 + logger.error(f"Prediction error: {e}", exc_info=True) + return jsonify({"error": "An internal error occurred during prediction."}), 500 -@app.route('/predict_batch', methods=['POST']) + +@app.route("/predict_batch", methods=["POST"]) def predict_batch(): """Predict emotions for multiple texts""" if detector is None: - return jsonify({'error': 'Model not loaded'}), 500 - + return jsonify({"error": "Model not loaded"}), 500 + try: - data = request.get_json() - texts = data.get('texts', []) - + data = request.get_json(silent=True) or {} + texts = data.get("texts", []) + if not texts: - return jsonify({'error': 'No texts provided'}), 400 - + return jsonify({"error": "No texts provided"}), 400 + results = detector.predict_batch(texts) - return jsonify({'results': results}) - + return jsonify({"results": results}) + except Exception as e: - logger.error(f"Batch prediction error: {e}") - return jsonify({'error': str(e)}), 500 + logger.error(f"Batch prediction error: {e}", exc_info=True) + return ( + jsonify({"error": "An internal error occurred during batch prediction."}), + 500, + ) + -@app.route('/emotions', methods=['GET']) +@app.route("/emotions", methods=["GET"]) def get_emotions(): """Get list of supported emotions""" if detector is None: - return jsonify({'error': 'Model not loaded'}), 500 - - return jsonify({ - 'emotions': list(detector.label_encoder.classes_), - 'count': len(detector.label_encoder.classes_) - }) - -if __name__ == '__main__': + return jsonify({"error": "Model not loaded"}), 500 + + return jsonify( + { + "emotions": list(detector.label_encoder.classes_), + "count": len(detector.label_encoder.classes_), + } + ) + + +if __name__ == "__main__": print("🚀 Starting Emotion Detection API Server") print("=" * 50) print("📊 Model Performance: 99.48% F1 Score") - print("🎯 Supported Emotions:", list(detector.label_encoder.classes_) if detector else "None") + print( + "🎯 Supported Emotions:", + list(detector.label_encoder.classes_) if detector else "None", + ) print("🌐 API Endpoints:") print(" - GET /health - Health check") print(" - POST /predict - Single text prediction") print(" - POST /predict_batch - Batch prediction") print(" - GET /emotions - List emotions") print("=" * 50) - - app.run(host='0.0.0.0', port=5000, debug=False) + + # Environment-based host configuration for security + host = os.environ.get("FLASK_HOST", "127.0.0.1") + port = int(os.environ.get("FLASK_PORT", "5000")) + + # Use centralized security-first host binding configuration + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + get_binding_security_summary, + ) + + host, port = get_secure_host_binding(default_port=port) + validate_host_binding(host, port) + + security_summary = get_binding_security_summary(host, port) + logger.info("Security Summary: %s", security_summary) + + app.run(host=host, port=port, debug=False) diff --git a/deployment/cloud-run/README-consolidated-dockerfile.md b/deployment/cloud-run/README-consolidated-dockerfile.md index bab04eefd..5e56f2eae 100644 --- a/deployment/cloud-run/README-consolidated-dockerfile.md +++ b/deployment/cloud-run/README-consolidated-dockerfile.md @@ -121,7 +121,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \ The consolidated Dockerfile supports multiple sources for loading the emotion detection model: ```bash -# Hugging Face Hub model (default: "0xmnrv/samo") +# Hugging Face Hub model (default: "duelker/samo-goemotions-deberta-v3-large") EMOTION_MODEL_ID=your-model-id # Hugging Face authentication token (if model is private) @@ -148,7 +148,7 @@ EMOTION_MODEL_ENDPOINT_URL=https://your-endpoint.com/predict ### **Example Environment Configuration:** ```bash # For production with HF Hub model -export EMOTION_MODEL_ID="0xmnrv/samo" +export EMOTION_MODEL_ID="duelker/samo-goemotions-deberta-v3-large" export HF_TOKEN="hf_your_token_here" # For local development diff --git a/deployment/cloud-run/cloudbuild.yaml b/deployment/cloud-run/cloudbuild.yaml index 259e3b517..6c420b490 100644 --- a/deployment/cloud-run/cloudbuild.yaml +++ b/deployment/cloud-run/cloudbuild.yaml @@ -1,5 +1,29 @@ +# Cloud Build configuration with optimized timeout and machine type for model downloading +timeout: 1800s # 30 minutes - enough time for large model downloads + +options: + # Use high-performance machine for faster builds and model downloads + machineType: 'E2_HIGHCPU_32' + # Larger disk for model storage during build + diskSizeGb: 100 + logging: CLOUD_LOGGING_ONLY + +substitutions: + _IMAGE_NAME: 'samo-emotion-api-secure' + _DOCKERFILE: 'deployment/docker/Dockerfile.optimized' + _REGION: 'us-central1' + _PROJECT_ID: 'the-tendril-466607-n8' + steps: - name: 'gcr.io/cloud-builders/docker' - args: ['build', '-t', 'us-central1-docker.pkg.dev/the-tendril-466607-n8/samo-dl/samo-emotion-api-secure', '-f', 'deployment/cloud-run/Dockerfile.secure', '.'] + args: + - 'build' + - '-t' + - '${_REGION}-docker.pkg.dev/${_PROJECT_ID}/samo-dl/${_IMAGE_NAME}' + - '-f' + - '${_DOCKERFILE}' + - '.' + timeout: 1800s # 30 minutes for this step specifically + images: - - 'us-central1-docker.pkg.dev/the-tendril-466607-n8/samo-dl/samo-emotion-api-secure' + - '${_REGION}-docker.pkg.dev/${_PROJECT_ID}/samo-dl/${_IMAGE_NAME}' diff --git a/deployment/cloud-run/config.py b/deployment/cloud-run/config.py index d44221d89..918096e63 100644 --- a/deployment/cloud-run/config.py +++ b/deployment/cloud-run/config.py @@ -1,5 +1,4 @@ -""" -Environment Configuration Management - Phase 3 Cloud Run Optimization +"""Environment Configuration Management - Phase 3 Cloud Run Optimization Provides environment-specific settings for development, staging, and production """ @@ -7,9 +6,11 @@ from typing import Dict, Any, Optional, List from dataclasses import dataclass + @dataclass class CloudRunConfig: """Cloud Run specific configuration""" + # Resource allocation memory_limit_mb: int = 2048 cpu_limit: int = 2 @@ -47,38 +48,43 @@ class CloudRunConfig: enable_rate_limiting: bool = True enable_input_sanitization: bool = True + class EnvironmentConfig: """Environment-specific configuration management""" def __init__(self, environment: str = None): - self.environment = environment or os.getenv('ENVIRONMENT', 'development') + self.environment = environment or os.getenv("ENVIRONMENT", "development") self.config = self._load_environment_config() def _load_environment_config(self) -> CloudRunConfig: """Load configuration based on environment""" - if self.environment == 'production': + if self.environment == "production": return CloudRunConfig( - memory_limit_mb=int(os.getenv('MEMORY_LIMIT_MB', '2048') or '2048'), - cpu_limit=int(os.getenv('CPU_LIMIT', '2') or '2'), - max_instances=int(os.getenv('MAX_INSTANCES', '10') or '10'), - min_instances=int(os.getenv('MIN_INSTANCES', '1') or '1'), - concurrency=int(os.getenv('CONCURRENCY', '80') or '80'), - timeout_seconds=int(os.getenv('TIMEOUT_SECONDS', '300') or '300'), - target_cpu_utilization=float(os.getenv('TARGET_CPU_UTILIZATION', '0.7') or '0.7'), - target_memory_utilization=float(os.getenv('TARGET_MEMORY_UTILIZATION', '0.8') or '0.8'), - health_check_interval_seconds=int(os.getenv('HEALTH_CHECK_INTERVAL', '30') or '30'), - graceful_shutdown_timeout_seconds=int(os.getenv('GRACEFUL_SHUTDOWN_TIMEOUT', '30') or '30'), - enable_monitoring=os.getenv('ENABLE_MONITORING', 'true').lower() == 'true', - enable_metrics=os.getenv('ENABLE_METRICS', 'true').lower() == 'true', - log_level=os.getenv('LOG_LEVEL', 'info'), - max_requests_per_minute=int(os.getenv('MAX_REQUESTS_PER_MINUTE', '1000') or '1000'), + memory_limit_mb=int(os.getenv("MEMORY_LIMIT_MB", "2048") or "2048"), + cpu_limit=int(os.getenv("CPU_LIMIT", "2") or "2"), + max_instances=int(os.getenv("MAX_INSTANCES", "10") or "10"), + min_instances=int(os.getenv("MIN_INSTANCES", "1") or "1"), + concurrency=int(os.getenv("CONCURRENCY", "80") or "80"), + timeout_seconds=int(os.getenv("TIMEOUT_SECONDS", "300") or "300"), + target_cpu_utilization=float(os.getenv("TARGET_CPU_UTILIZATION", "0.7") or "0.7"), + target_memory_utilization=float( + os.getenv("TARGET_MEMORY_UTILIZATION", "0.8") or "0.8" + ), + health_check_interval_seconds=int(os.getenv("HEALTH_CHECK_INTERVAL", "30") or "30"), + graceful_shutdown_timeout_seconds=int( + os.getenv("GRACEFUL_SHUTDOWN_TIMEOUT", "30") or "30" + ), + enable_monitoring=os.getenv("ENABLE_MONITORING", "true").lower() == "true", + enable_metrics=os.getenv("ENABLE_METRICS", "true").lower() == "true", + log_level=os.getenv("LOG_LEVEL", "info"), + max_requests_per_minute=int(os.getenv("MAX_REQUESTS_PER_MINUTE", "1000") or "1000"), enable_cors=True, - cors_origins=os.getenv('CORS_ORIGINS', '*').split(','), + cors_origins=os.getenv("CORS_ORIGINS", "*").split(","), enable_rate_limiting=True, - enable_input_sanitization=True + enable_input_sanitization=True, ) - if self.environment == 'staging': + if self.environment == "staging": return CloudRunConfig( memory_limit_mb=1024, cpu_limit=1, @@ -92,12 +98,12 @@ def _load_environment_config(self) -> CloudRunConfig: graceful_shutdown_timeout_seconds=15, enable_monitoring=True, enable_metrics=True, - log_level='debug', + log_level="debug", max_requests_per_minute=500, enable_cors=True, - cors_origins=['*'], + cors_origins=["*"], enable_rate_limiting=True, - enable_input_sanitization=True + enable_input_sanitization=True, ) return CloudRunConfig( memory_limit_mb=512, @@ -112,59 +118,59 @@ def _load_environment_config(self) -> CloudRunConfig: graceful_shutdown_timeout_seconds=10, enable_monitoring=False, enable_metrics=False, - log_level='debug', + log_level="debug", max_requests_per_minute=100, enable_cors=True, - cors_origins=['*'], + cors_origins=["*"], enable_rate_limiting=False, - enable_input_sanitization=False + enable_input_sanitization=False, ) def get_gunicorn_config(self) -> Dict[str, Any]: """Get Gunicorn configuration for Cloud Run""" return { - 'bind': f':{os.getenv("PORT", "8080")}', - 'workers': 1, # Cloud Run best practice - 'threads': 8, - 'timeout': 0, # Cloud Run handles timeouts - 'keepalive': 5, - 'max_requests': 1000, - 'max_requests_jitter': 100, - 'access_logfile': '-', - 'error_logfile': '-', - 'loglevel': self.config.log_level, - 'preload_app': True, - 'worker_class': 'sync', - 'worker_connections': self.config.concurrency + "bind": f":{os.getenv('PORT', '8080')}", + "workers": 1, # Cloud Run best practice + "threads": 8, + "timeout": 0, # Cloud Run handles timeouts + "keepalive": 5, + "max_requests": 1000, + "max_requests_jitter": 100, + "access_logfile": "-", + "error_logfile": "-", + "loglevel": self.config.log_level, + "preload_app": True, + "worker_class": "sync", + "worker_connections": self.config.concurrency, } def get_health_check_config(self) -> Dict[str, Any]: """Get health check configuration""" return { - 'interval_seconds': self.config.health_check_interval_seconds, - 'timeout_seconds': self.config.health_check_timeout_seconds, - 'retries': self.config.health_check_retries, - 'graceful_shutdown_timeout': self.config.graceful_shutdown_timeout_seconds + "interval_seconds": self.config.health_check_interval_seconds, + "timeout_seconds": self.config.health_check_timeout_seconds, + "retries": self.config.health_check_retries, + "graceful_shutdown_timeout": self.config.graceful_shutdown_timeout_seconds, } def get_monitoring_config(self) -> Dict[str, Any]: """Get monitoring configuration""" return { - 'enabled': self.config.enable_monitoring, - 'metrics_enabled': self.config.enable_metrics, - 'log_level': self.config.log_level, - 'target_cpu_utilization': self.config.target_cpu_utilization, - 'target_memory_utilization': self.config.target_memory_utilization + "enabled": self.config.enable_monitoring, + "metrics_enabled": self.config.enable_metrics, + "log_level": self.config.log_level, + "target_cpu_utilization": self.config.target_cpu_utilization, + "target_memory_utilization": self.config.target_memory_utilization, } def get_security_config(self) -> Dict[str, Any]: """Get security configuration""" return { - 'enable_cors': self.config.enable_cors, - 'cors_origins': self.config.cors_origins, - 'enable_rate_limiting': self.config.enable_rate_limiting, - 'enable_input_sanitization': self.config.enable_input_sanitization, - 'max_requests_per_minute': self.config.max_requests_per_minute + "enable_cors": self.config.enable_cors, + "cors_origins": self.config.cors_origins, + "enable_rate_limiting": self.config.enable_rate_limiting, + "enable_input_sanitization": self.config.enable_input_sanitization, + "max_requests_per_minute": self.config.max_requests_per_minute, } def validate_config(self) -> None: @@ -194,26 +200,28 @@ def validate_config(self) -> None: def to_dict(self) -> Dict[str, Any]: """Convert configuration to dictionary""" return { - 'environment': self.environment, - 'cloud_run': { - 'memory_limit_mb': self.config.memory_limit_mb, - 'cpu_limit': self.config.cpu_limit, - 'max_instances': self.config.max_instances, - 'min_instances': self.config.min_instances, - 'concurrency': self.config.concurrency, - 'timeout_seconds': self.config.timeout_seconds, - 'target_cpu_utilization': self.config.target_cpu_utilization, - 'target_memory_utilization': self.config.target_memory_utilization, - 'health_check_interval_seconds': self.config.health_check_interval_seconds, - 'graceful_shutdown_timeout_seconds': self.config.graceful_shutdown_timeout_seconds + "environment": self.environment, + "cloud_run": { + "memory_limit_mb": self.config.memory_limit_mb, + "cpu_limit": self.config.cpu_limit, + "max_instances": self.config.max_instances, + "min_instances": self.config.min_instances, + "concurrency": self.config.concurrency, + "timeout_seconds": self.config.timeout_seconds, + "target_cpu_utilization": self.config.target_cpu_utilization, + "target_memory_utilization": self.config.target_memory_utilization, + "health_check_interval_seconds": self.config.health_check_interval_seconds, + "graceful_shutdown_timeout_seconds": self.config.graceful_shutdown_timeout_seconds, }, - 'monitoring': self.get_monitoring_config(), - 'security': self.get_security_config() + "monitoring": self.get_monitoring_config(), + "security": self.get_security_config(), } + # Global configuration instance config = EnvironmentConfig() + def get_config() -> EnvironmentConfig: """Get the global configuration instance""" - return config + return config diff --git a/deployment/cloud-run/debug_api_import.py b/deployment/cloud-run/debug_api_import.py index 9ceee410d..be6ada2a5 100644 --- a/deployment/cloud-run/debug_api_import.py +++ b/deployment/cloud-run/debug_api_import.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -Debug script to isolate the 'int' object is not callable error -""" +"""Debug script to isolate the 'int' object is not callable error""" import sys import os @@ -14,6 +12,7 @@ try: print("1. Importing Flask...") from flask import Flask + print("✅ Flask imported successfully") except Exception as e: print(f"❌ Flask import failed: {e}") @@ -21,7 +20,8 @@ try: print("2. Importing Flask-RESTX...") - from flask_restx import Api, Resource, fields, Namespace + from flask_restx import Api, Namespace + print("✅ Flask-RESTX imported successfully") except Exception as e: print(f"❌ Flask-RESTX import failed: {e}") @@ -37,12 +37,7 @@ try: print("4. Creating API object...") - api = Api( - app, - version='1.0.0', - title='Test API', - description='Test API for debugging' - ) + api = Api(app, version="1.0.0", title="Test API", description="Test API for debugging") print(f"✅ API object created successfully: {type(api)}") print(f"API object: {api}") except Exception as e: @@ -51,9 +46,11 @@ try: print("5. Testing API decorator...") + @api.errorhandler(429) def test_handler(error): return {"error": "test"}, 429 + print("✅ API decorator test successful") except Exception as e: print(f"❌ API decorator test failed: {e}") @@ -63,7 +60,7 @@ def test_handler(error): try: print("6. Testing namespace creation...") - test_ns = Namespace('test', description='Test namespace') + test_ns = Namespace("test", description="Test namespace") api.add_namespace(test_ns) print("✅ Namespace test successful") except Exception as e: @@ -75,21 +72,18 @@ def test_handler(error): # Now let's test the actual imports from secure_api_server.py try: print("\n7. Testing security_headers import...") - from security_headers import add_security_headers print("✅ security_headers imported successfully") except Exception as e: print(f"❌ security_headers import failed: {e}") try: print("8. Testing rate_limiter import...") - from rate_limiter import rate_limit print("✅ rate_limiter imported successfully") except Exception as e: print(f"❌ rate_limiter import failed: {e}") try: print("9. Testing model_utils import...") - from model_utils import ensure_model_loaded, predict_emotions, get_model_status, validate_text_input print("✅ model_utils imported successfully") except Exception as e: print(f"❌ model_utils import failed: {e}") diff --git a/deployment/cloud-run/debug_errorhandler.py b/deployment/cloud-run/debug_errorhandler.py index 1e78cfe2f..321477734 100644 --- a/deployment/cloud-run/debug_errorhandler.py +++ b/deployment/cloud-run/debug_errorhandler.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -Debug script to investigate the errorhandler issue -""" +"""Debug script to investigate the errorhandler issue""" import sys import os @@ -13,7 +11,8 @@ try: from flask import Flask - from flask_restx import Api, Resource, fields, Namespace + from flask_restx import Api + print("✅ Imports successful") except Exception as e: print(f"❌ Import failed: {e}") @@ -21,38 +20,33 @@ try: app = Flask(__name__) - api = Api( - app, - version='1.0.0', - title='Test API', - description='Test API for debugging' - ) + api = Api(app, version="1.0.0", title="Test API", description="Test API for debugging") print("✅ API object created successfully") except Exception as e: print(f"❌ API creation failed: {e}") sys.exit(1) # Let's inspect the API object in detail -print(f"\n🔍 API object details:") +print("\n🔍 API object details:") print(f"Type: {type(api)}") print(f"Dir: {[attr for attr in dir(api) if not attr.startswith('_')]}") print(f"Has errorhandler: {'errorhandler' in dir(api)}") try: - errorhandler_method = getattr(api, 'errorhandler') + errorhandler_method = api.errorhandler print(f"✅ errorhandler method found: {type(errorhandler_method)}") print(f"errorhandler callable: {callable(errorhandler_method)}") except Exception as e: print(f"❌ errorhandler method access failed: {e}") # Let's check if there are any global variables that might be interfering -print(f"\n🔍 Checking for global variable conflicts...") +print("\n🔍 Checking for global variable conflicts...") print(f"Built-in errorhandler: {getattr(__builtins__, 'errorhandler', 'Not found')}") print(f"Global errorhandler: {globals().get('errorhandler', 'Not found')}") # Let's try to call errorhandler directly try: - print(f"\n🔍 Testing errorhandler call...") + print("\n🔍 Testing errorhandler call...") result = api.errorhandler(429) print(f"✅ errorhandler(429) call successful: {type(result)}") except Exception as e: @@ -63,8 +57,9 @@ # Let's check if there's a version issue try: import flask_restx + print(f"\n🔍 Flask-RESTX version: {flask_restx.__version__}") except Exception as e: print(f"❌ Could not get Flask-RESTX version: {e}") -print("\n🔍 Debug complete.") \ No newline at end of file +print("\n🔍 Debug complete.") diff --git a/deployment/cloud-run/debug_errorhandler_detailed.py b/deployment/cloud-run/debug_errorhandler_detailed.py index 2aecdcb8d..73fd4e4d4 100644 --- a/deployment/cloud-run/debug_errorhandler_detailed.py +++ b/deployment/cloud-run/debug_errorhandler_detailed.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 -""" -Detailed debug script to understand the errorhandler issue -""" +"""Detailed debug script to understand the errorhandler issue""" import os -os.environ['ADMIN_API_KEY'] = 'test123' + +os.environ["ADMIN_API_KEY"] = "test123" print("🔍 Starting detailed errorhandler debug...") try: from flask import Flask from flask_restx import Api + print("✅ Imports successful") except Exception as e: print(f"❌ Import failed: {e}") @@ -18,62 +18,65 @@ try: app = Flask(__name__) - api = Api(app, version='1.0.0', title='Test') + api = Api(app, version="1.0.0", title="Test") print("✅ API object created") except Exception as e: print(f"❌ API creation failed: {e}") exit(1) # Let's inspect the API object in detail -print(f"\n🔍 API object details:") +print("\n🔍 API object details:") print(f"Type: {type(api)}") print(f"Dir: {[attr for attr in dir(api) if not attr.startswith('_')]}") print(f"Has errorhandler: {'errorhandler' in dir(api)}") try: - errorhandler_method = getattr(api, 'errorhandler') + errorhandler_method = api.errorhandler print(f"✅ errorhandler method found: {type(errorhandler_method)}") print(f"errorhandler callable: {callable(errorhandler_method)}") - print(f"errorhandler bound: {errorhandler_method.__self__ if hasattr(errorhandler_method, '__self__') else 'Not bound'}") + print( + f"errorhandler bound: {errorhandler_method.__self__ if hasattr(errorhandler_method, '__self__') else 'Not bound'}" + ) except Exception as e: print(f"❌ errorhandler method access failed: {e}") # Let's try to understand what happens when we call errorhandler try: - print(f"\n🔍 Testing errorhandler call step by step...") - + print("\n🔍 Testing errorhandler call step by step...") + # First, let's see what the method looks like print(f"errorhandler method: {errorhandler_method}") print(f"errorhandler method type: {type(errorhandler_method)}") - + # Let's try calling it with different approaches - print(f"\nTrying direct call...") + print("\nTrying direct call...") result = errorhandler_method(429) print(f"Direct call result: {type(result)} - {result}") - - print(f"\nTrying bound call...") + + print("\nTrying bound call...") result2 = api.errorhandler(429) print(f"Bound call result: {type(result2)} - {result2}") - + # Let's check if there's a difference print(f"\nResults are the same: {result == result2}") - + except Exception as e: print(f"❌ errorhandler testing failed: {e}") print(f"Error type: {type(e)}") print(f"Error details: {e}") # Let's check if there are any global variables that might be interfering -print(f"\n🔍 Checking for global variable conflicts...") +print("\n🔍 Checking for global variable conflicts...") print(f"Built-in errorhandler: {getattr(__builtins__, 'errorhandler', 'Not found')}") print(f"Global errorhandler: {globals().get('errorhandler', 'Not found')}") # Let's check if there's a version issue try: import flask_restx + print(f"\n🔍 Flask-RESTX version: {flask_restx.__version__}") print(f"Flask version: {flask.__version__}") except Exception as e: print(f"❌ Could not get versions: {e}") -print("\n🔍 Debug complete.") \ No newline at end of file +print("\n🔍 Debug complete.") diff --git a/deployment/cloud-run/docs_blueprint.py b/deployment/cloud-run/docs_blueprint.py index 169a6a289..7e7d7284d 100644 --- a/deployment/cloud-run/docs_blueprint.py +++ b/deployment/cloud-run/docs_blueprint.py @@ -4,38 +4,39 @@ from flask import Blueprint, Response, jsonify, render_template, g -docs_bp = Blueprint('docs', __name__, template_folder='templates') +docs_bp = Blueprint("docs", __name__, template_folder="templates") -@docs_bp.route('/openapi.yaml', methods=['GET']) +@docs_bp.route("/openapi.yaml", methods=["GET"]) def serve_openapi_spec(): """Serve OpenAPI spec for Swagger UI with safe path validation.""" # Restrict spec path to a safe directory - allowed_dir = os.path.abspath(os.environ.get('OPENAPI_ALLOWED_DIR', '/app')) - spec_path = os.environ.get('OPENAPI_SPEC_PATH', '/app/openapi.yaml') + allowed_dir = os.path.abspath(os.environ.get("OPENAPI_ALLOWED_DIR", "/app")) + spec_path = os.environ.get("OPENAPI_SPEC_PATH", "/app/openapi.yaml") abs_spec_path = os.path.abspath(spec_path) try: # Validate that the spec path is within the allowed directory if os.path.commonpath([abs_spec_path, allowed_dir]) != allowed_dir: - return jsonify({'error': 'Invalid OpenAPI spec path'}), 400 + return jsonify({"error": "Invalid OpenAPI spec path"}), 400 - with open(abs_spec_path, 'r', encoding='utf-8') as f: + with open(abs_spec_path, encoding="utf-8") as f: content = f.read() # Use a standard YAML mimetype - return Response(content, mimetype='application/x-yaml') - except Exception as e: + return Response(content, mimetype="application/x-yaml") + except Exception: # Avoid leaking exact path in error; log on server side only if needed - return jsonify({'error': 'OpenAPI spec not found'}), 404 + return jsonify({"error": "OpenAPI spec not found"}), 404 -@docs_bp.route('/docs', methods=['GET'], strict_slashes=False) +@docs_bp.route("/docs", methods=["GET"], strict_slashes=False) def swagger_ui(): """Render Swagger UI that loads the OpenAPI spec from /openapi.yaml.""" # Allow overriding the spec URL (e.g., behind a proxy) but default to local - spec_url = os.environ.get('OPENAPI_SPEC_URL', '/openapi.yaml') + spec_url = os.environ.get("OPENAPI_SPEC_URL", "/openapi.yaml") # Generate per-request nonce for CSP and pass to template import secrets + nonce = secrets.token_urlsafe(16) g.csp_nonce = nonce - return render_template('docs.html', spec_url=spec_url, csp_nonce=nonce) + return render_template("docs.html", spec_url=spec_url, csp_nonce=nonce) diff --git a/deployment/cloud-run/health_monitor.py b/deployment/cloud-run/health_monitor.py index 8f681a028..73e02648a 100644 --- a/deployment/cloud-run/health_monitor.py +++ b/deployment/cloud-run/health_monitor.py @@ -1,5 +1,4 @@ -""" -Cloud Run Health Monitor - Phase 3 Optimization +"""Cloud Run Health Monitor - Phase 3 Optimization Provides comprehensive health checks, graceful shutdown, and monitoring """ @@ -17,9 +16,11 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) + @dataclass class HealthMetrics: """Health check metrics""" + status: str response_time_ms: float memory_usage_mb: float @@ -28,6 +29,7 @@ class HealthMetrics: timestamp: datetime error_message: Optional[str] = None + class HealthMonitor: """Comprehensive health monitoring for Cloud Run""" @@ -36,7 +38,7 @@ def __init__(self): self.is_shutting_down = False self.active_requests = 0 self.health_metrics: Dict[str, HealthMetrics] = {} - self.shutdown_timeout = int(os.getenv('GRACEFUL_SHUTDOWN_TIMEOUT', '30') or '30') + self.shutdown_timeout = int(os.getenv("GRACEFUL_SHUTDOWN_TIMEOUT", "30") or "30") # Register graceful shutdown handlers signal.signal(signal.SIGTERM, self._graceful_shutdown) @@ -56,7 +58,9 @@ def _graceful_shutdown(self, signum, frame): time.sleep(1) if self.active_requests > 0: - logger.warning(f"Force shutdown after {self.shutdown_timeout}s timeout with {self.active_requests} active requests") + logger.warning( + f"Force shutdown after {self.shutdown_timeout}s timeout with {self.active_requests} active requests" + ) else: logger.info("Graceful shutdown completed successfully") @@ -69,36 +73,35 @@ def get_system_metrics(self) -> Dict[str, float]: memory_info = process.memory_info() return { - 'memory_usage_mb': memory_info.rss / 1024 / 1024, - 'cpu_usage_percent': process.cpu_percent(), - 'memory_percent': process.memory_percent(), - 'uptime_seconds': (datetime.now() - self.start_time).total_seconds() + "memory_usage_mb": memory_info.rss / 1024 / 1024, + "cpu_usage_percent": process.cpu_percent(), + "memory_percent": process.memory_percent(), + "uptime_seconds": (datetime.now() - self.start_time).total_seconds(), } except Exception as e: logger.error(f"Error getting system metrics: {e}") return { - 'memory_usage_mb': 0.0, - 'cpu_usage_percent': 0.0, - 'memory_percent': 0.0, - 'uptime_seconds': 0.0 + "memory_usage_mb": 0.0, + "cpu_usage_percent": 0.0, + "memory_percent": 0.0, + "uptime_seconds": 0.0, } @staticmethod def check_model_health() -> Dict[str, Any]: """Check if ML models are loaded and responding""" try: - # Import models (this will fail if models aren't loaded) - from secure_api_server import app # Test model loading start_time = time.time() # Simple health check - try to import key components import importlib + modules_to_check = [ - 'src.models.emotion_detection.bert_classifier', - 'src.models.summarization.t5_summarizer', - 'src.models.voice_processing.whisper_transcriber' + "src.models.emotion_detection.bert_classifier", + "src.models.summarization.t5_summarizer", + "src.models.voice_processing.whisper_transcriber", ] for module_name in modules_to_check: @@ -106,22 +109,22 @@ def check_model_health() -> Dict[str, Any]: importlib.import_module(module_name) except ImportError as e: return { - 'status': 'unhealthy', - 'error': f'Model module {module_name} not available: {e}', - 'response_time_ms': (time.time() - start_time) * 1000 + "status": "unhealthy", + "error": f"Model module {module_name} not available: {e}", + "response_time_ms": (time.time() - start_time) * 1000, } return { - 'status': 'healthy', - 'response_time_ms': (time.time() - start_time) * 1000, - 'models_loaded': len(modules_to_check) + "status": "healthy", + "response_time_ms": (time.time() - start_time) * 1000, + "models_loaded": len(modules_to_check), } except Exception as e: return { - 'status': 'unhealthy', - 'error': f'Model health check failed: {e}', - 'response_time_ms': 0 + "status": "unhealthy", + "error": f"Model health check failed: {e}", + "response_time_ms": 0, } @staticmethod @@ -141,31 +144,31 @@ def check_api_health() -> Dict[str, Any]: if response.status_code == 200: return { - 'status': 'healthy', - 'response_time_ms': response_time, - 'status_code': response.status_code + "status": "healthy", + "response_time_ms": response_time, + "status_code": response.status_code, } return { - 'status': 'unhealthy', - 'error': f'Health endpoint returned {response.status_code}', - 'response_time_ms': response_time, - 'status_code': response.status_code + "status": "unhealthy", + "error": f"Health endpoint returned {response.status_code}", + "response_time_ms": response_time, + "status_code": response.status_code, } except Exception as e: return { - 'status': 'unhealthy', - 'error': f'API health check failed: {e}', - 'response_time_ms': 0 + "status": "unhealthy", + "error": f"API health check failed: {e}", + "response_time_ms": 0, } def get_comprehensive_health(self) -> Dict[str, Any]: """Get comprehensive health status""" if self.is_shutting_down: return { - 'status': 'shutting_down', - 'message': 'Service is shutting down gracefully', - 'active_requests': self.active_requests, - 'timestamp': datetime.now().isoformat() + "status": "shutting_down", + "message": "Service is shutting down gracefully", + "active_requests": self.active_requests, + "timestamp": datetime.now().isoformat(), } # Get system metrics @@ -178,43 +181,43 @@ def get_comprehensive_health(self) -> Dict[str, Any]: api_health = self.check_api_health() # Determine overall health - overall_status = 'healthy' - if model_health['status'] != 'healthy' or api_health['status'] != 'healthy': - overall_status = 'unhealthy' + overall_status = "healthy" + if model_health["status"] != "healthy" or api_health["status"] != "healthy": + overall_status = "unhealthy" # Check resource thresholds - if system_metrics['memory_usage_mb'] > 1500: # 1.5GB threshold - overall_status = 'degraded' + if system_metrics["memory_usage_mb"] > 1500: # 1.5GB threshold + overall_status = "degraded" - if system_metrics['cpu_usage_percent'] > 80: # 80% CPU threshold - overall_status = 'degraded' + if system_metrics["cpu_usage_percent"] > 80: # 80% CPU threshold + overall_status = "degraded" health_data = { - 'status': overall_status, - 'timestamp': datetime.now().isoformat(), - 'uptime_seconds': system_metrics['uptime_seconds'], - 'system': { - 'memory_usage_mb': round(system_metrics['memory_usage_mb'], 2), - 'cpu_usage_percent': round(system_metrics['cpu_usage_percent'], 2), - 'memory_percent': round(system_metrics['memory_percent'], 2) + "status": overall_status, + "timestamp": datetime.now().isoformat(), + "uptime_seconds": system_metrics["uptime_seconds"], + "system": { + "memory_usage_mb": round(system_metrics["memory_usage_mb"], 2), + "cpu_usage_percent": round(system_metrics["cpu_usage_percent"], 2), + "memory_percent": round(system_metrics["memory_percent"], 2), + }, + "models": model_health, + "api": api_health, + "requests": { + "active": self.active_requests, + "total_processed": len(self.health_metrics), }, - 'models': model_health, - 'api': api_health, - 'requests': { - 'active': self.active_requests, - 'total_processed': len(self.health_metrics) - } } # Store metrics for trend analysis self.health_metrics[datetime.now().isoformat()] = HealthMetrics( status=overall_status, - response_time_ms=api_health.get('response_time_ms', 0), - memory_usage_mb=system_metrics['memory_usage_mb'], - cpu_usage_percent=system_metrics['cpu_usage_percent'], + response_time_ms=api_health.get("response_time_ms", 0), + memory_usage_mb=system_metrics["memory_usage_mb"], + cpu_usage_percent=system_metrics["cpu_usage_percent"], active_requests=self.active_requests, timestamp=datetime.now(), - error_message=model_health.get('error') or api_health.get('error') + error_message=model_health.get("error") or api_health.get("error"), ) # Keep only last 100 metrics @@ -234,9 +237,11 @@ def request_completed(self): with self.lock: self.active_requests = max(0, self.active_requests - 1) + # Global health monitor instance health_monitor = HealthMonitor() + def get_health_monitor() -> HealthMonitor: """Get the global health monitor instance""" - return health_monitor + return health_monitor diff --git a/deployment/cloud-run/minimal_api_server.py b/deployment/cloud-run/minimal_api_server.py index 5f90bc504..ba8d978bc 100644 --- a/deployment/cloud-run/minimal_api_server.py +++ b/deployment/cloud-run/minimal_api_server.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Minimal Emotion Detection API Server +"""Minimal Emotion Detection API Server Uses known working PyTorch/transformers combination Matches the actual model architecture: RoBERTa with 12 emotion classes """ @@ -8,7 +7,6 @@ import logging import os import time -import os from flask import Flask, request, jsonify import psutil @@ -16,8 +14,10 @@ # Import shared model utilities from model_utils import ( - ensure_model_loaded, predict_emotions, get_model_status, - MAX_TEXT_LENGTH + ensure_model_loaded, + predict_emotions, + get_model_status, + MAX_TEXT_LENGTH, ) # Configure logging @@ -29,12 +29,15 @@ # Register shared docs blueprint from docs_blueprint import docs_bp + app.register_blueprint(docs_bp) # Prometheus metrics -REQUEST_COUNT = Counter('emotion_api_requests_total', 'Total requests', ['endpoint', 'status']) -REQUEST_DURATION = Histogram('emotion_api_request_duration_seconds', 'Request duration', ['endpoint']) -MODEL_LOAD_TIME = Histogram('emotion_model_load_time_seconds', 'Model load time') +REQUEST_COUNT = Counter("emotion_api_requests_total", "Total requests", ["endpoint", "status"]) +REQUEST_DURATION = Histogram( + "emotion_api_request_duration_seconds", "Request duration", ["endpoint"] +) +MODEL_LOAD_TIME = Histogram("emotion_model_load_time_seconds", "Model load time") def initialize_model(): @@ -47,39 +50,39 @@ def initialize_model(): logger.error("❌ Model initialization failed") -@app.route('/health', methods=['GET']) +@app.route("/health", methods=["GET"]) def health_check(): """Health check endpoint.""" try: # Check model status using shared utilities model_status_info = get_model_status() - model_status = "ready" if model_status_info.get('model_loaded', False) else "loading" + model_status = "ready" if model_status_info.get("model_loaded", False) else "loading" # System metrics cpu_percent = psutil.cpu_percent() memory = psutil.virtual_memory() health_data = { - 'status': 'healthy', - 'model_status': model_status, - 'timestamp': time.time(), - 'system': { - 'cpu_percent': cpu_percent, - 'memory_percent': memory.percent, - 'memory_available': memory.available - } + "status": "healthy", + "model_status": model_status, + "timestamp": time.time(), + "system": { + "cpu_percent": cpu_percent, + "memory_percent": memory.percent, + "memory_available": memory.available, + }, } - REQUEST_COUNT.labels(endpoint='/health', status='success').inc() + REQUEST_COUNT.labels(endpoint="/health", status="success").inc() return jsonify(health_data), 200 except Exception as e: - logger.error(f"❌ Health check failed: {e}") - REQUEST_COUNT.labels(endpoint='/health', status='error').inc() - return jsonify({'status': 'unhealthy', 'error': str(e)}), 500 + logger.error(f"❌ Health check failed: {e}", exc_info=True) + REQUEST_COUNT.labels(endpoint="/health", status="error").inc() + return jsonify({"status": "unhealthy", "error": "Health check failed"}), 500 -@app.route('/predict', methods=['POST']) +@app.route("/predict", methods=["POST"]) def predict(): """Predict emotions from text.""" start_time = time.time() @@ -87,19 +90,22 @@ def predict(): try: # Validate request if not request.is_json: - REQUEST_COUNT.labels(endpoint='/predict', status='error').inc() - return jsonify({'error': 'Content-Type must be application/json'}), 400 + REQUEST_COUNT.labels(endpoint="/predict", status="error").inc() + return jsonify({"error": "Content-Type must be application/json"}), 400 data = request.get_json() - text = data.get('text', '').strip() + text = data.get("text", "").strip() if not text: - REQUEST_COUNT.labels(endpoint='/predict', status='error').inc() - return jsonify({'error': 'Text field is required'}), 400 + REQUEST_COUNT.labels(endpoint="/predict", status="error").inc() + return jsonify({"error": "Text field is required"}), 400 if len(text) > MAX_TEXT_LENGTH: - REQUEST_COUNT.labels(endpoint='/predict', status='error').inc() - return jsonify({'error': f'Text too long (max {MAX_TEXT_LENGTH} characters)'}), 400 + REQUEST_COUNT.labels(endpoint="/predict", status="error").inc() + return ( + jsonify({"error": f"Text too long (max {MAX_TEXT_LENGTH} characters)"}), + 400, + ) # Ensure model is loaded initialize_model() @@ -109,50 +115,68 @@ def predict(): # Record metrics duration = time.time() - start_time - REQUEST_DURATION.labels(endpoint='/predict').observe(duration) - REQUEST_COUNT.labels(endpoint='/predict', status='success').inc() + REQUEST_DURATION.labels(endpoint="/predict").observe(duration) + REQUEST_COUNT.labels(endpoint="/predict", status="success").inc() return jsonify(result), 200 except Exception as e: logger.error(f"❌ Prediction endpoint error: {e}") duration = time.time() - start_time - REQUEST_DURATION.labels(endpoint='/predict').observe(duration) - REQUEST_COUNT.labels(endpoint='/predict', status='error').inc() - return jsonify({'error': 'Internal server error'}), 500 + REQUEST_DURATION.labels(endpoint="/predict").observe(duration) + REQUEST_COUNT.labels(endpoint="/predict", status="error").inc() + return jsonify({"error": "Internal server error"}), 500 -@app.route('/metrics', methods=['GET']) +@app.route("/metrics", methods=["GET"]) def metrics(): """Prometheus metrics endpoint.""" - return generate_latest(), 200, {'Content-Type': CONTENT_TYPE_LATEST} + return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST} -@app.route('/', methods=['GET']) +@app.route("/", methods=["GET"]) def root(): """Root endpoint with API information.""" # Get model status from shared utilities model_status = get_model_status() - return jsonify({ - 'service': 'SAMO Emotion Detection API (Minimal)', - 'version': '2.0.0', - 'status': 'operational', - 'endpoints': { - 'health': '/health', - 'predict': '/predict', - 'metrics': '/metrics' - }, - 'model_type': 'roberta_single_label', - 'emotions_supported': len(model_status.get('emotion_labels', [])), - 'emotions': model_status.get('emotion_labels', []) - }), 200 - - -if __name__ == '__main__': + return ( + jsonify( + { + "service": "SAMO Emotion Detection API (Minimal)", + "version": "2.0.0", + "status": "operational", + "endpoints": { + "health": "/health", + "predict": "/predict", + "metrics": "/metrics", + }, + "model_type": "roberta_single_label", + "emotions_supported": len(model_status.get("emotion_labels", [])), + "emotions": model_status.get("emotion_labels", []), + } + ), + 200, + ) + + +if __name__ == "__main__": # Initialize model on startup initialize_model() # Start server - port = int(os.getenv('PORT', '8080')) - app.run(host='0.0.0.0', port=port, debug=False, threaded=True) + port = int(os.getenv("PORT", "8080")) + # Use centralized security-first host binding configuration + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + get_binding_security_summary, + ) + + host, port = get_secure_host_binding(default_port=port) + validate_host_binding(host, port) + + security_summary = get_binding_security_summary(host, port) + logger.info("Security Summary: %s", security_summary) + + app.run(host=host, port=port, debug=False, threaded=True) diff --git a/deployment/cloud-run/minimal_test.py b/deployment/cloud-run/minimal_test.py index dffdddac6..c0e5addaa 100644 --- a/deployment/cloud-run/minimal_test.py +++ b/deployment/cloud-run/minimal_test.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 -""" -Minimal test to isolate the API setup issue -""" +"""Minimal test to isolate the API setup issue""" import os -os.environ['ADMIN_API_KEY'] = 'test123' + +os.environ["ADMIN_API_KEY"] = "test123" print("🔍 Starting minimal API setup test...") try: print("1. Importing modules...") from flask import Flask - from flask_restx import Api, Resource, fields, Namespace + from flask_restx import Api, fields, Namespace + print("✅ Imports successful") except Exception as e: print(f"❌ Imports failed: {e}") @@ -27,12 +27,7 @@ try: print("3. Creating API object...") - api = Api( - app, - version='1.0.0', - title='Test API', - description='Test API' - ) + api = Api(app, version="1.0.0", title="Test API", description="Test API") print(f"✅ API object created: {type(api)}") except Exception as e: print(f"❌ API creation failed: {e}") @@ -40,7 +35,7 @@ try: print("4. Creating namespace...") - test_ns = Namespace('test', description='Test namespace') + test_ns = Namespace("test", description="Test namespace") api.add_namespace(test_ns) print("✅ Namespace added") except Exception as e: @@ -49,9 +44,7 @@ try: print("5. Creating model...") - test_model = api.model('Test', { - 'message': fields.String(description='Test message') - }) + test_model = api.model("Test", {"message": fields.String(description="Test message")}) print("✅ Model created") except Exception as e: print(f"❌ Model creation failed: {e}") @@ -59,9 +52,11 @@ try: print("6. Testing errorhandler...") + @api.errorhandler(429) def test_handler(error): return {"error": "test"}, 429 + print("✅ Error handler created") except Exception as e: print(f"❌ Error handler creation failed: {e}") @@ -69,4 +64,4 @@ def test_handler(error): print(f"API errorhandler type: {type(api.errorhandler)}") exit(1) -print("🎉 All tests passed!") \ No newline at end of file +print("🎉 All tests passed!") diff --git a/deployment/cloud-run/model_utils.py b/deployment/cloud-run/model_utils.py index 0156e08d8..177d9eb4b 100644 --- a/deployment/cloud-run/model_utils.py +++ b/deployment/cloud-run/model_utils.py @@ -1,5 +1,4 @@ -""" -Shared model utilities for Cloud Run deployment with Hugging Face emotion model. +"""Shared model utilities for Cloud Run deployment with Hugging Face emotion model. This module provides common functionality for model loading, inference, and error handling to eliminate code duplication between API servers. @@ -20,8 +19,7 @@ from src.constants import EMOTION_MODEL_DIR # single source of truth except ImportError: EMOTION_MODEL_DIR = os.getenv( - 'EMOTION_MODEL_DIR', - '/app/models/emotion-english-distilroberta-base' + "EMOTION_MODEL_DIR", "/app/models/emotion-english-distilroberta-base" ) logger = logging.getLogger(__name__) @@ -34,16 +32,16 @@ model_ready_event = threading.Event() # Configuration -EMOTION_PROVIDER = os.getenv('EMOTION_PROVIDER', 'hf') -EMOTION_LOCAL_ONLY = os.getenv('EMOTION_LOCAL_ONLY', '1').lower() in ( - '1', 'true', 'yes' +EMOTION_PROVIDER = os.getenv("EMOTION_PROVIDER", "hf") +EMOTION_LOCAL_ONLY = os.getenv("EMOTION_LOCAL_ONLY", "1").lower() in ( + "1", + "true", + "yes", ) -MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', '1000')) +MAX_TEXT_LENGTH = int(os.getenv("MAX_TEXT_LENGTH", "1000")) # Emotion labels for the HF emotion model (6 classes) -EMOTION_LABELS = [ - 'anger', 'disgust', 'fear', 'joy', 'neutral', 'sadness', 'surprise' -] +EMOTION_LABELS = ["anger", "disgust", "fear", "joy", "neutral", "sadness", "surprise"] # Runtime emotion labels emotion_labels_runtime: List[str] = EMOTION_LABELS.copy() @@ -64,12 +62,12 @@ def _create_emotion_pipeline(tokenizer, model) -> TextClassificationPipeline: model=model, tokenizer=tokenizer, return_all_scores=True, - device=0 if torch.cuda.is_available() else -1 + device=0 if torch.cuda.is_available() else -1, ) def _validate_and_prepare_texts( - texts: List[str] + texts: List[str], ) -> Tuple[List[Optional[Dict[str, Any]]], List[str], List[int]]: """Validate input texts and prepare them for batch processing. @@ -84,23 +82,17 @@ def _validate_and_prepare_texts( valid_indices = [] for i, text in enumerate(texts): - if not isinstance(text, str): - results[i] = { - 'error': 'Text must be a non-empty string', - 'emotions': [], - 'confidence': 0.0 - } - elif not text.strip(): + if not isinstance(text, str) or not text.strip(): results[i] = { - 'error': 'Text must be a non-empty string', - 'emotions': [], - 'confidence': 0.0 + "error": "Text must be a non-empty string", + "emotions": [], + "confidence": 0.0, } elif len(text) > MAX_TEXT_LENGTH: results[i] = { - 'error': f'Text too long (max {MAX_TEXT_LENGTH} characters)', - 'emotions': [], - 'confidence': 0.0 + "error": f"Text too long (max {MAX_TEXT_LENGTH} characters)", + "emotions": [], + "confidence": 0.0, } else: valid_texts.append(text) @@ -137,11 +129,8 @@ def ensure_model_loaded() -> bool: # Check if local model directory exists if EMOTION_LOCAL_ONLY and os.path.isdir(EMOTION_MODEL_DIR): # Load from local directory - logger.info("📁 Loading from local model directory: %s", - EMOTION_MODEL_DIR) - tokenizer = AutoTokenizer.from_pretrained( - EMOTION_MODEL_DIR, local_files_only=True - ) + logger.info("📁 Loading from local model directory: %s", EMOTION_MODEL_DIR) + tokenizer = AutoTokenizer.from_pretrained(EMOTION_MODEL_DIR, local_files_only=True) model = AutoModelForSequenceClassification.from_pretrained( EMOTION_MODEL_DIR, local_files_only=True ) @@ -155,25 +144,23 @@ def ensure_model_loaded() -> bool: task="text-classification", model="j-hartmann/emotion-english-distilroberta-base", return_all_scores=True, - device=0 if torch.cuda.is_available() else -1 + device=0 if torch.cuda.is_available() else -1, ) logger.info("✅ Emotion model loaded from Hugging Face Hub") except Exception as download_error: - logger.warning("Failed to load from cache, downloading model: %s", - download_error) + logger.warning("Failed to load from cache, downloading model: %s", download_error) # Force download the model from huggingface_hub import snapshot_download + model_path = snapshot_download( repo_id="j-hartmann/emotion-english-distilroberta-base", local_dir=EMOTION_MODEL_DIR, - local_dir_use_symlinks=False + local_dir_use_symlinks=False, ) logger.info("📥 Model downloaded to: %s", model_path) # Load from downloaded directory - tokenizer = AutoTokenizer.from_pretrained( - EMOTION_MODEL_DIR, local_files_only=True - ) + tokenizer = AutoTokenizer.from_pretrained(EMOTION_MODEL_DIR, local_files_only=True) model = AutoModelForSequenceClassification.from_pretrained( EMOTION_MODEL_DIR, local_files_only=True ) @@ -183,9 +170,7 @@ def ensure_model_loaded() -> bool: # Update runtime labels from loaded model if available try: id2label = emotion_pipeline.model.config.id2label - emotion_labels_runtime = [ - id2label[i] for i in range(len(id2label)) - ] + emotion_labels_runtime = [id2label[i] for i in range(len(id2label))] except Exception as label_err: logger.debug("Unable to derive runtime labels from model config: %s", label_err) with model_lock: @@ -205,8 +190,7 @@ def ensure_model_loaded() -> bool: def predict_emotions(text: str) -> Dict[str, Any]: - """ - Predict emotions for given text using the emotion model. + """Predict emotions for given text using the emotion model. Args: text (str): Input text to analyze @@ -217,48 +201,40 @@ def predict_emotions(text: str) -> Dict[str, Any]: # Validate input first ok, err = validate_text_input(text) if not ok: - return {'error': err, 'emotions': [], 'confidence': 0.0} + return {"error": err, "emotions": [], "confidence": 0.0} if not ensure_model_loaded(): return { - 'error': 'Emotion model not available', - 'emotions': [], - 'confidence': 0.0 + "error": "Emotion model not available", + "emotions": [], + "confidence": 0.0, } try: - # Use the emotion pipeline for prediction results = emotion_pipeline(text) # Format results to match expected output emotions = [] for result in results[0]: # results is a list with one item for single text - emotions.append({ - 'emotion': result['label'], - 'confidence': result['score'] - }) + emotions.append({"emotion": result["label"], "confidence": result["score"]}) # Sort by confidence (highest first) - emotions.sort(key=lambda x: x['confidence'], reverse=True) + emotions.sort(key=lambda x: x["confidence"], reverse=True) # Overall confidence is the highest confidence score - overall_confidence = emotions[0]['confidence'] if emotions else 0.0 + overall_confidence = emotions[0]["confidence"] if emotions else 0.0 return { - 'text': text, - 'emotions': emotions, - 'confidence': overall_confidence, - 'timestamp': time.time() + "text": text, + "emotions": emotions, + "confidence": overall_confidence, + "timestamp": time.time(), } except Exception as e: logger.exception("❌ Emotion prediction failed: %s", e) - return { - 'error': 'Emotion prediction failed', - 'emotions': [], - 'confidence': 0.0 - } + return {"error": "Emotion prediction failed", "emotions": [], "confidence": 0.0} def get_model_status() -> Dict[str, Any]: @@ -268,14 +244,14 @@ def get_model_status() -> Dict[str, Any]: Dict[str, Any]: Model status information """ return { - 'model_loaded': model_loaded, - 'model_loading': model_loading, - 'model_dir': EMOTION_MODEL_DIR, - 'model_provider': EMOTION_PROVIDER, - 'local_only': EMOTION_LOCAL_ONLY, - 'max_text_length': MAX_TEXT_LENGTH, - 'emotion_labels': emotion_labels_runtime, - 'timestamp': time.time() + "model_loaded": model_loaded, + "model_loading": model_loading, + "model_dir": EMOTION_MODEL_DIR, + "model_provider": EMOTION_PROVIDER, + "local_only": EMOTION_LOCAL_ONLY, + "max_text_length": MAX_TEXT_LENGTH, + "emotion_labels": emotion_labels_runtime, + "timestamp": time.time(), } @@ -289,16 +265,14 @@ def predict_emotions_batch(texts: List[str]) -> List[Dict[str, Any]]: List[Dict[str, Any]]: List of prediction results for each text """ if not ensure_model_loaded(): - return [{ - 'error': 'Emotion model not available', - 'emotions': [], - 'confidence': 0.0 - } for _ in texts] + return [ + {"error": "Emotion model not available", "emotions": [], "confidence": 0.0} + for _ in texts + ] try: # Validate and prepare texts for processing - results, valid_texts_to_process, valid_indices = \ - _validate_and_prepare_texts(texts) + results, valid_texts_to_process, valid_indices = _validate_and_prepare_texts(texts) # Only run pipeline if there are valid texts if valid_texts_to_process: @@ -313,39 +287,41 @@ def predict_emotions_batch(texts: List[str]) -> List[Dict[str, Any]]: # Convert emotion results to list comprehension emotions = [ { - 'emotion': emotion_result['label'], - 'confidence': emotion_result['score'] + "emotion": emotion_result["label"], + "confidence": emotion_result["score"], } for emotion_result in result ] # Sort by confidence (highest first) - emotions.sort(key=lambda x: x['confidence'], reverse=True) + emotions.sort(key=lambda x: x["confidence"], reverse=True) # Overall confidence is the highest confidence score - overall_confidence = emotions[0]['confidence'] if emotions else 0.0 + overall_confidence = emotions[0]["confidence"] if emotions else 0.0 results[original_idx] = { - 'text': text, - 'emotions': emotions, - 'confidence': overall_confidence, - 'timestamp': time.time() + "text": text, + "emotions": emotions, + "confidence": overall_confidence, + "timestamp": time.time(), } return results except Exception as e: logger.exception("❌ Batch emotion prediction failed: %s", e) - return [{ - 'error': 'Batch emotion prediction failed', - 'emotions': [], - 'confidence': 0.0 - } for _ in texts] + return [ + { + "error": "Batch emotion prediction failed", + "emotions": [], + "confidence": 0.0, + } + for _ in texts + ] def validate_text_input(text: str) -> Tuple[bool, str]: - """ - Validate text input for prediction. + """Validate text input for prediction. Args: text (str): Text to validate @@ -354,7 +330,7 @@ def validate_text_input(text: str) -> Tuple[bool, str]: Tuple[bool, str]: (is_valid, error_message) """ if not isinstance(text, str) or not text.strip(): - return False, 'Text must be a non-empty string' + return False, "Text must be a non-empty string" if len(text) > MAX_TEXT_LENGTH: - return False, f'Text too long (max {MAX_TEXT_LENGTH} characters)' - return True, '' + return False, f"Text too long (max {MAX_TEXT_LENGTH} characters)" + return True, "" diff --git a/deployment/cloud-run/onnx_api_server.py b/deployment/cloud-run/onnx_api_server.py index 7354c35fc..a84452eaa 100644 --- a/deployment/cloud-run/onnx_api_server.py +++ b/deployment/cloud-run/onnx_api_server.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 -""" -Simplified ONNX-Based Emotion Detection API Server +"""Simplified ONNX-Based Emotion Detection API Server Uses simple string tokenization - no complex dependencies """ + import logging import os import time import re -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Tuple import threading import numpy as np @@ -30,43 +30,150 @@ model_lock = threading.Lock() # Prometheus metrics -REQUEST_COUNT = Counter('emotion_api_requests_total', 'Total requests', ['endpoint', 'status']) -REQUEST_DURATION = Histogram('emotion_api_request_duration_seconds', 'Request duration', ['endpoint']) -MODEL_LOAD_TIME = Histogram('emotion_model_load_time_seconds', 'Model load time') +REQUEST_COUNT = Counter("emotion_api_requests_total", "Total requests", ["endpoint", "status"]) +REQUEST_DURATION = Histogram( + "emotion_api_request_duration_seconds", "Request duration", ["endpoint"] +) +MODEL_LOAD_TIME = Histogram("emotion_model_load_time_seconds", "Model load time") # Emotion labels (immutable tuple) EMOTION_LABELS = ( - 'admiration', 'amusement', 'anger', 'annoyance', 'approval', 'caring', - 'confusion', 'curiosity', 'desire', 'disappointment', 'disapproval', - 'disgust', 'embarrassment', 'excitement', 'fear', 'gratitude', 'grief', - 'joy', 'love', 'nervousness', 'optimism', 'pride', 'realization', - 'relief', 'remorse', 'sadness', 'surprise', 'neutral' + "admiration", + "amusement", + "anger", + "annoyance", + "approval", + "caring", + "confusion", + "curiosity", + "desire", + "disappointment", + "disapproval", + "disgust", + "embarrassment", + "excitement", + "fear", + "gratitude", + "grief", + "joy", + "love", + "nervousness", + "optimism", + "pride", + "realization", + "relief", + "remorse", + "sadness", + "surprise", + "neutral", ) # Configuration -MODEL_PATH = os.getenv('MODEL_PATH', '/app/model/bert_emotion_classifier.onnx') -VOCAB_PATH = os.getenv('VOCAB_PATH', '/app/model/vocab.txt') -MAX_LENGTH = int(os.getenv('MAX_LENGTH', '128') or '128') -TEMPERATURE = float(os.getenv('TEMPERATURE', '1.0') or '1.0') -THRESHOLD = float(os.getenv('THRESHOLD', '0.6') or '0.6') +MODEL_PATH = os.getenv("MODEL_PATH", "/app/model/bert_emotion_classifier.onnx") +VOCAB_PATH = os.getenv("VOCAB_PATH", "/app/model/vocab.txt") +MAX_LENGTH = int(os.getenv("MAX_LENGTH", "128") or "128") +TEMPERATURE = float(os.getenv("TEMPERATURE", "1.0") or "1.0") +THRESHOLD = float(os.getenv("THRESHOLD", "0.6") or "0.6") # Simple vocabulary (fallback if no vocab file) SIMPLE_VOCAB = { - '': 0, '': 1, '': 2, '': 3, - 'the': 4, 'a': 5, 'and': 6, 'is': 7, 'in': 8, 'to': 9, 'of': 10, - 'i': 11, 'you': 12, 'he': 13, 'she': 14, 'it': 15, 'we': 16, 'they': 17, - 'am': 18, 'are': 19, 'was': 20, 'were': 21, 'be': 22, 'been': 23, 'being': 24, - 'have': 25, 'has': 26, 'had': 27, 'do': 28, 'does': 29, 'did': 30, - 'will': 31, 'would': 32, 'could': 33, 'should': 34, 'may': 35, 'might': 36, - 'can': 37, 'must': 38, 'shall': 39, 'this': 40, 'that': 41, 'these': 42, 'those': 43, - 'my': 44, 'your': 45, 'his': 46, 'her': 47, 'its': 48, 'our': 49, 'their': 50, - 'me': 51, 'him': 52, 'us': 53, 'them': 54, 'myself': 55, 'yourself': 56, 'himself': 57, - 'herself': 58, 'itself': 59, 'ourselves': 60, 'yourselves': 61, 'themselves': 62, - 'what': 63, 'which': 64, 'who': 65, 'whom': 66, 'whose': 67, 'all': 72, 'any': 73, 'both': 74, 'each': 75, 'few': 76, - 'more': 77, 'most': 78, 'other': 79, 'some': 80, 'such': 81, 'no': 82, 'nor': 83, - 'not': 84, 'only': 85, 'own': 86, 'same': 87, 'so': 88, 'than': 89, 'too': 90, - 'very': 91, 'just': 92, 'now': 93, 'then': 94, 'here': 95, 'there': 96, 'when': 97, - 'where': 98, 'why': 99, 'how': 100 + "": 0, + "": 1, + "": 2, + "": 3, + "the": 4, + "a": 5, + "and": 6, + "is": 7, + "in": 8, + "to": 9, + "of": 10, + "i": 11, + "you": 12, + "he": 13, + "she": 14, + "it": 15, + "we": 16, + "they": 17, + "am": 18, + "are": 19, + "was": 20, + "were": 21, + "be": 22, + "been": 23, + "being": 24, + "have": 25, + "has": 26, + "had": 27, + "do": 28, + "does": 29, + "did": 30, + "will": 31, + "would": 32, + "could": 33, + "should": 34, + "may": 35, + "might": 36, + "can": 37, + "must": 38, + "shall": 39, + "this": 40, + "that": 41, + "these": 42, + "those": 43, + "my": 44, + "your": 45, + "his": 46, + "her": 47, + "its": 48, + "our": 49, + "their": 50, + "me": 51, + "him": 52, + "us": 53, + "them": 54, + "myself": 55, + "yourself": 56, + "himself": 57, + "herself": 58, + "itself": 59, + "ourselves": 60, + "yourselves": 61, + "themselves": 62, + "what": 63, + "which": 64, + "who": 65, + "whom": 66, + "whose": 67, + "all": 72, + "any": 73, + "both": 74, + "each": 75, + "few": 76, + "more": 77, + "most": 78, + "other": 79, + "some": 80, + "such": 81, + "no": 82, + "nor": 83, + "not": 84, + "only": 85, + "own": 86, + "same": 87, + "so": 88, + "than": 89, + "too": 90, + "very": 91, + "just": 92, + "now": 93, + "then": 94, + "here": 95, + "there": 96, + "when": 97, + "where": 98, + "why": 99, + "how": 100, } @@ -75,7 +182,7 @@ def load_vocab() -> Dict[str, int]: try: if os.path.exists(VOCAB_PATH): vocab_dict = {} - with open(VOCAB_PATH, 'r', encoding='utf-8') as f: + with open(VOCAB_PATH, encoding="utf-8") as f: for i, line in enumerate(f): word = line.strip() if word: @@ -95,19 +202,19 @@ def simple_tokenize(text: str) -> List[int]: """Simple tokenization using word splitting and vocabulary lookup.""" # Clean and normalize text text = text.lower().strip() - text = re.sub(r'[^\w\s]', ' ', text) + text = re.sub(r"[^\w\s]", " ", text) # Split into words words = text.split() # Convert to token IDs - tokens = [vocab.get('', 2)] # Start token + tokens = [vocab.get("", 2)] # Start token - for word in words[:MAX_LENGTH-2]: # Leave room for CLS and SEP - token_id = vocab.get(word, vocab.get('', 1)) + for word in words[: MAX_LENGTH - 2]: # Leave room for CLS and SEP + token_id = vocab.get(word, vocab.get("", 1)) tokens.append(token_id) - tokens.append(vocab.get('', 3)) # End token + tokens.append(vocab.get("", 3)) # End token return tokens @@ -119,7 +226,7 @@ def preprocess_text(text: str) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: # Pad or truncate to MAX_LENGTH if len(tokens) < MAX_LENGTH: - tokens.extend([vocab.get('', 0)] * (MAX_LENGTH - len(tokens))) + tokens.extend([vocab.get("", 0)] * (MAX_LENGTH - len(tokens))) else: tokens = tokens[:MAX_LENGTH] @@ -171,13 +278,10 @@ def postprocess_predictions(logits: np.ndarray) -> List[Dict[str, float]]: results = [] for i, prob in enumerate(probabilities[0]): if prob >= THRESHOLD: - results.append({ - 'emotion': EMOTION_LABELS[i], - 'confidence': float(prob) - }) + results.append({"emotion": EMOTION_LABELS[i], "confidence": float(prob)}) # Sort by confidence - results.sort(key=lambda x: x['confidence'], reverse=True) + results.sort(key=lambda x: x["confidence"], reverse=True) return results @@ -190,9 +294,9 @@ def predict_emotions(text: str) -> Dict[str, any]: # Prepare inputs for ONNX onnx_inputs = { - 'input_ids': input_ids, - 'attention_mask': attention_mask, - 'token_type_ids': token_type_ids + "input_ids": input_ids, + "attention_mask": attention_mask, + "token_type_ids": token_type_ids, } # Run inference @@ -205,10 +309,10 @@ def predict_emotions(text: str) -> Dict[str, any]: emotions = postprocess_predictions(logits) return { - 'emotions': emotions, - 'inference_time': inference_time, - 'text_length': len(text), - 'model_type': 'onnx_simple' + "emotions": emotions, + "inference_time": inference_time, + "text_length": len(text), + "model_type": "onnx_simple", } except Exception as e: @@ -240,7 +344,7 @@ def initialize_model(): initialize_model() -@app.route('/health', methods=['GET']) +@app.route("/health", methods=["GET"]) def health_check(): """Health check endpoint.""" try: @@ -252,26 +356,26 @@ def health_check(): memory = psutil.virtual_memory() health_data = { - 'status': 'healthy', - 'model_status': model_status, - 'timestamp': time.time(), - 'system': { - 'cpu_percent': cpu_percent, - 'memory_percent': memory.percent, - 'memory_available': memory.available - } + "status": "healthy", + "model_status": model_status, + "timestamp": time.time(), + "system": { + "cpu_percent": cpu_percent, + "memory_percent": memory.percent, + "memory_available": memory.available, + }, } - REQUEST_COUNT.labels(endpoint='/health', status='success').inc() + REQUEST_COUNT.labels(endpoint="/health", status="success").inc() return jsonify(health_data), 200 except Exception as e: - logger.error(f"❌ Health check failed: {e}") - REQUEST_COUNT.labels(endpoint='/health', status='error').inc() - return jsonify({'error': str(e)}), 500 + logger.error(f"❌ Health check failed: {e}", exc_info=True) + REQUEST_COUNT.labels(endpoint="/health", status="error").inc() + return jsonify({"error": "Health check failed"}), 500 -@app.route('/predict', methods=['POST']) +@app.route("/predict", methods=["POST"]) def predict(): """Predict emotions from text.""" start_time = time.time() @@ -279,53 +383,58 @@ def predict(): try: # Get request data data = request.get_json() - if not data or 'text' not in data: - return jsonify({'error': 'Missing text field'}), 400 + if not data or "text" not in data: + return jsonify({"error": "Missing text field"}), 400 - text = data['text'].strip() + text = data["text"].strip() if not text: - return jsonify({'error': 'Text cannot be empty'}), 400 + return jsonify({"error": "Text cannot be empty"}), 400 # Predict emotions result = predict_emotions(text) # Record metrics duration = time.time() - start_time - REQUEST_DURATION.labels(endpoint='/predict').observe(duration) - REQUEST_COUNT.labels(endpoint='/predict', status='success').inc() + REQUEST_DURATION.labels(endpoint="/predict").observe(duration) + REQUEST_COUNT.labels(endpoint="/predict", status="success").inc() return jsonify(result), 200 except Exception as e: - logger.error(f"❌ Prediction failed: {e}") + logger.error(f"❌ Prediction failed: {e}", exc_info=True) duration = time.time() - start_time - REQUEST_DURATION.labels(endpoint='/predict').observe(duration) - REQUEST_COUNT.labels(endpoint='/predict', status='error').inc() - return jsonify({'error': str(e)}), 500 + REQUEST_DURATION.labels(endpoint="/predict").observe(duration) + REQUEST_COUNT.labels(endpoint="/predict", status="error").inc() + return jsonify({"error": "Prediction failed"}), 500 -@app.route('/metrics', methods=['GET']) +@app.route("/metrics", methods=["GET"]) def metrics(): """Prometheus metrics endpoint.""" - return generate_latest(), 200, {'Content-Type': CONTENT_TYPE_LATEST} + return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST} -@app.route('/', methods=['GET']) +@app.route("/", methods=["GET"]) def root(): """Root endpoint with API information.""" - return jsonify({ - 'service': 'SAMO Emotion Detection API', - 'version': '2.0.0', - 'model_type': 'ONNX Simple Tokenizer', - 'endpoints': { - '/health': 'Health check', - '/predict': 'Emotion prediction (POST)', - '/metrics': 'Prometheus metrics' - } - }), 200 + return ( + jsonify( + { + "service": "SAMO Emotion Detection API", + "version": "2.0.0", + "model_type": "ONNX Simple Tokenizer", + "endpoints": { + "/health": "Health check", + "/predict": "Emotion prediction (POST)", + "/metrics": "Prometheus metrics", + }, + } + ), + 200, + ) -if __name__ == '__main__': +if __name__ == "__main__": # Production WSGI server try: import gunicorn.app.base @@ -334,6 +443,7 @@ class StandaloneApplication(gunicorn.app.base.BaseApplication): def init(self, parser, opts, args): """Initialize the application (abstract method override).""" raise NotImplementedError() + def __init__(self, flask_app, gunicorn_options=None): self.options = gunicorn_options or {} self.application = flask_app @@ -348,18 +458,18 @@ def load(self): # Production configuration options = { - 'bind': '127.0.0.1:8080', - 'workers': 1, - 'worker_class': 'sync', - 'timeout': 120, - 'keepalive': 2, - 'max_requests': 1000, - 'max_requests_jitter': 50, - 'preload_app': True + "bind": "127.0.0.1:8080", + "workers": 1, + "worker_class": "sync", + "timeout": 120, + "keepalive": 2, + "max_requests": 1000, + "max_requests_jitter": 50, + "preload_app": True, } StandaloneApplication(flask_app=app, gunicorn_options=options).run() except ImportError: # Development server - app.run(host='127.0.0.1', port=8080, debug=False) + app.run(host="127.0.0.1", port=8080, debug=False) diff --git a/deployment/cloud-run/openapi.yaml b/deployment/cloud-run/openapi.yaml index 6106dfb85..58476c97e 100644 --- a/deployment/cloud-run/openapi.yaml +++ b/deployment/cloud-run/openapi.yaml @@ -3,27 +3,27 @@ info: title: SAMO-DL Emotion Detection API description: | # SAMO-DL Emotion Detection API - + A production-ready API for emotion detection using advanced deep learning models. - + ## Features - Real-time emotion detection from text - Batch processing capabilities - High accuracy (99.48% F1 Score) - Production-grade security and monitoring - + ## Supported Emotions - anxious, calm, content, excited, frustrated, grateful - happy, hopeful, overwhelmed, proud, sad, tired - + ## Authentication This API requires authentication using API keys. Include your API key in the `X-API-Key` header. - + ## Rate Limiting - 60 requests per minute per API key - 100 requests per hour per user - Batch requests count as individual requests - + ## Security - All endpoints use HTTPS - Input validation and sanitization diff --git a/deployment/cloud-run/rate_limiter.py b/deployment/cloud-run/rate_limiter.py index 96f040232..a0b4ef3b6 100644 --- a/deployment/cloud-run/rate_limiter.py +++ b/deployment/cloud-run/rate_limiter.py @@ -7,6 +7,7 @@ from flask import request, jsonify from functools import wraps + class RateLimiter: def __init__(self, requests_per_minute: int = 100): self.requests_per_minute = requests_per_minute @@ -19,8 +20,7 @@ def is_allowed(self, client_id: str) -> bool: with self.lock: # Clean old requests (older than 1 minute) - while (self.requests[client_id] and - current_time - self.requests[client_id][0] > 60): + while self.requests[client_id] and current_time - self.requests[client_id][0] > 60: self.requests[client_id].popleft() # Check if under limit @@ -34,13 +34,14 @@ def is_allowed(self, client_id: str) -> bool: def get_client_id(request) -> str: """Get client identifier""" # Try API key first - api_key = request.headers.get('X-API-Key') + api_key = request.headers.get("X-API-Key") if api_key: return f"api_key:{api_key}" # Fall back to IP address return f"ip:{request.remote_addr}" + def rate_limit(requests_per_minute: int = 100): """Rate limiting decorator""" limiter = RateLimiter(requests_per_minute) @@ -51,11 +52,10 @@ def decorated_function(*args, **kwargs): client_id = limiter.get_client_id(request) if not limiter.is_allowed(client_id): - return jsonify({ - 'error': 'Rate limit exceeded', - 'retry_after': 60 - }), 429 + return jsonify({"error": "Rate limit exceeded", "retry_after": 60}), 429 return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/deployment/cloud-run/robust_predict.py b/deployment/cloud-run/robust_predict.py index 713de8542..f61cb047e 100644 --- a/deployment/cloud-run/robust_predict.py +++ b/deployment/cloud-run/robust_predict.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -🚀 EMOTION DETECTION API FOR CLOUD RUN +"""🚀 EMOTION DETECTION API FOR CLOUD RUN ====================================== Robust Flask API optimized for Cloud Run deployment. """ @@ -17,8 +16,7 @@ # Configure logging for Cloud Run logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -32,64 +30,96 @@ model_loaded = False model_lock = threading.Lock() -# Emotion mapping based on training order -EMOTION_MAPPING = ['anxious', 'calm', 'content', 'excited', 'frustrated', 'grateful', 'happy', 'hopeful', 'overwhelmed', 'proud', 'sad', 'tired'] +# Emotion mapping fallback (used if model has no labels) +EMOTION_MAPPING = [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", +] # Constants MAX_INPUT_LENGTH = 512 + def load_model(): """Load the emotion detection model""" global model, tokenizer, emotion_mapping, model_loading, model_loaded, model_lock - + with model_lock: if model_loading or model_loaded: return - - model_loading = True + model_loading = True + logger.info("🔄 Starting model loading...") - + try: # Get model path model_path = Path("/app/model") logger.info(f"📁 Loading model from: {model_path}") - + # Check if model files exist if not model_path.exists(): raise FileNotFoundError(f"Model directory not found: {model_path}") - + # Load tokenizer and model logger.info("📥 Loading tokenizer...") - tokenizer = AutoTokenizer.from_pretrained("roberta-base") - + tokenizer = AutoTokenizer.from_pretrained(str(model_path)) + logger.info("📥 Loading model...") model = AutoModelForSequenceClassification.from_pretrained(str(model_path)) - + # Set device (CPU for Cloud Run) - device = torch.device('cpu') + device = torch.device("cpu") model.to(device) model.eval() - - emotion_mapping = EMOTION_MAPPING - model_loaded = True - model_loading = False - + + # Derive mapping from model config (fallback to constant) + id2label = getattr(model.config, "id2label", {}) or {} + try: + pairs = [(int(k), v) for k, v in id2label.items()] + pairs.sort(key=lambda kv: kv[0]) + emotion_mapping = [v for _, v in pairs] or EMOTION_MAPPING + except Exception: + emotion_mapping = EMOTION_MAPPING + logger.info(f"✅ Model loaded successfully on {device}") logger.info(f"🎯 Supported emotions: {emotion_mapping}") - + except Exception: - model_loading = False logger.exception("❌ Failed to load model") # Do not re-raise to maintain secure error handling finally: - model_loading = False + # Update flags under lock to prevent race conditions + with model_lock: + # Verify that both model and tokenizer are present and non-None + if ( + "model" in locals() + and model is not None + and "tokenizer" in locals() + and tokenizer is not None + and "emotion_mapping" in locals() + and emotion_mapping is not None + ): + model_loaded = True + model_loading = False + def predict_emotion(text): """Predict emotion for given text""" global model, tokenizer, emotion_mapping - - if not model_loaded: - raise RuntimeError("Model not loaded") + + with model_lock: + if not model_loaded: + raise RuntimeError("Model not loaded") # Input sanitization and length check if not isinstance(text, str): @@ -98,143 +128,174 @@ def predict_emotion(text): raise ValueError(f"Input text too long (>{MAX_INPUT_LENGTH} characters).") # Tokenize - inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=MAX_INPUT_LENGTH, padding=True) - + inputs = tokenizer( + text, + return_tensors="pt", + truncation=True, + max_length=MAX_INPUT_LENGTH, + padding=True, + ) + # Predict with torch.no_grad(): outputs = model(**inputs) probabilities = torch.softmax(outputs.logits, dim=1) predicted_class = torch.argmax(probabilities, dim=1).item() confidence = probabilities[0][predicted_class].item() - + # Map to emotion name - emotion = emotion_mapping[predicted_class] - - return { - "emotion": emotion, - "confidence": confidence, - "text": text - } + emotion = ( + emotion_mapping[predicted_class] + if 0 <= predicted_class < len(emotion_mapping) + else f"label_{predicted_class}" + ) + + return {"emotion": emotion, "confidence": confidence, "text": text} + def ensure_model_loaded(): """Ensure model is loaded before processing requests""" - if not model_loaded and not model_loading: + should_load = False + + with model_lock: + if model_loaded: + return + if not model_loading: + should_load = True + # If model_loading is True, just return and let the loading complete + + # Call load_model outside the lock if needed + if should_load: load_model() - - if not model_loaded: - raise RuntimeError("Model not loaded") + + # Check again after loading + with model_lock: + if not model_loaded: + raise RuntimeError("Model not loaded") + def create_error_response(message, status_code=500): """Create standardized error response with request ID for debugging""" request_id = str(uuid.uuid4()) logger.exception(f"{message} [request_id={request_id}]") - return jsonify({ - 'error': message, - 'request_id': request_id - }), status_code + return jsonify({"error": message, "request_id": request_id}), status_code -@app.route('/', methods=['GET']) + +@app.route("/", methods=["GET"]) def root(): """Root endpoint""" - return jsonify({ - "message": "Hello from SAMO Emotion Detection API!", - "status": "running", - "timestamp": time.time() - }) + return jsonify( + { + "message": "Hello from SAMO Emotion Detection API!", + "status": "running", + "timestamp": time.time(), + } + ) + -@app.route('/health', methods=['GET']) +@app.route("/health", methods=["GET"]) def health_check(): """Health check endpoint""" - return jsonify({ - 'status': 'healthy', - 'model_loaded': model_loaded, - 'model_loading': model_loading, - 'port': os.environ.get('PORT', '8080'), - 'timestamp': time.time() - }) - -@app.route('/predict', methods=['POST']) + with model_lock: + return jsonify( + { + "status": "healthy", + "model_loaded": model_loaded, + "model_loading": model_loading, + "port": os.environ.get("PORT", "8080"), + "timestamp": time.time(), + } + ) + + +@app.route("/predict", methods=["POST"]) def predict(): """Predict emotion for given text""" try: # Ensure model is loaded ensure_model_loaded() - + # Content-type validation if not request.is_json: - return jsonify({'error': 'Content-Type must be application/json'}), 400 - + return jsonify({"error": "Content-Type must be application/json"}), 400 + try: data = request.get_json() except Exception: - return jsonify({'error': 'Invalid JSON data'}), 400 - + return jsonify({"error": "Invalid JSON data"}), 400 + if not data: - return jsonify({'error': 'No JSON data provided'}), 400 - - text = data.get('text', '') + return jsonify({"error": "No JSON data provided"}), 400 + + text = data.get("text", "") if not text: - return jsonify({'error': 'No text provided'}), 400 - + return jsonify({"error": "No text provided"}), 400 + # Make prediction result = predict_emotion(text) return jsonify(result) - + except Exception: - return create_error_response('Prediction processing failed. Please try again later.') + return create_error_response("Prediction processing failed. Please try again later.") + -@app.route('/predict_batch', methods=['POST']) +@app.route("/predict_batch", methods=["POST"]) def predict_batch(): """Predict emotions for multiple texts""" try: # Ensure model is loaded ensure_model_loaded() - + # Content-type validation if not request.is_json: - return jsonify({'error': 'Content-Type must be application/json'}), 400 - + return jsonify({"error": "Content-Type must be application/json"}), 400 + try: data = request.get_json() except Exception: - return jsonify({'error': 'Invalid JSON data'}), 400 - + return jsonify({"error": "Invalid JSON data"}), 400 + if not data: - return jsonify({'error': 'No JSON data provided'}), 400 - - texts = data.get('texts', []) + return jsonify({"error": "No JSON data provided"}), 400 + + texts = data.get("texts", []) if not texts: - return jsonify({'error': 'No texts provided'}), 400 - + return jsonify({"error": "No texts provided"}), 400 + # Make predictions results = [] for text in texts: result = predict_emotion(text) results.append(result) - - return jsonify({'results': results}) - + + return jsonify({"results": results}) + except Exception: - return create_error_response('Batch prediction processing failed. Please try again later.') + return create_error_response("Batch prediction processing failed. Please try again later.") + -@app.route('/emotions', methods=['GET']) +@app.route("/emotions", methods=["GET"]) def get_emotions(): """Get list of supported emotions""" - return jsonify({ - 'emotions': EMOTION_MAPPING, - 'count': len(EMOTION_MAPPING) - }) + with model_lock: + current_emotions = emotion_mapping if model_loaded else EMOTION_MAPPING + return jsonify({"emotions": current_emotions, "count": len(current_emotions)}) + -@app.route('/model_status', methods=['GET']) +@app.route("/model_status", methods=["GET"]) def model_status(): """Get detailed model status""" - return jsonify({ - 'model_loaded': model_loaded, - 'model_loading': model_loading, - 'emotions': EMOTION_MAPPING if model_loaded else [], - 'device': 'cpu', - 'timestamp': time.time() - }) + with model_lock: + return jsonify( + { + "model_loaded": model_loaded, + "model_loading": model_loading, + "emotions": emotion_mapping if model_loaded else EMOTION_MAPPING, + "device": "cpu", + "timestamp": time.time(), + } + ) + # Load model on startup def initialize_model(): @@ -244,10 +305,11 @@ def initialize_model(): except Exception: logger.exception("Failed to initialize model") + # Initialize model when module is imported initialize_model() -if __name__ == '__main__': +if __name__ == "__main__": logger.info("🚀 Starting SAMO Emotion Detection API") logger.info("=" * 50) logger.info("📊 Model Performance: 99.48% F1 Score") @@ -260,45 +322,62 @@ def initialize_model(): logger.info(" - GET /emotions - List emotions") logger.info(" - GET /model_status - Model status") logger.info("=" * 50) - + # Load model immediately try: load_model() except Exception: logger.exception("Failed to load model on startup") - + # Get port from environment (Cloud Run requirement) - port = int(os.environ.get('PORT', '8080')) - + port = int(os.environ.get("PORT", "8080")) + # Use production WSGI server for better performance and reliability import gunicorn.app.base - + class StandaloneApplication(gunicorn.app.base.BaseApplication): - def __init__(self, app, options=None): - self.options = options or {} + def __init__(self, app, gunicorn_options=None): + self.options = gunicorn_options or {} self.application = app super().__init__() - + def load_config(self): - config = {key: value for key, value in self.options.items() - if key in self.cfg.settings and value is not None} + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } for key, value in config.items(): self.cfg.set(key.lower(), value) - + def load(self): return self.application - + + # Use secure host binding for Gunicorn + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + ) + + host, derived_port = get_secure_host_binding(port) + validate_host_binding(host, derived_port) + bind_address = f"{host}:{derived_port}" + except ImportError: + # Fallback for container environments + bind_address = f"0.0.0.0:{port}" + options = { - 'bind': f'0.0.0.0:{port}', - 'workers': 1, # Single worker for Cloud Run - 'threads': 8, - 'timeout': 0, # No timeout for Cloud Run - 'keepalive': 5, - 'max_requests': 1000, - 'max_requests_jitter': 100, - 'access_logfile': '-', - 'error_logfile': '-', - 'loglevel': 'info' + "bind": bind_address, + "workers": 1, # Single worker for Cloud Run + "threads": 8, + "timeout": 0, # No timeout for Cloud Run + "keepalive": 5, + "max_requests": 1000, + "max_requests_jitter": 100, + "access_logfile": "-", + "error_logfile": "-", + "loglevel": "info", } - - StandaloneApplication(app, options).run() \ No newline at end of file + + StandaloneApplication(app, options).run() diff --git a/deployment/cloud-run/secure_api_server.py b/deployment/cloud-run/secure_api_server.py index beca133e2..c48e81948 100644 --- a/deployment/cloud-run/secure_api_server.py +++ b/deployment/cloud-run/secure_api_server.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -🚀 SECURE EMOTION DETECTION API FOR CLOUD RUN +"""🚀 SECURE EMOTION DETECTION API FOR CLOUD RUN ============================================ Production-ready Flask API with comprehensive security features and Swagger documentation. """ @@ -21,14 +20,14 @@ # Import shared model utilities from model_utils import ( - ensure_model_loaded, predict_emotions, get_model_status, - validate_text_input, + ensure_model_loaded, + predict_emotions, + get_model_status, ) # Configure logging for Cloud Run logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -37,89 +36,116 @@ # Add security headers add_security_headers(app) + # Register root endpoint BEFORE Flask-RESTX initialization to avoid conflicts -@app.route('/') +@app.route("/") def home(): # Changed from api_root to home to avoid conflict with Flask-RESTX's root """Get API status and information""" try: logger.info(f"Root endpoint accessed from {request.remote_addr}") - return jsonify({ - 'service': 'SAMO Emotion Detection API', - 'status': 'operational', - 'version': '2.0.0-secure', - 'security': 'enabled', - 'rate_limit': RATE_LIMIT_PER_MINUTE, - 'timestamp': time.time() - }) + return jsonify( + { + "service": "SAMO Emotion Detection API", + "status": "operational", + "version": "2.0.0-secure", + "security": "enabled", + "rate_limit": RATE_LIMIT_PER_MINUTE, + "timestamp": time.time(), + } + ) except Exception as e: - logger.error(f"Root endpoint error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + logger.error(f"Root endpoint error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) + # Initialize Flask-RESTX API without Swagger to avoid 500 errors api = Api( app, - version='2.0.0', - title='SAMO Emotion Detection API', - description='Secure, production-ready emotion detection API with comprehensive security features', + version="2.0.0", + title="SAMO Emotion Detection API", + description=( + "Secure, production-ready emotion detection API with comprehensive security features" + ), # Temporarily disable Swagger docs to avoid 500 errors # doc='/docs', - authorizations={ - 'apikey': { - 'type': 'apiKey', - 'in': 'header', - 'name': 'X-API-Key' - } - }, - security='apikey' + authorizations={"apikey": {"type": "apiKey", "in": "header", "name": "X-API-Key"}}, + security="apikey", ) # Create namespaces for better organization -main_ns = Namespace('api', description='Main API operations') # Removed leading slash to avoid double slashes -admin_ns = Namespace('/admin', description='Admin operations', authorizations={ - 'apikey': { - 'type': 'apiKey', - 'in': 'header', - 'name': 'X-API-Key' - } -}) +# Removed leading slash to avoid double slashes +main_ns = Namespace("api", description="Main API operations") +admin_ns = Namespace( + "/admin", + description="Admin operations", + authorizations={"apikey": {"type": "apiKey", "in": "header", "name": "X-API-Key"}}, +) # Add namespaces to API api.add_namespace(main_ns) api.add_namespace(admin_ns) # Define request/response models for Swagger -text_input_model = api.model('TextInput', { - 'text': fields.String(required=True, description='Text to analyze for emotion', example='I am feeling happy today!') -}) - -emotion_response_model = api.model('EmotionResponse', { - 'text': fields.String(description='Input text'), - 'emotions': fields.List(fields.Nested(api.model('Emotion', { - 'emotion': fields.String(description='Emotion label'), - 'confidence': fields.Float(description='Confidence score') - }))), - 'confidence': fields.Float(description='Overall confidence'), - 'request_id': fields.String(description='Unique request identifier'), - 'timestamp': fields.Float(description='Unix timestamp') -}) - -batch_input_model = api.model('BatchInput', { - 'texts': fields.List(fields.String, required=True, description='List of texts to analyze', example=['I am happy', 'I am sad']) -}) - -batch_response_model = api.model('BatchResponse', { - 'results': fields.List(fields.Nested(emotion_response_model)) -}) - -error_model = api.model('Error', { - 'error': fields.String(description='Error message'), - 'status_code': fields.Integer(description='HTTP status code'), - 'request_id': fields.String(description='Unique request identifier'), - 'timestamp': fields.Float(description='Unix timestamp') -}) +text_input_model = api.model( + "TextInput", + { + "text": fields.String( + required=True, + description="Text to analyze for emotion", + example="I am feeling happy today!", + ) + }, +) + +emotion_response_model = api.model( + "EmotionResponse", + { + "text": fields.String(description="Input text"), + "emotions": fields.List( + fields.Nested( + api.model( + "Emotion", + { + "emotion": fields.String(description="Emotion label"), + "confidence": fields.Float(description="Confidence score"), + }, + ) + ) + ), + "confidence": fields.Float(description="Overall confidence"), + "request_id": fields.String(description="Unique request identifier"), + "timestamp": fields.Float(description="Unix timestamp"), + }, +) + +batch_input_model = api.model( + "BatchInput", + { + "texts": fields.List( + fields.String, + required=True, + description="List of texts to analyze", + example=["I am happy", "I am sad"], + ) + }, +) + +batch_response_model = api.model( + "BatchResponse", {"results": fields.List(fields.Nested(emotion_response_model))} +) + +error_model = api.model( + "Error", + { + "error": fields.String(description="Error message"), + "status_code": fields.Integer(description="HTTP status code"), + "request_id": fields.String(description="Unique request identifier"), + "timestamp": fields.Float(description="Unix timestamp"), + }, +) # Security configuration from environment variables -ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY") +ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "") if not ADMIN_API_KEY: raise ValueError("ADMIN_API_KEY environment variable must be set") MAX_INPUT_LENGTH = int(os.environ.get("MAX_INPUT_LENGTH", "512")) @@ -136,34 +162,66 @@ def home(): # Changed from api_root to home to avoid conflict with Flask-RESTX' model_lock = threading.Lock() # Emotion mapping based on training order -EMOTION_MAPPING = ['anxious', 'calm', 'content', 'excited', 'frustrated', 'grateful', 'happy', 'hopeful', 'overwhelmed', 'proud', 'sad', 'tired'] +EMOTION_MAPPING = [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", +] + def require_api_key(f): """Decorator to require API key via X-API-Key header""" + @wraps(f) def decorated_function(*args, **kwargs): - api_key = request.headers.get('X-API-Key') + api_key = request.headers.get("X-API-Key") if not verify_api_key(api_key): logger.warning(f"Invalid API key attempt from {request.remote_addr}") - return create_error_response('Unauthorized - Invalid API key', 401) + return create_error_response("Unauthorized - Invalid API key", 401) return f(*args, **kwargs) + return decorated_function + def verify_api_key(api_key: str) -> bool: """Verify API key using constant-time comparison""" if not api_key: return False return hmac.compare_digest(api_key, ADMIN_API_KEY) + def sanitize_input(text: str) -> str: """Sanitize input text""" if not isinstance(text, str): raise ValueError("Input must be a string") # Remove potentially dangerous characters - dangerous_chars = ['<', '>', '"', "'", '&', ';', '|', '`', '$', '(', ')', '{{', '}}'] + dangerous_chars = [ + "<", + ">", + '"', + "'", + "&", + ";", + "|", + "`", + "$", + "(", + ")", + "{{", + "}}", + ] for char in dangerous_chars: - text = text.replace(char, '') + text = text.replace(char, "") # Limit length if len(text) > MAX_INPUT_LENGTH: @@ -171,6 +229,7 @@ def sanitize_input(text: str) -> str: return text.strip() + def load_model(): """Load the emotion detection model using shared utilities""" # Use the shared model loading function @@ -179,115 +238,131 @@ def load_model(): logger.error("❌ Model loading failed") raise RuntimeError("Model loading failed - check logs for details") + def predict_emotion(text: str) -> dict: """Predict emotion for given text using shared utilities""" # Use shared prediction function result = predict_emotions(text) # Add request ID for tracking - result['request_id'] = str(uuid.uuid4()) + result["request_id"] = str(uuid.uuid4()) return result + def check_model_loaded(): """Ensure model is loaded before processing requests""" # Use shared model loading function return ensure_model_loaded() + def create_error_response(error_message: str, status_code: int): """Create a properly formatted error response for Flask-RESTX""" error_response = { - 'error': error_message, - 'status_code': status_code, - 'request_id': str(uuid.uuid4()), - 'timestamp': time.time() + "error": error_message, + "status_code": status_code, + "request_id": str(uuid.uuid4()), + "timestamp": time.time(), } return error_response, status_code + def handle_rate_limit_exceeded(): """Handle rate limit exceeded - return proper error response""" logger.warning(f"Rate limit exceeded for {request.remote_addr}") - return create_error_response('Rate limit exceeded - too many requests', 429) + return create_error_response("Rate limit exceeded - too many requests", 429) + def log_rate_limit_info(): """Log rate limiting information for debugging""" logger.debug(f"Rate limiting configured: {RATE_LIMIT_PER_MINUTE} requests per minute") logger.debug(f"Current request from: {request.remote_addr}") + @app.before_request def before_request(): """Add request ID and timing to all requests""" g.start_time = time.time() g.request_id = str(uuid.uuid4()) - + # Lazy model initialization on first request if not check_model_loaded(): logger.info("🔄 Lazy initializing model on first request...") initialize_model() - + # Log incoming requests for debugging - logger.info(f"📥 Request: {request.method} {request.path} from {request.remote_addr} (ID: {g.request_id})") - + logger.info( + f"📥 Request: {request.method} {request.path} from " + f"{request.remote_addr} (ID: {g.request_id})" + ) + # Log request headers for debugging (excluding sensitive ones) - headers_to_log = {k: v for k, v in request.headers.items() - if k.lower() not in ['authorization', 'x-api-key', 'cookie']} + headers_to_log = { + k: v + for k, v in request.headers.items() + if k.lower() not in ["authorization", "x-api-key", "cookie"] + } logger.debug(f"📋 Request headers: {headers_to_log}") + @app.after_request def after_request(response): """Add request tracking headers""" - if hasattr(g, 'start_time'): + if hasattr(g, "start_time"): duration = time.time() - g.start_time - response.headers['X-Request-Duration'] = str(duration) - if hasattr(g, 'request_id'): - response.headers['X-Request-ID'] = g.request_id - + response.headers["X-Request-Duration"] = str(duration) + if hasattr(g, "request_id"): + response.headers["X-Request-ID"] = g.request_id + # Log response for debugging - logger.info(f"📤 Response: {response.status_code} for {request.method} {request.path} " - f"from {request.remote_addr} (ID: {g.request_id}, Duration: {duration:.3f}s)") - - return response + logger.info( + f"📤 Response: {response.status_code} for {request.method} " + f"{request.path} from {request.remote_addr} " + f"(ID: {g.request_id}, Duration: {duration:.3f}s)" + ) + return response -@main_ns.route('/health') +@main_ns.route("/health") class Health(Resource): - @api.doc('get_health') - @api.response(200, 'Success') - @api.response(503, 'Service Unavailable', error_model) - @api.response(500, 'Internal Server Error', error_model) + @api.doc("get_health") + @api.response(200, "Success") + @api.response(503, "Service Unavailable", error_model) + @api.response(500, "Internal Server Error", error_model) def get(self): """Get API health status""" try: logger.info(f"Health check from {request.remote_addr}") model_status = check_model_loaded() - + if model_status: logger.info("Health check passed - model is ready") return { - 'status': 'healthy', - 'model_loaded': model_status, - 'model_loading': False, - 'port': PORT, - 'timestamp': time.time() + "status": "healthy", + "model_loaded": model_status, + "model_loading": False, + "port": PORT, + "timestamp": time.time(), } else: logger.warning("Health check failed - model not ready") - return create_error_response('Service unavailable - model not ready', 503) - + return create_error_response("Service unavailable - model not ready", 503) + except Exception as e: - logger.error(f"Health check error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + logger.error(f"Health check error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) + -@main_ns.route('/predict') +@main_ns.route("/predict") class Predict(Resource): - @api.doc('post_predict', security='apikey') + @api.doc("post_predict", security="apikey") @api.expect(text_input_model, validate=True) - @api.response(200, 'Success', emotion_response_model) - @api.response(400, 'Bad Request', error_model) - @api.response(401, 'Unauthorized', error_model) - @api.response(429, 'Too Many Requests', error_model) - @api.response(503, 'Service Unavailable', error_model) + @api.response(200, "Success", emotion_response_model) + @api.response(400, "Bad Request", error_model) + @api.response(401, "Unauthorized", error_model) + @api.response(429, "Too Many Requests", error_model) + @api.response(503, "Service Unavailable", error_model) @rate_limit(RATE_LIMIT_PER_MINUTE) @require_api_key def post(self): @@ -295,29 +370,29 @@ def post(self): try: # Log rate limiting info for debugging log_rate_limit_info() - + # Get and validate input data = request.get_json() - if not data or 'text' not in data: + if not data or "text" not in data: logger.warning(f"Missing text field in request from {request.remote_addr}") - return create_error_response('Missing text field', 400) + return create_error_response("Missing text field", 400) - text = data['text'] + text = data["text"] if not text or not isinstance(text, str): logger.warning(f"Invalid text input from {request.remote_addr}: {type(text)}") - return create_error_response('Text must be a non-empty string', 400) + return create_error_response("Text must be a non-empty string", 400) # Sanitize input try: text = sanitize_input(text) except ValueError as e: - logger.warning(f"Input sanitization failed for {request.remote_addr}: {str(e)}") + logger.warning(f"Input sanitization failed for {request.remote_addr}: {e!s}") return create_error_response(str(e), 400) # Ensure model is loaded if not check_model_loaded(): logger.error("Model not ready for prediction request") - return create_error_response('Model not ready', 503) + return create_error_response("Model not ready", 503) # Predict emotion logger.info(f"Processing prediction request for {request.remote_addr}") @@ -325,18 +400,19 @@ def post(self): return result except Exception as e: - logger.error(f"Prediction error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + logger.error(f"Prediction error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) + -@main_ns.route('/predict_batch') +@main_ns.route("/predict_batch") class PredictBatch(Resource): - @api.doc('post_predict_batch', security='apikey') + @api.doc("post_predict_batch", security="apikey") @api.expect(batch_input_model, validate=True) - @api.response(200, 'Success', batch_response_model) - @api.response(400, 'Bad Request', error_model) - @api.response(401, 'Unauthorized', error_model) - @api.response(429, 'Too Many Requests', error_model) - @api.response(503, 'Service Unavailable', error_model) + @api.response(200, "Success", batch_response_model) + @api.response(400, "Bad Request", error_model) + @api.response(401, "Unauthorized", error_model) + @api.response(429, "Too Many Requests", error_model) + @api.response(503, "Service Unavailable", error_model) @rate_limit(RATE_LIMIT_PER_MINUTE) @require_api_key def post(self): @@ -344,73 +420,79 @@ def post(self): try: # Log rate limiting info for debugging log_rate_limit_info() - + # Get and validate input data = request.get_json() - if not data or 'texts' not in data: + if not data or "texts" not in data: logger.warning(f"Missing texts field in batch request from {request.remote_addr}") - return create_error_response('Missing texts field', 400) + return create_error_response("Missing texts field", 400) - texts = data['texts'] + texts = data["texts"] if not isinstance(texts, list) or len(texts) == 0: logger.warning(f"Invalid texts input from {request.remote_addr}: {type(texts)}") - return create_error_response('Texts must be a non-empty list', 400) + return create_error_response("Texts must be a non-empty list", 400) if len(texts) > 100: # Limit batch size logger.warning(f"Batch size too large from {request.remote_addr}: {len(texts)}") - return create_error_response('Batch size too large (max 100)', 400) + return create_error_response("Batch size too large (max 100)", 400) # Ensure model is loaded if not check_model_loaded(): logger.error("Model not ready for batch prediction request") - return create_error_response('Model not ready', 503) + return create_error_response("Model not ready", 503) # Process each text - logger.info(f"Processing batch prediction request for {request.remote_addr} with {len(texts)} texts") + logger.info( + f"Processing batch prediction request for {request.remote_addr} with {len(texts)} texts" + ) results = [] for text in texts: if not text or not isinstance(text, str): continue - + try: text = sanitize_input(text) result = predict_emotion(text) results.append(result) except Exception as e: - logger.warning(f"Failed to process text in batch from {request.remote_addr}: {str(e)}") + logger.warning( + f"Failed to process text in batch from {request.remote_addr}: {e!s}" + ) continue - return {'results': results} + return {"results": results} except Exception as e: - logger.error(f"Batch prediction error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + logger.error(f"Batch prediction error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) -@main_ns.route('/emotions') + +@main_ns.route("/emotions") class Emotions(Resource): - @api.doc('get_emotions') - @api.response(200, 'Success') - @api.response(500, 'Internal Server Error', error_model) + @api.doc("get_emotions") + @api.response(200, "Success") + @api.response(500, "Internal Server Error", error_model) def get(self): """Get list of supported emotions""" try: logger.info(f"Emotions list requested from {request.remote_addr}") return { - 'emotions': EMOTION_MAPPING, - 'count': len(EMOTION_MAPPING), - 'timestamp': time.time() + "emotions": EMOTION_MAPPING, + "count": len(EMOTION_MAPPING), + "timestamp": time.time(), } except Exception as e: - logger.error(f"Emotions endpoint error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + logger.error(f"Emotions endpoint error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) + # Admin endpoints -@admin_ns.route('/model_status') +@admin_ns.route("/model_status") class ModelStatus(Resource): - @api.doc('get_model_status', security='apikey') - @api.response(200, 'Success') - @api.response(401, 'Unauthorized', error_model) - @api.response(500, 'Internal Server Error', error_model) + @api.doc("get_model_status", security="apikey") + @api.response(200, "Success") + @api.response(401, "Unauthorized", error_model) + @api.response(500, "Internal Server Error", error_model) @require_api_key def get(self): """Get detailed model status (admin only)""" @@ -420,57 +502,64 @@ def get(self): status = get_model_status() return status except Exception as e: - logger.error(f"Model status error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + logger.error(f"Model status error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) -@admin_ns.route('/security_status') + +@admin_ns.route("/security_status") class SecurityStatus(Resource): - @api.doc('get_security_status', security='apikey') - @api.response(200, 'Success') - @api.response(401, 'Unauthorized', error_model) - @api.response(500, 'Internal Server Error', error_model) + @api.doc("get_security_status", security="apikey") + @api.response(200, "Success") + @api.response(401, "Unauthorized", error_model) + @api.response(500, "Internal Server Error", error_model) @require_api_key def get(self): """Get security configuration status (admin only)""" try: logger.info(f"Admin security status request from {request.remote_addr}") return { - 'api_key_protection': True, - 'input_sanitization': True, - 'rate_limiting': True, - 'request_tracking': True, - 'security_headers': True, - 'timestamp': time.time() + "api_key_protection": True, + "input_sanitization": True, + "rate_limiting": True, + "request_tracking": True, + "security_headers": True, + "timestamp": time.time(), } except Exception as e: - logger.error(f"Security status error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) + logger.error(f"Security status error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) + # Error handlers for Flask-RESTX - using direct registration due to decorator compatibility issue def rate_limit_exceeded(error): """Handle rate limit exceeded errors""" logger.warning(f"Rate limit exceeded for {request.remote_addr}") - return create_error_response('Rate limit exceeded - too many requests', 429) + return create_error_response("Rate limit exceeded - too many requests", 429) + def internal_error(error): """Handle internal server errors""" - logger.error(f"Internal server error for {request.remote_addr}: {str(error)}") - return create_error_response('Internal server error', 500) + logger.error(f"Internal server error for {request.remote_addr}: {error!s}") + return create_error_response("Internal server error", 500) + def not_found(error): """Handle not found errors""" logger.warning(f"Endpoint not found for {request.remote_addr}: {request.url}") - return create_error_response('Endpoint not found', 404) + return create_error_response("Endpoint not found", 404) + def method_not_allowed(error): """Handle method not allowed errors""" logger.warning(f"Method not allowed for {request.remote_addr}: {request.method} {request.url}") - return create_error_response('Method not allowed', 405) + return create_error_response("Method not allowed", 405) + def handle_unexpected_error(error): """Handle any unexpected errors""" - logger.error(f"Unexpected error for {request.remote_addr}: {str(error)}") - return create_error_response('An unexpected error occurred', 500) + logger.error(f"Unexpected error for {request.remote_addr}: {error!s}") + return create_error_response("An unexpected error occurred", 500) + # Register error handlers directly api.error_handlers[429] = rate_limit_exceeded @@ -479,30 +568,53 @@ def handle_unexpected_error(error): api.error_handlers[405] = method_not_allowed api.error_handlers[Exception] = handle_unexpected_error + def initialize_model(): """Initialize the emotion detection model""" try: logger.info("🚀 Initializing emotion detection API server...") - logger.info(f"📊 Configuration: MAX_INPUT_LENGTH={MAX_INPUT_LENGTH}, RATE_LIMIT={RATE_LIMIT_PER_MINUTE}/min") - logger.info(f"🔐 Security: API key protection enabled, Admin API key configured") + logger.info( + f"📊 Configuration: MAX_INPUT_LENGTH={MAX_INPUT_LENGTH}, " + f"RATE_LIMIT={RATE_LIMIT_PER_MINUTE}/min" + ) + logger.info("🔐 Security: API key protection enabled, Admin API key configured") logger.info(f"🌐 Server: Port {PORT}, Model path: {MODEL_PATH}") logger.info(f"🔄 Rate limiting: {RATE_LIMIT_PER_MINUTE} requests per minute") - + # Load the emotion detection model logger.info("🔄 Loading emotion detection model...") load_model() logger.info("✅ Model initialization completed successfully") logger.info("🚀 API server ready to handle requests") - + except Exception as e: - logger.error(f"❌ Failed to initialize API server: {str(e)}") + logger.error(f"❌ Failed to initialize API server: {e!s}") raise + # Initialize model when the application starts -if __name__ == '__main__': +if __name__ == "__main__": initialize_model() - logger.info(f"🌐 Starting Flask development server on port {PORT}") - app.run(host='0.0.0.0', port=PORT, debug=False) + + # Use centralized host binding for security + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + get_binding_security_summary, + ) + + host, port = get_secure_host_binding(PORT) + validate_host_binding(host, port) + logger.info( + "🌐 Starting Flask development server: %s", + get_binding_security_summary(host, port), + ) + app.run(host=host, port=port, debug=False) + except ImportError: + # Fallback if host_binding module not available + logger.warning("⚠️ Host binding module not available, using default configuration") + app.run(host="127.0.0.1", port=PORT, debug=False) else: # For production deployment - don't initialize during import # Model will be initialized when the app actually starts diff --git a/deployment/cloud-run/security_headers.py b/deployment/cloud-run/security_headers.py index 0aebcc545..58ab1c24e 100644 --- a/deployment/cloud-run/security_headers.py +++ b/deployment/cloud-run/security_headers.py @@ -2,7 +2,7 @@ """Security Headers Module for Cloud Run API""" from flask import Flask, request, g -from typing import Dict, Any + def add_security_headers(app: Flask) -> None: """Add comprehensive security headers to Flask app""" @@ -19,8 +19,8 @@ def add_headers(response): "connect-src 'self'; " "frame-ancestors 'none';" ) - if request.path.startswith('/docs'): - nonce = getattr(g, 'csp_nonce', None) + if request.path.startswith("/docs"): + nonce = getattr(g, "csp_nonce", None) if nonce: csp_docs = ( "default-src 'self'; " @@ -33,20 +33,23 @@ def add_headers(response): ) else: # Reject request if no nonce is available for docs - return "Content Security Policy violation: nonce required for /docs", 403 - response.headers['Content-Security-Policy'] = csp_docs + return ( + "Content Security Policy violation: nonce required for /docs", + 403, + ) + response.headers["Content-Security-Policy"] = csp_docs else: - response.headers['Content-Security-Policy'] = csp_base + response.headers["Content-Security-Policy"] = csp_base # Security headers - response.headers['X-Content-Type-Options'] = 'nosniff' - response.headers['X-Frame-Options'] = 'DENY' - response.headers['X-XSS-Protection'] = '1; mode=block' - response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' - response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()' - response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" # Remove server information - response.headers.pop('Server', None) + response.headers.pop("Server", None) return response diff --git a/deployment/cloud-run/test_direct_errorhandler.py b/deployment/cloud-run/test_direct_errorhandler.py index 00f16200a..ab11a074f 100644 --- a/deployment/cloud-run/test_direct_errorhandler.py +++ b/deployment/cloud-run/test_direct_errorhandler.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 -""" -Test direct error handler registration -""" +"""Test direct error handler registration""" import os -os.environ['ADMIN_API_KEY'] = 'test123' + +os.environ["ADMIN_API_KEY"] = "test123" print("🔍 Testing direct error handler registration...") try: from flask import Flask from flask_restx import Api + print("✅ Imports successful") except Exception as e: print(f"❌ Import failed: {e}") @@ -18,7 +18,7 @@ try: app = Flask(__name__) - api = Api(app, version='1.0.0', title='Test') + api = Api(app, version="1.0.0", title="Test") print("✅ API object created") except Exception as e: print(f"❌ API creation failed: {e}") @@ -27,38 +27,38 @@ # Let's try to register error handlers directly try: print("1. Testing direct error handler registration...") - + def rate_limit_handler(error): return {"error": "Rate limit exceeded"}, 429 - + def internal_error_handler(error): return {"error": "Internal server error"}, 500 - + # Try to register directly api.error_handlers[429] = rate_limit_handler api.error_handlers[500] = internal_error_handler - + print("✅ Direct registration successful") print(f"Error handlers: {api.error_handlers}") - + except Exception as e: print(f"❌ Direct registration failed: {e}") # Let's also try using the Flask app's error handler try: print("\n2. Testing Flask app error handler...") - + @app.errorhandler(429) def flask_rate_limit_handler(error): return {"error": "Rate limit exceeded"}, 429 - + @app.errorhandler(500) def flask_internal_error_handler(error): return {"error": "Internal server error"}, 500 - + print("✅ Flask app error handlers registered") - + except Exception as e: print(f"❌ Flask app error handler failed: {e}") -print("\n�� Test complete.") \ No newline at end of file +print("\n�� Test complete.") diff --git a/deployment/cloud-run/test_docs_error.py b/deployment/cloud-run/test_docs_error.py index ab387bab1..fc9b107e4 100644 --- a/deployment/cloud-run/test_docs_error.py +++ b/deployment/cloud-run/test_docs_error.py @@ -1,41 +1,53 @@ #!/usr/bin/env python3 -""" -Test script to investigate the Swagger docs 500 error -""" +"""Test script to investigate the Swagger docs 500 error""" import os import requests # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8082' # Different port +os.environ["ADMIN_API_KEY"] = "test-key-123" +os.environ["MAX_INPUT_LENGTH"] = "512" +os.environ["RATE_LIMIT_PER_MINUTE"] = "100" +os.environ["MODEL_PATH"] = "/app/model" +os.environ["PORT"] = "8082" # Different port try: from secure_api_server import app - + print("✅ Successfully imported secure_api_server") - + # Start server in background import threading + def run_server(): - app.run(host='0.0.0.0', port=8082, debug=False) - + # Use secure host binding for test server + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + ) + + host, port = get_secure_host_binding(8082) + validate_host_binding(host, port) + app.run(host=host, port=port, debug=False) + except ImportError: + # Fallback for test environment + app.run(host="127.0.0.1", port=8082, debug=False) + server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() - + # Wait for server to start import time + print("🔄 Starting server...") time.sleep(3) - + # Test docs endpoint specifically base_url = "http://localhost:8082" - + print("\n=== Testing Docs Endpoint ===") - + try: response = requests.get(f"{base_url}/docs", timeout=10) print(f"Status Code: {response.status_code}") @@ -43,17 +55,18 @@ def run_server(): print(f"Content Type: {response.headers.get('content-type', 'unknown')}") print(f"Content Length: {len(response.text)}") print(f"Response Text (first 500 chars): {response.text[:500]}") - + if response.status_code == 500: print("\n❌ 500 Error Details:") print(f"Full Response: {response.text}") - + except Exception as e: print(f"❌ Request failed: {e}") - + print("\n✅ Docs test completed!") - + except Exception as e: print(f"❌ Error: {e}") import traceback - traceback.print_exc() \ No newline at end of file + + traceback.print_exc() diff --git a/deployment/cloud-run/test_minimal_import.py b/deployment/cloud-run/test_minimal_import.py index 1bd62f110..11e217251 100644 --- a/deployment/cloud-run/test_minimal_import.py +++ b/deployment/cloud-run/test_minimal_import.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 -""" -Minimal test to isolate the API issue -""" +"""Minimal test to isolate the API issue""" import os -os.environ['ADMIN_API_KEY'] = 'test123' + +os.environ["ADMIN_API_KEY"] = "test123" print("🔍 Starting minimal import test...") @@ -12,6 +11,7 @@ print("1. Importing Flask and Flask-RESTX...") from flask import Flask from flask_restx import Api + print("✅ Basic imports successful") except Exception as e: print(f"❌ Basic imports failed: {e}") @@ -27,7 +27,7 @@ try: print("3. Creating API object...") - api = Api(app, version='1.0.0', title='Test') + api = Api(app, version="1.0.0", title="Test") print(f"✅ API object created: {type(api)}") except Exception as e: print(f"❌ API creation failed: {e}") @@ -52,4 +52,4 @@ print(f"Error type: {type(e)}") exit(1) -print("🎉 All tests passed!") \ No newline at end of file +print("🎉 All tests passed!") diff --git a/deployment/cloud-run/test_minimal_swagger.py b/deployment/cloud-run/test_minimal_swagger.py index a372cc6c7..8b51d2f36 100644 --- a/deployment/cloud-run/test_minimal_swagger.py +++ b/deployment/cloud-run/test_minimal_swagger.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -Minimal test to isolate Swagger docs issue -""" +"""Minimal test to isolate Swagger docs issue""" import os from flask import Flask, jsonify @@ -10,39 +8,55 @@ # Create Flask app app = Flask(__name__) + # Register root endpoint first -@app.route('/') +@app.route("/") def root(): - return jsonify({'message': 'Root endpoint'}) + return jsonify({"message": "Root endpoint"}) + # Initialize Flask-RESTX API api = Api( app, - version='1.0.0', - title='Test API', - description='Minimal test for Swagger docs', - doc='/docs' + version="1.0.0", + title="Test API", + description="Minimal test for Swagger docs", + doc="/docs", ) # Create namespace -main_ns = Namespace('api', description='Main operations') +main_ns = Namespace("api", description="Main operations") api.add_namespace(main_ns) + # Test endpoint -@main_ns.route('/health') +@main_ns.route("/health") class Health(Resource): def get(self): - return {'status': 'healthy'} + return {"status": "healthy"} + -if __name__ == '__main__': +if __name__ == "__main__": print("=== Routes ===") for rule in app.url_map.iter_rules(): print(f"{rule.rule} -> {rule.endpoint}") - + print("\n=== Starting test server ===") print("Test these endpoints:") print("- http://localhost:5003/ (should work)") print("- http://localhost:5003/docs (should work)") print("- http://localhost:5003/api/health (should work)") - - app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 5003)), debug=False) # Debug mode disabled for security \ No newline at end of file + + # Use secure host binding for test server + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + ) + + host, port = get_secure_host_binding(int(os.environ.get("PORT", 5003))) + validate_host_binding(host, port) + app.run(host=host, port=port, debug=False) + except ImportError: + # Fallback for test environment + app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 5003)), debug=False) diff --git a/deployment/cloud-run/test_routing_debug.py b/deployment/cloud-run/test_routing_debug.py index a7a53a252..da0cd9f22 100644 --- a/deployment/cloud-run/test_routing_debug.py +++ b/deployment/cloud-run/test_routing_debug.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -Debug script to understand Flask-RESTX routing behavior -""" +"""Debug script to understand Flask-RESTX routing behavior""" from flask import Flask, jsonify from flask_restx import Api, Resource, Namespace @@ -15,35 +13,39 @@ # Initialize Flask-RESTX API api = Api( app, - version='1.0.0', - title='Test API', - description='Minimal test to isolate routing issues', - doc='/docs' + version="1.0.0", + title="Test API", + description="Minimal test to isolate routing issues", + doc="/docs", ) print("\n=== After API creation ===") print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) # Create namespace -main_ns = Namespace('/api', description='Main operations') +main_ns = Namespace("/api", description="Main operations") api.add_namespace(main_ns) print("\n=== After adding namespace ===") print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) + # Test endpoint in namespace -@main_ns.route('/health') +@main_ns.route("/health") class Health(Resource): def get(self): - return {'status': 'healthy'} + return {"status": "healthy"} + print("\n=== After adding namespace route ===") print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) + # Test direct Flask route -@app.route('/test') +@app.route("/test") def test(): - return jsonify({'message': 'Test route'}) + return jsonify({"message": "Test route"}) + print("\n=== After adding Flask route ===") print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) @@ -51,9 +53,11 @@ def test(): # Now try to add root endpoint print("\n=== Trying to add root endpoint ===") try: - @app.route('/') + + @app.route("/") def root(): - return jsonify({'message': 'Root endpoint'}) + return jsonify({"message": "Root endpoint"}) + print("✅ Root endpoint added successfully") except Exception as e: print(f"❌ Failed to add root endpoint: {e}") @@ -78,7 +82,7 @@ def root(): # Check what Flask-RESTX created for the root route print("\n=== Flask-RESTX root route details ===") for rule in app.url_map.iter_rules(): - if rule.rule == '/': + if rule.rule == "/": print(f"Root route: {rule.rule} -> {rule.endpoint}") print(f" Methods: {rule.methods}") - print(f" View function: {rule.endpoint}") \ No newline at end of file + print(f" View function: {rule.endpoint}") diff --git a/deployment/cloud-run/test_routing_fixed.py b/deployment/cloud-run/test_routing_fixed.py index dc3e579f5..aff4387e5 100644 --- a/deployment/cloud-run/test_routing_fixed.py +++ b/deployment/cloud-run/test_routing_fixed.py @@ -1,57 +1,57 @@ #!/usr/bin/env python3 -""" -Test script to verify the fixed routing in secure_api_server.py -""" +"""Test script to verify the fixed routing in secure_api_server.py""" import os # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8080' +os.environ["ADMIN_API_KEY"] = "test-key-123" +os.environ["MAX_INPUT_LENGTH"] = "512" +os.environ["RATE_LIMIT_PER_MINUTE"] = "100" +os.environ["MODEL_PATH"] = "/app/model" +os.environ["PORT"] = "8080" try: from secure_api_server import app + print("✅ Successfully imported secure_api_server") - + print("\n=== All Routes ===") for rule in app.url_map.iter_rules(): print(f"{rule.rule} -> {rule.endpoint}") - + print("\n=== Testing specific endpoints ===") - + # Check if root endpoint exists - root_routes = [rule for rule in app.url_map.iter_rules() if rule.rule == '/'] + root_routes = [rule for rule in app.url_map.iter_rules() if rule.rule == "/"] if root_routes: print("✅ Root endpoint (/) exists") for route in root_routes: print(f" - {route.endpoint} (methods: {route.methods})") else: print("❌ Root endpoint (/) missing") - + # Check if health endpoint exists - health_routes = [rule for rule in app.url_map.iter_rules() if '/health' in rule.rule] + health_routes = [rule for rule in app.url_map.iter_rules() if "/health" in rule.rule] if health_routes: print("✅ Health endpoint exists") for route in health_routes: print(f" - {route.rule} -> {route.endpoint}") else: print("❌ Health endpoint missing") - + # Check if docs endpoint exists - docs_routes = [rule for rule in app.url_map.iter_rules() if rule.rule == '/docs'] + docs_routes = [rule for rule in app.url_map.iter_rules() if rule.rule == "/docs"] if docs_routes: print("✅ Docs endpoint (/docs) exists") for route in docs_routes: print(f" - {route.endpoint} (methods: {route.methods})") else: print("❌ Docs endpoint (/docs) missing") - + print("\n✅ Routing test completed successfully!") - + except Exception as e: print(f"❌ Error testing routing: {e}") import traceback - traceback.print_exc() \ No newline at end of file + + traceback.print_exc() diff --git a/deployment/cloud-run/test_routing_minimal.py b/deployment/cloud-run/test_routing_minimal.py index 73f2ea03e..b93f71a1f 100644 --- a/deployment/cloud-run/test_routing_minimal.py +++ b/deployment/cloud-run/test_routing_minimal.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -Minimal test script to isolate Flask-RESTX routing issues -""" +"""Minimal test script to isolate Flask-RESTX routing issues""" import os from flask import Flask, jsonify @@ -13,45 +11,62 @@ # Initialize Flask-RESTX API api = Api( app, - version='1.0.0', - title='Test API', - description='Minimal test to isolate routing issues', - doc='/docs' + version="1.0.0", + title="Test API", + description="Minimal test to isolate routing issues", + doc="/docs", ) # Create namespace with a different path to avoid conflicts -main_ns = Namespace('/api', description='Main operations') # Changed from '/' to '/api' +main_ns = Namespace("/api", description="Main operations") # Changed from '/' to '/api' api.add_namespace(main_ns) + # Test endpoint in namespace -@main_ns.route('/health') +@main_ns.route("/health") class Health(Resource): def get(self): - return {'status': 'healthy'} + return {"status": "healthy"} + # Test direct Flask route BEFORE API setup -@app.route('/test_before') +@app.route("/test_before") def test_before(): - return jsonify({'message': 'This route was added before API setup'}) + return jsonify({"message": "This route was added before API setup"}) + # Test direct Flask route AFTER API setup -@app.route('/test_after') +@app.route("/test_after") def test_after(): - return jsonify({'message': 'This route was added after API setup'}) + return jsonify({"message": "This route was added after API setup"}) + # Test root endpoint - this should work now -@app.route('/') +@app.route("/") def root(): - return jsonify({'message': 'Root endpoint'}) + return jsonify({"message": "Root endpoint"}) -if __name__ == '__main__': + +if __name__ == "__main__": print("=== Flask App Routes ===") for rule in app.url_map.iter_rules(): print(f"App: {rule.rule} -> {rule.endpoint}") - + print("\n=== Flask-RESTX API Routes ===") for rule in api.url_map.iter_rules(): print(f"API: {rule.rule} -> {rule.endpoint}") - + print("\n=== Starting test server ===") - app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 5000)), debug=False) # Debug mode disabled for security \ No newline at end of file + # Use secure host binding for test server + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + ) + + host, port = get_secure_host_binding(int(os.environ.get("PORT", 5000))) + validate_host_binding(host, port) + app.run(host=host, port=port, debug=False) + except ImportError: + # Fallback for test environment + app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 5000)), debug=False) diff --git a/deployment/cloud-run/test_server_start.py b/deployment/cloud-run/test_server_start.py index 19eb6edd1..71e1c722d 100644 --- a/deployment/cloud-run/test_server_start.py +++ b/deployment/cloud-run/test_server_start.py @@ -1,65 +1,77 @@ #!/usr/bin/env python3 -""" -Test script to verify the server starts and responds correctly -""" +"""Test script to verify the server starts and responds correctly""" import os import time import requests # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8081' # Different port to avoid conflicts +os.environ["ADMIN_API_KEY"] = "test-key-123" +os.environ["MAX_INPUT_LENGTH"] = "512" +os.environ["RATE_LIMIT_PER_MINUTE"] = "100" +os.environ["MODEL_PATH"] = "/app/model" +os.environ["PORT"] = "8081" # Different port to avoid conflicts try: from secure_api_server import app - + print("✅ Successfully imported secure_api_server") - + # Start server in background import threading + def run_server(): - app.run(host='0.0.0.0', port=8081, debug=False) - + # Use secure host binding for test server + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + ) + + host, port = get_secure_host_binding(8081) + validate_host_binding(host, port) + app.run(host=host, port=port, debug=False) + except ImportError: + # Fallback for test environment + app.run(host="127.0.0.1", port=8081, debug=False) + server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() - + # Wait for server to start print("🔄 Starting server...") time.sleep(3) - + # Test endpoints base_url = "http://localhost:8081" - + print("\n=== Testing Endpoints ===") - + # Test root endpoint try: response = requests.get(f"{base_url}/", timeout=5) print(f"✅ Root endpoint: {response.status_code} - {response.json()}") except Exception as e: print(f"❌ Root endpoint failed: {e}") - + # Test health endpoint try: response = requests.get(f"{base_url}/api/health", timeout=5) print(f"✅ Health endpoint: {response.status_code} - {response.json()}") except Exception as e: print(f"❌ Health endpoint failed: {e}") - + # Test docs endpoint try: response = requests.get(f"{base_url}/docs", timeout=5) print(f"✅ Docs endpoint: {response.status_code} - Content length: {len(response.text)}") except Exception as e: print(f"❌ Docs endpoint failed: {e}") - + print("\n✅ Server test completed!") - + except Exception as e: print(f"❌ Error testing server: {e}") import traceback - traceback.print_exc() \ No newline at end of file + + traceback.print_exc() diff --git a/deployment/cloud-run/test_swagger_debug.py b/deployment/cloud-run/test_swagger_debug.py index fdb5b3f40..18168a2c2 100644 --- a/deployment/cloud-run/test_swagger_debug.py +++ b/deployment/cloud-run/test_swagger_debug.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 -""" -Test script to debug Swagger docs 500 error -""" +"""Test script to debug Swagger docs 500 error""" import os from flask import Flask, jsonify @@ -13,36 +11,51 @@ # Initialize Flask-RESTX API api = Api( app, - version='1.0.0', - title='Test API', - description='Minimal test to isolate routing issues', - doc='/docs' + version="1.0.0", + title="Test API", + description="Minimal test to isolate routing issues", + doc="/docs", ) # Create namespace -main_ns = Namespace('/api', description='Main operations') +main_ns = Namespace("/api", description="Main operations") api.add_namespace(main_ns) + # Test endpoint in namespace -@main_ns.route('/health') +@main_ns.route("/health") class Health(Resource): def get(self): - return {'status': 'healthy'} + return {"status": "healthy"} + # Override the root route with a different endpoint name -@app.route('/') +@app.route("/") def api_root(): # Different function name to avoid conflict - return jsonify({'message': 'Root endpoint'}) + return jsonify({"message": "Root endpoint"}) + -if __name__ == '__main__': +if __name__ == "__main__": print("=== Routes ===") for rule in app.url_map.iter_rules(): print(f"{rule.rule} -> {rule.endpoint}") - + print("\n=== Starting test server ===") print("Test these endpoints:") print("- http://localhost:5001/ (should work)") print("- http://localhost:5001/docs (should work)") print("- http://localhost:5001/api/health (should work)") - - app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 5001)), debug=False) # Debug mode disabled for security \ No newline at end of file + + # Use secure host binding for test server + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + ) + + host, port = get_secure_host_binding(int(os.environ.get("PORT", 5001))) + validate_host_binding(host, port) + app.run(host=host, port=port, debug=False) + except ImportError: + # Fallback for test environment + app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 5001)), debug=False) diff --git a/deployment/cloud-run/test_swagger_debug_detailed.py b/deployment/cloud-run/test_swagger_debug_detailed.py index 0cb467f87..db9b9dce2 100644 --- a/deployment/cloud-run/test_swagger_debug_detailed.py +++ b/deployment/cloud-run/test_swagger_debug_detailed.py @@ -1,84 +1,94 @@ #!/usr/bin/env python3 -""" -Detailed test to capture Swagger docs 500 error -""" +"""Detailed test to capture Swagger docs 500 error""" import os import requests import traceback # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8084' +os.environ["ADMIN_API_KEY"] = "test-key-123" +os.environ["MAX_INPUT_LENGTH"] = "512" +os.environ["RATE_LIMIT_PER_MINUTE"] = "100" +os.environ["MODEL_PATH"] = "/app/model" +os.environ["PORT"] = "8084" try: from secure_api_server import app - + print("✅ Successfully imported secure_api_server") - + # Start server in background with error capture import threading import time - + def run_server(): try: - app.run(host='0.0.0.0', port=8084, debug=False) + # Use secure host binding for test server + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + ) + + host, port = get_secure_host_binding(8084) + validate_host_binding(host, port) + app.run(host=host, port=port, debug=False) + except ImportError: + # Fallback for test environment + app.run(host="127.0.0.1", port=8084, debug=False) except Exception as e: print(f"❌ Server error: {e}") traceback.print_exc() - + server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() - + # Wait for server to start print("🔄 Starting server...") time.sleep(3) - + # Test docs endpoint with detailed error capture base_url = "http://localhost:8084" - + print("\n=== Testing Docs Endpoint with Error Capture ===") - + try: # First test if server is responding response = requests.get(f"{base_url}/", timeout=5) print(f"✅ Root endpoint: {response.status_code}") - + # Test health endpoint response = requests.get(f"{base_url}/api/health", timeout=5) print(f"✅ Health endpoint: {response.status_code}") - + # Now test docs endpoint print("\n🔄 Testing /docs endpoint...") response = requests.get(f"{base_url}/docs", timeout=10) - + print(f"Status Code: {response.status_code}") print(f"Headers: {dict(response.headers)}") print(f"Content Type: {response.headers.get('content-type', 'unknown')}") print(f"Content Length: {len(response.text)}") - + if response.status_code == 500: print("\n❌ 500 Error Details:") print(f"Full Response: {response.text}") - + # Try to get more info by checking if it's a Flask error page if "Internal Server Error" in response.text: print("🔍 This is a Flask internal server error page") print("🔍 The actual error is likely in the server logs") - + elif response.status_code == 200: print("✅ Docs endpoint working!") print(f"Content preview: {response.text[:200]}...") - + except Exception as e: print(f"❌ Request failed: {e}") traceback.print_exc() - + print("\n✅ Docs test completed!") - + except Exception as e: print(f"❌ Error: {e}") - traceback.print_exc() \ No newline at end of file + traceback.print_exc() diff --git a/deployment/cloud-run/test_swagger_no_model.py b/deployment/cloud-run/test_swagger_no_model.py index 09b350a00..3d0d0ce68 100644 --- a/deployment/cloud-run/test_swagger_no_model.py +++ b/deployment/cloud-run/test_swagger_no_model.py @@ -1,55 +1,69 @@ #!/usr/bin/env python3 -""" -Test Swagger docs without model dependencies -""" +"""Test Swagger docs without model dependencies""" import os from flask import Flask, jsonify from flask_restx import Api, Resource, Namespace # Set required environment variables -os.environ['ADMIN_API_KEY'] = 'test-key-123' -os.environ['MAX_INPUT_LENGTH'] = '512' -os.environ['RATE_LIMIT_PER_MINUTE'] = '100' -os.environ['MODEL_PATH'] = '/app/model' -os.environ['PORT'] = '8083' +os.environ["ADMIN_API_KEY"] = "test-key-123" +os.environ["MAX_INPUT_LENGTH"] = "512" +os.environ["RATE_LIMIT_PER_MINUTE"] = "100" +os.environ["MODEL_PATH"] = "/app/model" +os.environ["PORT"] = "8083" # Create Flask app app = Flask(__name__) + # Register root endpoint first -@app.route('/') +@app.route("/") def home(): - return jsonify({'message': 'Root endpoint'}) + return jsonify({"message": "Root endpoint"}) + # Initialize Flask-RESTX API api = Api( app, - version='1.0.0', - title='Test API', - description='Test for Swagger docs issue', - doc='/docs' + version="1.0.0", + title="Test API", + description="Test for Swagger docs issue", + doc="/docs", ) # Create namespace -main_ns = Namespace('api', description='Main operations') +main_ns = Namespace("api", description="Main operations") api.add_namespace(main_ns) + # Test endpoint -@main_ns.route('/health') +@main_ns.route("/health") class Health(Resource): def get(self): - return {'status': 'healthy'} + return {"status": "healthy"} + -if __name__ == '__main__': +if __name__ == "__main__": print("=== Routes ===") for rule in app.url_map.iter_rules(): print(f"{rule.rule} -> {rule.endpoint}") - + print("\n=== Starting test server ===") print("Test these endpoints:") print("- http://localhost:8083/ (should work)") print("- http://localhost:8083/docs (should work)") print("- http://localhost:8083/api/health (should work)") - - app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8083)), debug=False) # Debug mode disabled for security \ No newline at end of file + + # Use secure host binding for test server + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + ) + + host, port = get_secure_host_binding(int(os.environ.get("PORT", 8083))) + validate_host_binding(host, port) + app.run(host=host, port=port, debug=False) + except ImportError: + # Fallback for test environment + app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 8083)), debug=False) diff --git a/deployment/docker/Dockerfile.optimized b/deployment/docker/Dockerfile.optimized index efa505091..82d25047f 100644 --- a/deployment/docker/Dockerfile.optimized +++ b/deployment/docker/Dockerfile.optimized @@ -14,11 +14,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ # Set working directory WORKDIR /app -# Install minimal system dependencies +# Install minimal system dependencies including audio processing ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ + ffmpeg \ + libsndfile1 \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean @@ -28,20 +30,23 @@ COPY deployment/docker/requirements-api-optimized.txt ./requirements.txt # Install Python dependencies with CPU-only PyTorch RUN pip install --no-cache-dir -r requirements.txt -# Copy the actual production code from the PRs -COPY deployment/cloud-run/secure_api_server.py . -COPY deployment/cloud-run/model_utils.py . -COPY deployment/cloud-run/security_headers.py . -COPY deployment/cloud-run/rate_limiter.py . - -# Pre-download the model during build to avoid OOM during startup -RUN mkdir -p /app/models && \ - python -c "from transformers import AutoTokenizer, AutoModelForSequenceClassification; \ - model_name='j-hartmann/emotion-english-distilroberta-base'; \ - print(f'Pre-downloading model {model_name}...'); \ - AutoTokenizer.from_pretrained(model_name, cache_dir='/app/models'); \ - AutoModelForSequenceClassification.from_pretrained(model_name, cache_dir='/app/models'); \ - print('Model pre-downloaded successfully');" +# Create models directory and set environment early +RUN mkdir -p /app/models +ENV HF_HOME=/app/models \ + TRANSFORMERS_CACHE=/app/models + +# Copy ONLY the model download script first (separate from main source code) +# This allows model downloading to be cached independently from code changes +COPY scripts/pre_download_models.py ./scripts/ + +# Pre-download models in a separate layer for optimal caching +# This layer will only invalidate if pre_download_models.py changes +RUN python scripts/pre_download_models.py + +# Copy the rest of the scripts and source code +# Changes to main source code won't invalidate the model download cache +COPY scripts/ ./scripts/ +COPY src/ ./src/ # Create non-root user for security (Cloud Run best practice) RUN useradd -m -u 1000 appuser && \ @@ -55,9 +60,8 @@ EXPOSE 8080 # Health check following Cloud Run best practices HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD curl -f http://localhost:8080/api/health || exit 1 + CMD curl -f http://localhost:8080/health || exit 1 # Use exec form for CMD (Docker best practice) -# Set timeout to 0 for Cloud Run (allows unlimited request timeouts) -# Run the production Flask-RESTX server -CMD ["sh", "-c", "exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 300 --keep-alive 5 --max-requests 1000 --max-requests-jitter 100 --access-logfile - --error-logfile - --log-level info secure_api_server:app"] \ No newline at end of file +# Run the unified SAMO API with FastAPI/Uvicorn +CMD ["sh", "-c", "exec python -m uvicorn src.unified_ai_api:app --host 0.0.0.0 --port $PORT --workers 1"] \ No newline at end of file diff --git a/deployment/docker/requirements-api-optimized.txt b/deployment/docker/requirements-api-optimized.txt index de64baf3e..fed5259a1 100644 --- a/deployment/docker/requirements-api-optimized.txt +++ b/deployment/docker/requirements-api-optimized.txt @@ -7,9 +7,15 @@ --extra-index-url https://download.pytorch.org/whl/cpu # Core API dependencies +FastAPI>=0.104.0,<1.0.0 +uvicorn[standard]>=0.24.0,<1.0.0 +pydantic>=2.0.0,<3.0.0 +python-multipart>=0.0.6 +PyJWT==2.8.0 + +# Legacy Flask support for proxy server Flask>=3.1.1,<4.0.0 flask-restx==1.3.0 -PyJWT==2.8.0 # Utilities python-dotenv==1.0.1 @@ -31,10 +37,16 @@ torch==2.8.0+cpu # OPTIMIZED: Minimal transformers for inference only transformers==4.55.0 +protobuf>=3.20.0 +sentencepiece>=0.1.96 # OPTIMIZED: Only essential scientific computing numpy>=1.24.0,<2.0.0 scipy==1.13.1 +# Voice processing dependencies +openai-whisper>=20240930 +librosa>=0.10.0 + # OPTIMIZED: Remove unnecessary ML training dependencies # (No datasets, accelerate, onnx, etc. - only inference needed) \ No newline at end of file diff --git a/deployment/gcp/predict.py b/deployment/gcp/predict.py index 73fc60bff..5978160d8 100644 --- a/deployment/gcp/predict.py +++ b/deployment/gcp/predict.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Vertex AI Custom Container Prediction Server +"""Vertex AI Custom Container Prediction Server =========================================== This script runs a Flask server for the emotion detection model on Vertex AI. @@ -13,145 +12,265 @@ app = Flask(__name__) + class EmotionDetectionModel: def __init__(self): """Initialize the model.""" self.model_path = os.path.join(os.getcwd(), "model") print(f"Loading model from: {self.model_path}") - + try: self.tokenizer = AutoTokenizer.from_pretrained(self.model_path) self.model = AutoModelForSequenceClassification.from_pretrained(self.model_path) - - # Move to GPU if available - if torch.cuda.is_available(): - self.model = self.model.to('cuda') + + # Set device once and move model + self.device = "cuda" if torch.cuda.is_available() else "cpu" + self.model = self.model.to(self.device) + self.model.eval() # Set to evaluation mode + + if self.device == "cuda": print("✅ Model moved to GPU") else: print("⚠️ CUDA not available, using CPU") - - self.emotions = ['anxious', 'calm', 'content', 'excited', 'frustrated', 'grateful', 'happy', 'hopeful', 'overwhelmed', 'proud', 'sad', 'tired'] + + # Load emotions from model config + self.emotions = self._load_emotion_labels() print("✅ Model loaded successfully") - + except Exception as e: - print(f"❌ Failed to load model: {str(e)}") + print(f"❌ Failed to load model: {e!s}") raise - + + def _load_emotion_labels(self): + """Load emotion labels from model config.""" + try: + # Try to get labels from model config + if hasattr(self.model.config, "id2label") and self.model.config.id2label: + # Convert id2label dict to ordered list + max_id = max(self.model.config.id2label.keys()) + labels = [ + self.model.config.id2label.get(i, f"unknown_{i}") for i in range(max_id + 1) + ] + return labels + if hasattr(self.model.config, "label2id") and self.model.config.label2id: + # Convert label2id dict to ordered list + labels = sorted( + self.model.config.label2id.keys(), + key=lambda x: self.model.config.label2id[x], + ) + return labels + # Fallback to hardcoded list if config doesn't have labels + print("⚠️ No emotion labels found in model config, using fallback") + return [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", + ] + except Exception as e: + print(f"⚠️ Error loading emotion labels: {e}, using fallback") + return [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", + ] + + def _get_emotion_label(self, label_id): + """Get emotion label for a given label ID.""" + try: + # Try to get from model config first + if hasattr(self.model.config, "id2label") and self.model.config.id2label: + # Handle both int and str keys + if label_id in self.model.config.id2label: + return self.model.config.id2label[label_id] + if str(label_id) in self.model.config.id2label: + return self.model.config.id2label[str(label_id)] + + # Fallback to emotions list if available + if hasattr(self, "emotions") and 0 <= label_id < len(self.emotions): + return self.emotions[label_id] + + # Final fallback + return f"unknown_{label_id}" + except Exception: + return f"unknown_{label_id}" + def predict(self, text): """Make a prediction.""" try: # Tokenize input - inputs = self.tokenizer(text, return_tensors='pt', truncation=True, padding=True, max_length=512) - - if torch.cuda.is_available(): - inputs = {k: v.to('cuda') for k, v in inputs.items()} - + inputs = self.tokenizer( + text, return_tensors="pt", truncation=True, padding=True, max_length=512 + ) + + # Move inputs to the same device as the model + inputs = {k: v.to(self.device) for k, v in inputs.items()} + # Get prediction with torch.no_grad(): outputs = self.model(**inputs) probabilities = torch.softmax(outputs.logits, dim=1) predicted_label = torch.argmax(probabilities, dim=1).item() confidence = probabilities[0][predicted_label].item() - + # Get all probabilities all_probs = probabilities[0].cpu().numpy() - - # Get predicted emotion - if predicted_label in self.model.config.id2label: - predicted_emotion = self.model.config.id2label[predicted_label] - elif str(predicted_label) in self.model.config.id2label: - predicted_emotion = self.model.config.id2label[str(predicted_label)] - else: - predicted_emotion = f"unknown_{predicted_label}" - + + # Get predicted emotion using proper mapping + predicted_emotion = self._get_emotion_label(predicted_label) + # Create response response = { - 'text': text, - 'predicted_emotion': predicted_emotion, - 'confidence': float(confidence), - 'probabilities': { - emotion: float(prob) for emotion, prob in zip(self.emotions, all_probs) + "text": text, + "predicted_emotion": predicted_emotion, + "confidence": float(confidence), + "probabilities": { + self._get_emotion_label(i): float(prob) for i, prob in enumerate(all_probs) + }, + "model_version": "2.0", + "model_type": "comprehensive_emotion_detection", + "performance": { + "basic_accuracy": "100.00%", + "real_world_accuracy": "93.75%", + "average_confidence": "83.9%", }, - 'model_version': '2.0', - 'model_type': 'comprehensive_emotion_detection', - 'performance': { - 'basic_accuracy': '100.00%', - 'real_world_accuracy': '93.75%', - 'average_confidence': '83.9%' - } } - + return response - + except Exception as e: - print(f"Prediction error: {str(e)}") + print(f"Prediction error: {e!s}") raise + # Initialize model print("🔧 Loading emotion detection model...") model = EmotionDetectionModel() -@app.route('/health', methods=['GET']) + +@app.route("/health", methods=["GET"]) def health_check(): """Health check endpoint.""" - return jsonify({ - 'status': 'healthy', - 'model_version': '2.0', - 'model_type': 'comprehensive_emotion_detection' - }) + return jsonify( + { + "status": "healthy", + "model_version": "2.0", + "model_type": "comprehensive_emotion_detection", + } + ) -@app.route('/predict', methods=['POST']) + +@app.route("/predict", methods=["POST"]) def predict(): """Prediction endpoint.""" try: data = request.get_json() - - if not data or 'text' not in data: - return jsonify({'error': 'No text provided'}), 400 - - text = data['text'] + + if not data or "text" not in data: + return jsonify({"error": "No text provided"}), 400 + + text = data["text"] if not text.strip(): - return jsonify({'error': 'Empty text provided'}), 400 - + return jsonify({"error": "Empty text provided"}), 400 + # Make prediction result = model.predict(text) - + return jsonify(result) - - except Exception as e: - print(f"Prediction endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 -@app.route('/', methods=['GET']) + except Exception: + import logging + + logger = logging.getLogger(__name__) + logger.exception("Prediction endpoint error") + return jsonify({"error": "Prediction failed"}), 500 + + +@app.route("/", methods=["GET"]) def home(): """Home endpoint.""" - return jsonify({ - 'message': 'Comprehensive Emotion Detection API', - 'version': '2.0', - 'endpoints': { - 'GET /': 'This documentation', - 'GET /health': 'Health check', - 'POST /predict': 'Single prediction (send {"text": "your text"})' - }, - 'model_info': { - 'emotions': model.emotions, - 'performance': { - 'basic_accuracy': '100.00%', - 'real_world_accuracy': '93.75%', - 'average_confidence': '83.9%' - } + return jsonify( + { + "message": "Comprehensive Emotion Detection API", + "version": "2.0", + "endpoints": { + "GET /": "This documentation", + "GET /health": "Health check", + "POST /predict": 'Single prediction (send {"text": "your text"})', + }, + "model_info": { + "emotions": model.emotions, + "performance": { + "basic_accuracy": "100.00%", + "real_world_accuracy": "93.75%", + "average_confidence": "83.9%", + }, + }, } - }) + ) + -if __name__ == '__main__': +if __name__ == "__main__": print("🌐 Starting Vertex AI prediction server...") print("📋 Available endpoints:") print(" GET / - API documentation") print(" GET /health - Health check") print(" POST /predict - Single prediction") print("") - print("🚀 Server starting on http://0.0.0.0:8080") + + # Try to use centralized security-first host binding configuration + try: + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + get_binding_security_summary, + ) + + host, port = get_secure_host_binding(default_port=8080) + validate_host_binding(host, port) + security_summary = get_binding_security_summary(host, port) + print(f"Security Summary: {security_summary}") + + except ImportError: + # Fallback for container environments where host_binding module is not available + print("⚠️ Host binding module not available, using fallback configuration") + + # Use environment-based host detection with security annotations + host = os.environ.get("HOST", "127.0.0.1") # Default to localhost for security + + # Google Cloud Run/Vertex AI requires binding to all interfaces + if os.environ.get("AIP_HTTP_PORT") or os.environ.get("K_SERVICE"): + host = "0.0.0.0" # nosec B104 - required for Google Cloud containerized environments + print("🔒 Cloud container environment detected: binding to all interfaces") + print("🛡️ Ensure proper network security and firewall rules are in place") + + port = int(os.environ.get("AIP_HTTP_PORT", os.environ.get("PORT", "8080"))) + security_summary = ( + f"Fallback mode: host={host}, port={port} " + f"(AIP_HTTP_PORT={os.environ.get('AIP_HTTP_PORT', 'not set')}, " + f"K_SERVICE={os.environ.get('K_SERVICE', 'not set')})" + ) + print(f"Security Summary: {security_summary}") + + print(f"🚀 Server starting on http://{host}:{port}") print("") - + # Run the Flask app - app.run(host='0.0.0.0', port=8080, debug=False) + app.run(host=host, port=int(port), debug=False) diff --git a/deployment/inference.py b/deployment/inference.py index 430f45042..48d18f3eb 100644 --- a/deployment/inference.py +++ b/deployment/inference.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -EMOTION DETECTION INFERENCE SCRIPT +"""EMOTION DETECTION INFERENCE SCRIPT ===================================== Standalone script to run emotion detection on text. """ @@ -9,50 +8,62 @@ from transformers import AutoTokenizer, AutoModelForSequenceClassification from pathlib import Path + class EmotionDetector: def __init__(self, model_path=None): """Initialize the emotion detector""" if model_path is None: # Use the model directory relative to this script model_path = Path(__file__).parent / "model" - - self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') - + + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + print(f"🔧 Loading model from: {model_path}") - + # Load model and tokenizer self.tokenizer = AutoTokenizer.from_pretrained("roberta-base") self.model = AutoModelForSequenceClassification.from_pretrained(str(model_path)) self.model.to(self.device) self.model.eval() - + # Define emotion mapping based on training order - self.emotion_mapping = ['anxious', 'calm', 'content', 'excited', 'frustrated', 'grateful', 'happy', 'hopeful', 'overwhelmed', 'proud', 'sad', 'tired'] - + self.emotion_mapping = [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", + ] + print(f"✅ Model loaded successfully on {self.device}") - + def predict(self, text): """Predict emotion for given text""" # Tokenize - inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=512, padding=True) + inputs = self.tokenizer( + text, return_tensors="pt", truncation=True, max_length=512, padding=True + ) inputs = {k: v.to(self.device) for k, v in inputs.items()} - + # Predict with torch.no_grad(): outputs = self.model(**inputs) probabilities = torch.softmax(outputs.logits, dim=1) predicted_class = torch.argmax(probabilities, dim=1).item() confidence = probabilities[0][predicted_class].item() - + # Map to emotion name emotion = self.emotion_mapping[predicted_class] - - return { - "emotion": emotion, - "confidence": confidence, - "text": text - } - + + return {"emotion": emotion, "confidence": confidence, "text": text} + def predict_batch(self, texts): """Predict emotions for multiple texts""" results = [] @@ -61,28 +72,30 @@ def predict_batch(self, texts): results.append(result) return results + def main(): """Main function for command line usage""" import sys - + if len(sys.argv) < 2: print("Usage: python inference.py 'Your text here'") print("Example: python inference.py 'I am feeling happy today!'") return - + text = sys.argv[1] - + # Initialize detector detector = EmotionDetector() - + # Make prediction result = detector.predict(text) - - print(f"\n🎯 EMOTION DETECTION RESULT") - print(f"=" * 40) + + print("\n🎯 EMOTION DETECTION RESULT") + print("=" * 40) print(f"Text: {result['text']}") print(f"Emotion: {result['emotion']}") print(f"Confidence: {result['confidence']:.3f}") + if __name__ == "__main__": main() diff --git a/deployment/local/api_server.py b/deployment/local/api_server.py index 56224e566..ceb6157f8 100644 --- a/deployment/local/api_server.py +++ b/deployment/local/api_server.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Local Emotion Detection API Server +"""Local Emotion Detection API Server ================================= A production-ready Flask API server with monitoring, logging, @@ -27,11 +26,8 @@ # Configure logging after all imports logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('api_server.log'), - logging.StreamHandler() - ] + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("api_server.log"), logging.StreamHandler()], ) logger = logging.getLogger(__name__) @@ -48,349 +44,464 @@ # Monitoring metrics metrics = { - 'total_requests': 0, - 'successful_requests': 0, - 'failed_requests': 0, - 'average_response_time': 0.0, - 'response_times': deque(maxlen=1000), - 'emotion_distribution': defaultdict(int), - 'error_counts': defaultdict(int), - 'start_time': datetime.now() + "total_requests": 0, + "successful_requests": 0, + "failed_requests": 0, + "average_response_time": 0.0, + "response_times": deque(maxlen=1000), + "emotion_distribution": defaultdict(int), + "error_counts": defaultdict(int), + "start_time": datetime.now(), } metrics_lock = threading.Lock() + def rate_limit(f): """Rate limiting decorator.""" + @wraps(f) def decorated_function(*args, **kwargs): client_ip = request.remote_addr current_time = time.time() - + with rate_limit_lock: # Clean old requests - while rate_limit_data[client_ip] and current_time - rate_limit_data[client_ip][0] > RATE_LIMIT_WINDOW: + while ( + rate_limit_data[client_ip] + and current_time - rate_limit_data[client_ip][0] > RATE_LIMIT_WINDOW + ): rate_limit_data[client_ip].popleft() - + # Check rate limit if len(rate_limit_data[client_ip]) >= RATE_LIMIT_MAX_REQUESTS: logger.warning(f"Rate limit exceeded for IP: {client_ip}") - return jsonify({ - 'error': 'Rate limit exceeded', - 'message': f'Maximum {RATE_LIMIT_MAX_REQUESTS} requests per {RATE_LIMIT_WINDOW} seconds' - }), 429 - + return ( + jsonify( + { + "error": "Rate limit exceeded", + "message": ( + f"Maximum {RATE_LIMIT_MAX_REQUESTS} requests per " + f"{RATE_LIMIT_WINDOW} seconds" + ), + } + ), + 429, + ) + # Add current request rate_limit_data[client_ip].append(current_time) - + return f(*args, **kwargs) + return decorated_function + def update_metrics(response_time, success=True, emotion=None, error_type=None): """Update monitoring metrics.""" with metrics_lock: - metrics['total_requests'] += 1 - metrics['response_times'].append(response_time) - + metrics["total_requests"] += 1 + metrics["response_times"].append(response_time) + if success: - metrics['successful_requests'] += 1 + metrics["successful_requests"] += 1 if emotion: - metrics['emotion_distribution'][emotion] += 1 + metrics["emotion_distribution"][emotion] += 1 else: - metrics['failed_requests'] += 1 + metrics["failed_requests"] += 1 if error_type: - metrics['error_counts'][error_type] += 1 - + metrics["error_counts"][error_type] += 1 + # Update average response time - if metrics['response_times']: - metrics['average_response_time'] = sum(metrics['response_times']) / len(metrics['response_times']) + if metrics["response_times"]: + metrics["average_response_time"] = sum(metrics["response_times"]) / len( + metrics["response_times"] + ) + class EmotionDetectionModel: def __init__(self): """Initialize the model.""" self.model_path = os.path.join(os.getcwd(), "model") logger.info(f"Loading model from: {self.model_path}") - + try: self.tokenizer = AutoTokenizer.from_pretrained(self.model_path) self.model = AutoModelForSequenceClassification.from_pretrained(self.model_path) - + # Move to GPU if available if torch.cuda.is_available(): - self.model = self.model.to('cuda') + self.model = self.model.to("cuda") logger.info("✅ Model moved to GPU") else: logger.info("⚠️ CUDA not available, using CPU") - - self.emotions = ['anxious', 'calm', 'content', 'excited', 'frustrated', 'grateful', 'happy', 'hopeful', 'overwhelmed', 'proud', 'sad', 'tired'] + + # Derive emotions from model config + self.emotions = self._load_emotion_labels() logger.info("✅ Model loaded successfully") - + except Exception as e: - logger.error(f"❌ Failed to load model: {str(e)}") + logger.error(f"❌ Failed to load model: {e!s}") raise - + + def _load_emotion_labels(self): + """Load emotion labels from model config.""" + try: + # Try to get labels from model config + if hasattr(self.model.config, "id2label") and self.model.config.id2label: + # Convert id2label dict to ordered list + max_id = max(self.model.config.id2label.keys()) + labels = [ + self.model.config.id2label.get(i, f"unknown_{i}") for i in range(max_id + 1) + ] + logger.info(f"📊 Loaded {len(labels)} emotions from model config: {labels}") + return labels + if hasattr(self.model.config, "label2id") and self.model.config.label2id: + # Convert label2id dict to ordered list + labels = sorted( + self.model.config.label2id.keys(), + key=lambda x: self.model.config.label2id[x], + ) + logger.info(f"📊 Loaded {len(labels)} emotions from model config: {labels}") + return labels + # Fallback to hardcoded list if config doesn't have labels + logger.warning("⚠️ No emotion labels found in model config, using fallback") + return [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", + ] + except Exception as e: + logger.warning(f"⚠️ Error loading emotion labels: {e}, using fallback") + return [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", + ] + def predict(self, text): """Make a prediction.""" start_time = time.time() - + try: # Tokenize input - inputs = self.tokenizer(text, return_tensors='pt', truncation=True, padding=True, max_length=512) - + inputs = self.tokenizer( + text, return_tensors="pt", truncation=True, padding=True, max_length=512 + ) + if torch.cuda.is_available(): - inputs = {k: v.to('cuda') for k, v in inputs.items()} - + inputs = {k: v.to("cuda") for k, v in inputs.items()} + # Get prediction with torch.no_grad(): outputs = self.model(**inputs) probabilities = torch.softmax(outputs.logits, dim=1) predicted_label = torch.argmax(probabilities, dim=1).item() confidence = probabilities[0][predicted_label].item() - + # Get all probabilities all_probs = probabilities[0].cpu().numpy() - - # Get predicted emotion - if predicted_label in self.model.config.id2label: - predicted_emotion = self.model.config.id2label[predicted_label] - elif str(predicted_label) in self.model.config.id2label: - predicted_emotion = self.model.config.id2label[str(predicted_label)] + + # Get predicted emotion using derived emotions list + if 0 <= predicted_label < len(self.emotions): + predicted_emotion = self.emotions[predicted_label] else: predicted_emotion = f"unknown_{predicted_label}" - + prediction_time = time.time() - start_time - logger.info(f"Prediction completed in {prediction_time:.3f}s: '{text[:50]}...' → {predicted_emotion} (conf: {confidence:.3f})") - + # Log text length and hash instead of raw content to avoid PII exposure + import hashlib + + text_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8] + logger.info( + "Prediction completed in %.3fs: text_len=%d, text_hash=%s → %s (conf: %.3f)", + prediction_time, + len(text), + text_hash, + predicted_emotion, + confidence, + ) + # Create response response = { - 'text': text, - 'predicted_emotion': predicted_emotion, - 'confidence': float(confidence), - 'probabilities': { + "text": text, + "predicted_emotion": predicted_emotion, + "confidence": float(confidence), + "probabilities": { emotion: float(prob) for emotion, prob in zip(self.emotions, all_probs) }, - 'model_version': '2.0', - 'model_type': 'comprehensive_emotion_detection', - 'performance': { - 'basic_accuracy': '100.00%', - 'real_world_accuracy': '93.75%', - 'average_confidence': '83.9%' + "model_version": "2.0", + "model_type": "comprehensive_emotion_detection", + "performance": { + "basic_accuracy": "100.00%", + "real_world_accuracy": "93.75%", + "average_confidence": "83.9%", }, - 'prediction_time_ms': round(prediction_time * 1000, 2) + "prediction_time_ms": round(prediction_time * 1000, 2), } - + return response - + except Exception as e: prediction_time = time.time() - start_time - logger.error(f"Prediction failed after {prediction_time:.3f}s: {str(e)}") + logger.error(f"Prediction failed after {prediction_time:.3f}s: {e!s}") raise + # Initialize model logger.info("🔧 Loading emotion detection model...") model = EmotionDetectionModel() -@app.route('/health', methods=['GET']) + +@app.route("/health", methods=["GET"]) @rate_limit def health_check(): """Health check endpoint.""" start_time = time.time() - + try: response = { - 'status': 'healthy', - 'model_loaded': True, - 'model_version': '2.0', - 'emotions': model.emotions, - 'uptime_seconds': (datetime.now() - metrics['start_time']).total_seconds(), - 'metrics': { - 'total_requests': metrics['total_requests'], - 'successful_requests': metrics['successful_requests'], - 'failed_requests': metrics['failed_requests'], - 'average_response_time_ms': round(metrics['average_response_time'] * 1000, 2) - } + "status": "healthy", + "model_loaded": True, + "model_version": "2.0", + "emotions": model.emotions, + "uptime_seconds": (datetime.now() - metrics["start_time"]).total_seconds(), + "metrics": { + "total_requests": metrics["total_requests"], + "successful_requests": metrics["successful_requests"], + "failed_requests": metrics["failed_requests"], + "average_response_time_ms": round(metrics["average_response_time"] * 1000, 2), + }, } - + response_time = time.time() - start_time update_metrics(response_time, success=True) - + return jsonify(response) - - except Exception as e: + + except Exception: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='health_check_error') - logger.error(f"Health check failed: {str(e)}") - return jsonify({'error': str(e)}), 500 + update_metrics(response_time, success=False, error_type="health_check_error") + logger.exception("Health check failed") + return jsonify({"error": "Health check failed"}), 500 + -@app.route('/predict', methods=['POST']) +@app.route("/predict", methods=["POST"]) @rate_limit def predict(): """Prediction endpoint.""" start_time = time.time() - + try: data = request.get_json() - - if not data or 'text' not in data: + + if not data or "text" not in data: + response_time = time.time() - start_time + update_metrics(response_time, success=False, error_type="missing_text") + return jsonify({"error": "No text provided"}), 400 + + text = data["text"] + + # Validate text type and content + if not isinstance(text, str): response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='missing_text') - return jsonify({'error': 'No text provided'}), 400 - - text = data['text'] + update_metrics(response_time, success=False, error_type="invalid_text_type") + return jsonify({"error": "Text must be a string"}), 400 + if not text.strip(): response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='empty_text') - return jsonify({'error': 'Empty text provided'}), 400 - + update_metrics(response_time, success=False, error_type="empty_text") + return jsonify({"error": "Empty text provided"}), 400 + # Make prediction result = model.predict(text) - + response_time = time.time() - start_time - update_metrics(response_time, success=True, emotion=result['predicted_emotion']) - + update_metrics(response_time, success=True, emotion=result["predicted_emotion"]) + return jsonify(result) - + except werkzeug.exceptions.BadRequest: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='invalid_json') - logger.error(f"Invalid JSON in request") - return jsonify({'error': 'Invalid JSON format'}), 400 - except Exception as e: + update_metrics(response_time, success=False, error_type="invalid_json") + logger.error("Invalid JSON in request") + return jsonify({"error": "Invalid JSON format"}), 400 + except Exception: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='prediction_error') - logger.error(f"Prediction endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 + update_metrics(response_time, success=False, error_type="prediction_error") + logger.exception("Prediction endpoint error") + return jsonify({"error": "Prediction failed"}), 500 -@app.route('/predict_batch', methods=['POST']) + +@app.route("/predict_batch", methods=["POST"]) @rate_limit def predict_batch(): """Batch prediction endpoint.""" start_time = time.time() - + try: data = request.get_json() - - if not data or 'texts' not in data: + + if not data or "texts" not in data: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='missing_texts') - return jsonify({'error': 'No texts provided'}), 400 - - texts = data['texts'] + update_metrics(response_time, success=False, error_type="missing_texts") + return jsonify({"error": "No texts provided"}), 400 + + texts = data["texts"] if not isinstance(texts, list): response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='invalid_texts_format') - return jsonify({'error': 'Texts must be a list'}), 400 - + update_metrics(response_time, success=False, error_type="invalid_texts_format") + return jsonify({"error": "Texts must be a list"}), 400 + results = [] for text in texts: - if text.strip(): - result = model.predict(text) + # Validate text type and content + if not isinstance(text, str): + continue # Skip non-string items + + cleaned_text = text.strip() + if cleaned_text: # Only process non-empty strings + result = model.predict(cleaned_text) results.append(result) - + response_time = time.time() - start_time update_metrics(response_time, success=True) - - return jsonify({ - 'predictions': results, - 'count': len(results), - 'batch_processing_time_ms': round(response_time * 1000, 2) - }) - + + return jsonify( + { + "predictions": results, + "count": len(results), + "batch_processing_time_ms": round(response_time * 1000, 2), + } + ) + except werkzeug.exceptions.BadRequest: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='invalid_json') - logger.error(f"Invalid JSON in batch request") - return jsonify({'error': 'Invalid JSON format'}), 400 - except Exception as e: + update_metrics(response_time, success=False, error_type="invalid_json") + logger.error("Invalid JSON in batch request") + return jsonify({"error": "Invalid JSON format"}), 400 + except Exception: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='batch_prediction_error') - logger.error(f"Batch prediction endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 + update_metrics(response_time, success=False, error_type="batch_prediction_error") + logger.exception("Batch prediction endpoint error") + return jsonify({"error": "Batch prediction failed"}), 500 -@app.route('/metrics', methods=['GET']) + +@app.route("/metrics", methods=["GET"]) def get_metrics(): """Get detailed metrics endpoint.""" with metrics_lock: - return jsonify({ - 'server_metrics': { - 'uptime_seconds': (datetime.now() - metrics['start_time']).total_seconds(), - 'total_requests': metrics['total_requests'], - 'successful_requests': metrics['successful_requests'], - 'failed_requests': metrics['failed_requests'], - 'success_rate': f"{(metrics['successful_requests'] / max(metrics['total_requests'], 1)) * 100:.2f}%", - 'average_response_time_ms': round(metrics['average_response_time'] * 1000, 2), - 'requests_per_minute': metrics['total_requests'] / max((datetime.now() - metrics['start_time']).total_seconds() / 60, 1) - }, - 'emotion_distribution': dict(metrics['emotion_distribution']), - 'error_counts': dict(metrics['error_counts']), - 'rate_limiting': { - 'window_seconds': RATE_LIMIT_WINDOW, - 'max_requests': RATE_LIMIT_MAX_REQUESTS + return jsonify( + { + "server_metrics": { + "uptime_seconds": (datetime.now() - metrics["start_time"]).total_seconds(), + "total_requests": metrics["total_requests"], + "successful_requests": metrics["successful_requests"], + "failed_requests": metrics["failed_requests"], + "success_rate": f"{(metrics['successful_requests'] / max(metrics['total_requests'], 1)) * 100:.2f}%", + "average_response_time_ms": round(metrics["average_response_time"] * 1000, 2), + "requests_per_minute": ( + metrics["total_requests"] + / max( + (datetime.now() - metrics["start_time"]).total_seconds() / 60, + 1, + ) + ), + }, + "emotion_distribution": dict(metrics["emotion_distribution"]), + "error_counts": dict(metrics["error_counts"]), + "rate_limiting": { + "window_seconds": RATE_LIMIT_WINDOW, + "max_requests": RATE_LIMIT_MAX_REQUESTS, + }, } - }) + ) + -@app.route('/', methods=['GET']) +@app.route("/", methods=["GET"]) @rate_limit def home(): """Home endpoint with API documentation.""" start_time = time.time() - + try: response = { - 'message': 'Comprehensive Emotion Detection API', - 'version': '2.0', - 'endpoints': { - 'GET /': 'This documentation', - 'GET /health': 'Health check with basic metrics', - 'GET /metrics': 'Detailed server metrics', - 'POST /predict': 'Single prediction (send {"text": "your text"})', - 'POST /predict_batch': 'Batch prediction (send {"texts": ["text1", "text2"]})' + "message": "Comprehensive Emotion Detection API", + "version": "2.0", + "endpoints": { + "GET /": "This documentation", + "GET /health": "Health check with basic metrics", + "GET /metrics": "Detailed server metrics", + "POST /predict": 'Single prediction (send {"text": "your text"})', + "POST /predict_batch": ('Batch prediction (send {"texts": ["text1", "text2"]})'), }, - 'model_info': { - 'emotions': model.emotions, - 'performance': { - 'basic_accuracy': '100.00%', - 'real_world_accuracy': '93.75%', - 'average_confidence': '83.9%' - } + "model_info": { + "emotions": model.emotions, + "performance": { + "basic_accuracy": "100.00%", + "real_world_accuracy": "93.75%", + "average_confidence": "83.9%", + }, }, - 'features': { - 'rate_limiting': f'{RATE_LIMIT_MAX_REQUESTS} requests per {RATE_LIMIT_WINDOW} seconds', - 'monitoring': 'Comprehensive metrics and logging', - 'batch_processing': 'Efficient batch predictions', - 'error_handling': 'Robust error handling and reporting' + "features": { + "rate_limiting": ( + f"{RATE_LIMIT_MAX_REQUESTS} requests per {RATE_LIMIT_WINDOW} seconds" + ), + "monitoring": "Comprehensive metrics and logging", + "batch_processing": "Efficient batch predictions", + "error_handling": "Robust error handling and reporting", }, - 'example_usage': { - 'single_prediction': { - 'url': 'POST /predict', - 'body': '{"text": "I am feeling happy today!"}' + "example_usage": { + "single_prediction": { + "url": "POST /predict", + "body": '{"text": "I am feeling happy today!"}', }, - 'batch_prediction': { - 'url': 'POST /predict_batch', - 'body': '{"texts": ["I am happy", "I feel sad", "I am excited"]}' - } - } + "batch_prediction": { + "url": "POST /predict_batch", + "body": '{"texts": ["I am happy", "I feel sad", "I am excited"]}', + }, + }, } - + response_time = time.time() - start_time update_metrics(response_time, success=True) - + return jsonify(response) - - except Exception as e: + + except Exception: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='documentation_error') - logger.error(f"Documentation endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 + update_metrics(response_time, success=False, error_type="documentation_error") + logger.exception("Documentation endpoint error") + return jsonify({"error": "Documentation service unavailable"}), 500 + @app.errorhandler(werkzeug.exceptions.BadRequest) def handle_bad_request(e): """Handle BadRequest exceptions (invalid JSON, etc.).""" - logger.error(f"BadRequest error: {str(e)}") - update_metrics(0.0, success=False, error_type='invalid_json') - return jsonify({'error': 'Invalid JSON format'}), 400 + logger.error(f"BadRequest error: {e!s}") + update_metrics(0.0, success=False, error_type="invalid_json") + return jsonify({"error": "Invalid JSON format"}), 400 + -if __name__ == '__main__': +if __name__ == "__main__": logger.info("🌐 Starting enhanced local API server...") logger.info("📋 Available endpoints:") logger.info(" GET / - API documentation") @@ -403,10 +514,25 @@ def handle_bad_request(e): logger.info("📝 Example usage:") logger.info(" curl -X POST http://localhost:8000/predict \\") logger.info(" -H 'Content-Type: application/json' \\") - logger.info(" -d '{\"text\": \"I am feeling happy today!\"}'") + logger.info(' -d \'{"text": "I am feeling happy today!"}\'') logger.info("") - logger.info(f"🔒 Rate limiting: {RATE_LIMIT_MAX_REQUESTS} requests per {RATE_LIMIT_WINDOW} seconds") + logger.info( + f"🔒 Rate limiting: {RATE_LIMIT_MAX_REQUESTS} requests per {RATE_LIMIT_WINDOW} seconds" + ) logger.info("📊 Monitoring: Comprehensive metrics and logging enabled") logger.info("") - - app.run(host='0.0.0.0', port=8000, debug=False) + + # Use centralized security-first host binding configuration + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + get_binding_security_summary, + ) + + host, port = get_secure_host_binding(default_port=8000) + validate_host_binding(host, port) + + security_summary = get_binding_security_summary(host, port) + logger.info("Security Summary: %s", security_summary) + + app.run(host=host, port=port, debug=False) diff --git a/deployment/local/requirements-simple.txt b/deployment/local/requirements-simple.txt new file mode 100644 index 000000000..d7d8e27ee --- /dev/null +++ b/deployment/local/requirements-simple.txt @@ -0,0 +1,3 @@ +flask==3.0.3 +flask-cors==5.0.0 +requests==2.32.4 \ No newline at end of file diff --git a/deployment/local/simple_server.py b/deployment/local/simple_server.py new file mode 100644 index 000000000..929182afd --- /dev/null +++ b/deployment/local/simple_server.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Simple Web Server for Local Development +====================================== + +A lightweight Flask server that serves static website files with CORS enabled +for testing against deployed Cloud Run APIs. +""" + +import sys +import os +import argparse +from pathlib import Path +from flask import Flask, send_from_directory, jsonify +from flask_cors import CORS + +# Get the project root directory (two levels up from this script) +# Script is at deployment/local/simple_server.py, so we go up 2 levels to get to project root +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +WEBSITE_DIR = PROJECT_ROOT / "website" + + +app = Flask(__name__) + +# Configure CORS based on environment +is_production = os.getenv("ENV", "").lower() == "prod" +allowed_origins = os.getenv("ALLOWED_ORIGINS", "") + +if is_production: + # Production: Use environment variable or default to localhost regex + if allowed_origins: + origins = [origin.strip() for origin in allowed_origins.split(",")] + else: + # Default production origins - only allow specific localhost ports + origins = [ + "http://localhost:3000", + "http://localhost:5000", + "http://127.0.0.1:3000", + "http://127.0.0.1:5000", + ] + + CORS( + app, + origins=origins, + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], + supports_credentials=True, + ) +else: + # Development: Allow localhost with regex pattern + CORS( + app, + origins=[ + "http://localhost:3000", + "http://localhost:5000", + "http://127.0.0.1:3000", + "http://127.0.0.1:5000", + ], + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], + supports_credentials=True, + ) + + +@app.route("/") +def index(): + """Serve the main index page.""" + if (WEBSITE_DIR / "index.html").exists(): + return send_from_directory(WEBSITE_DIR, "index.html") + if (WEBSITE_DIR / "demo.html").exists(): + return send_from_directory(WEBSITE_DIR, "demo.html") + return ( + jsonify( + { + "error": "No index.html or demo.html found", + "website_dir": str(WEBSITE_DIR), + "available_files": ( + [f.name for f in WEBSITE_DIR.glob("*.html")] if WEBSITE_DIR.exists() else [] + ), + } + ), + 404, + ) + + +@app.route("/") +def serve_static(filename): + """Serve static files from the website directory.""" + try: + return send_from_directory(WEBSITE_DIR, filename) + except FileNotFoundError: + return ( + jsonify( + { + "error": f"File '{filename}' not found", + "website_dir": str(WEBSITE_DIR), + } + ), + 404, + ) + + +@app.route("/health") +def health(): + """Health check endpoint.""" + return jsonify( + { + "status": "healthy", + "server": "simple_server.py", + "website_dir": str(WEBSITE_DIR), + "website_exists": WEBSITE_DIR.exists(), + "available_html_files": ( + [f.name for f in WEBSITE_DIR.glob("*.html")] if WEBSITE_DIR.exists() else [] + ), + } + ) + + +@app.errorhandler(404) +def not_found(error): + """Custom 404 handler.""" + return ( + jsonify( + { + "error": "File not found", + "message": "The requested file was not found in the website directory", + "website_dir": str(WEBSITE_DIR), + } + ), + 404, + ) + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description="Simple web server for local development") + parser.add_argument("--port", type=int, default=8000, help="Port to run the server on") + parser.add_argument("--host", default="127.0.0.1", help="Host to bind to") + parser.add_argument("--debug", action="store_true", help="Enable debug mode") + + args = parser.parse_args() + + print("🌐 SAMO Simple Web Server") + print("=" * 30) + print(f"📁 Website directory: {WEBSITE_DIR}") + print(f"🔗 Server URL: http://{args.host}:{args.port}") + print("✅ CORS enabled for Cloud Run APIs") + print("🎯 Cloud Run API: https://samo-unified-api-optimized-frrnetyhfa-uc.a.run.app") + print("") + + if not WEBSITE_DIR.exists(): + print(f"⚠️ Warning: Website directory not found at {WEBSITE_DIR}") + print(" Make sure you're running this from the correct directory") + print("") + + print("Press Ctrl+C to stop the server") + print("") + + try: + app.run(host=args.host, port=args.port, debug=args.debug) + except KeyboardInterrupt: + print("\n👋 Server stopped") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/deployment/local/start-simple.sh b/deployment/local/start-simple.sh new file mode 100755 index 000000000..89e2b31fc --- /dev/null +++ b/deployment/local/start-simple.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Start simple local development server + +# Enable strict bash options for fail-fast behavior +set -euo pipefail +IFS=$'\n\t' + +# Change to script's directory for location independence +cd "$(dirname "$0")" + +echo "🚀 STARTING SIMPLE LOCAL DEVELOPMENT SERVER" +echo "===========================================" + +# Install minimal dependencies +echo "📦 Installing minimal dependencies..." +command -v python3 >/dev/null || { echo "python3 not found in PATH" >&2; exit 127; } +[ -f requirements-simple.txt ] || { echo "requirements-simple.txt not found next to script" >&2; exit 1; } +if [ -z "${VIRTUAL_ENV:-}" ]; then USER_FLAG="--user"; else USER_FLAG=""; fi +python3 -m pip install $USER_FLAG -r requirements-simple.txt + +# Start simple server +echo "🌐 Starting simple development server..." +PORT="${PORT:-8000}" +echo "Server will be available at: http://localhost:${PORT}" +echo "Website files served with CORS enabled" +echo "Press Ctrl+C to stop the server" +echo "" + +exec python3 simple_server.py --port "${PORT}" \ No newline at end of file diff --git a/deployment/local/test_api.py b/deployment/local/test_api.py index fb3c415c6..755297eff 100644 --- a/deployment/local/test_api.py +++ b/deployment/local/test_api.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -Enhanced API Testing Script +"""Enhanced API Testing Script =========================== Comprehensive testing for the enhanced emotion detection API with monitoring, @@ -26,9 +25,10 @@ "I feel overwhelmed by all the work", "I am hopeful for the future", "I feel content with my life", - "I am tired after a long day" + "I am tired after a long day", ] + def test_health_check(): """Test the enhanced health check endpoint.""" print("1. Testing enhanced health check...") @@ -36,21 +36,24 @@ def test_health_check(): response = requests.get(f"{BASE_URL}/health") if response.status_code == 200: data = response.json() - print(f"✅ Health check passed") + print("✅ Health check passed") print(f" Status: {data['status']}") print(f" Model Version: {data['model_version']}") print(f" Uptime: {data['uptime_seconds']:.1f} seconds") print(f" Total Requests: {data['metrics']['total_requests']}") - print(f" Success Rate: {data['metrics']['successful_requests']}/{data['metrics']['total_requests']}") + print( + f" Success Rate: {data['metrics']['successful_requests']}/{data['metrics']['total_requests']}" + ) print(f" Avg Response Time: {data['metrics']['average_response_time_ms']}ms") return True else: print(f"❌ Health check failed: {response.status_code}") return False except Exception as e: - print(f"❌ Health check error: {str(e)}") + print(f"❌ Health check error: {e!s}") return False + def test_metrics_endpoint(): """Test the new metrics endpoint.""" print("\n2. Testing metrics endpoint...") @@ -58,64 +61,77 @@ def test_metrics_endpoint(): response = requests.get(f"{BASE_URL}/metrics") if response.status_code == 200: data = response.json() - print(f"✅ Metrics endpoint working") + print("✅ Metrics endpoint working") print(f" Success Rate: {data['server_metrics']['success_rate']}") print(f" Requests/Minute: {data['server_metrics']['requests_per_minute']:.2f}") - print(f" Rate Limiting: {data['rate_limiting']['max_requests']} req/{data['rate_limiting']['window_seconds']}s") + print( + f" Rate Limiting: {data['rate_limiting']['max_requests']} req/{data['rate_limiting']['window_seconds']}s" + ) return True else: print(f"❌ Metrics endpoint failed: {response.status_code}") return False except Exception as e: - print(f"❌ Metrics endpoint error: {str(e)}") + print(f"❌ Metrics endpoint error: {e!s}") return False + def test_single_predictions(): """Test single predictions with timing.""" print("\n3. Testing single predictions...") results = [] - + for i, text in enumerate(TEST_TEXTS[:5], 1): try: start_time = time.time() response = requests.post( f"{BASE_URL}/predict", json={"text": text}, - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) end_time = time.time() - + if response.status_code == 200: data = response.json() - emotion = data['predicted_emotion'] - confidence = data['confidence'] - prediction_time = data.get('prediction_time_ms', 0) + # Handle both API schemas: primary_emotion/primary_confidence or predicted_emotion/confidence + emotion = data.get("primary_emotion") or data.get("predicted_emotion") + confidence = data.get("primary_confidence") or data.get("confidence", 0) + prediction_time = data.get("prediction_time_ms", 0) total_time = (end_time - start_time) * 1000 - - print(f"✅ Test {i}: '{text[:30]}...' → {emotion} (conf: {confidence:.3f}, time: {prediction_time}ms)") - results.append({ - 'text': text, - 'emotion': emotion, - 'confidence': confidence, - 'prediction_time_ms': prediction_time, - 'total_time_ms': total_time - }) + + # Log text length and hash instead of raw content to avoid PII exposure + import hashlib + + text_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8] + print( + f"✅ Test {i}: text_len={len(text)}, text_hash={text_hash} → {emotion} (conf: {confidence:.3f}, time: {prediction_time}ms)" + ) + results.append( + { + "text": text, + "emotion": emotion, + "confidence": confidence, + "prediction_time_ms": prediction_time, + "total_time_ms": total_time, + } + ) else: print(f"❌ Test {i} failed: {response.status_code}") return False - + except Exception as e: - print(f"❌ Test {i} error: {str(e)}") + print(f"❌ Test {i} error: {e!s}") return False - + # Calculate average performance - avg_confidence = sum(r['confidence'] for r in results) / len(results) - avg_prediction_time = sum(r['prediction_time_ms'] for r in results) / len(results) + avg_confidence = sum(r["confidence"] for r in results) / len(results) + avg_prediction_time = sum(r["prediction_time_ms"] for r in results) / len(results) print(f" 📊 Average confidence: {avg_confidence:.3f}") print(f" 📊 Average prediction time: {avg_prediction_time:.1f}ms") - + return True + def test_batch_predictions(): """Test batch predictions.""" print("\n4. Testing batch predictions...") @@ -124,84 +140,143 @@ def test_batch_predictions(): response = requests.post( f"{BASE_URL}/predict_batch", json={"texts": TEST_TEXTS[:5]}, - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) end_time = time.time() - + if response.status_code == 200: data = response.json() - predictions = data['predictions'] - batch_time = data.get('batch_processing_time_ms', 0) + predictions = data["predictions"] + batch_time = data.get("batch_processing_time_ms", 0) total_time = (end_time - start_time) * 1000 - + print(f"✅ Batch prediction successful: {len(predictions)} predictions") print(f" Batch processing time: {batch_time}ms") print(f" Total time: {total_time:.1f}ms") - + for i, pred in enumerate(predictions, 1): - emotion = pred['predicted_emotion'] - confidence = pred['confidence'] - text = pred['text'][:30] + "..." if len(pred['text']) > 30 else pred['text'] - print(f" {i}. '{text}' → {emotion} (conf: {confidence:.3f})") - + # Normalize prediction data to handle both response shapes + def normalize_prediction(pred_item): + """Normalize prediction item to extract emotion, confidence, and text.""" + # Check for direct keys first + if ( + "predicted_emotion" in pred_item + and "confidence" in pred_item + and "text" in pred_item + ): + return { + "emotion": pred_item.get("predicted_emotion"), + "confidence": pred_item.get("confidence", 0), + "text": pred_item.get("text", ""), + } + + # Check for primary_emotion/primary_confidence keys + if ( + "primary_emotion" in pred_item + and "primary_confidence" in pred_item + and "text" in pred_item + ): + return { + "emotion": pred_item.get("primary_emotion"), + "confidence": pred_item.get("primary_confidence", 0), + "text": pred_item.get("text", ""), + } + + # Try to extract from nested structures + nested_data = ( + pred_item.get("data") + or pred_item.get("result") + or pred_item.get("prediction") + ) + if nested_data: + if isinstance(nested_data, list) and len(nested_data) > 0: + nested_data = nested_data[0] + if isinstance(nested_data, dict): + return { + "emotion": nested_data.get("predicted_emotion") + or nested_data.get("primary_emotion"), + "confidence": nested_data.get("confidence") + or nested_data.get("primary_confidence", 0), + "text": nested_data.get("text", pred_item.get("text", "")), + } + + # Fallback to safe defaults + return { + "emotion": pred_item.get("primary_emotion") + or pred_item.get("predicted_emotion", "unknown"), + "confidence": pred_item.get("primary_confidence") + or pred_item.get("confidence", 0), + "text": pred_item.get("text", ""), + } + + # Normalize the prediction + normalized = normalize_prediction(pred) + emotion = normalized["emotion"] + confidence = normalized["confidence"] + text = normalized["text"] + + # Truncate text for display + display_text = text[:30] + "..." if len(text) > 30 else text + print(f" {i}. '{display_text}' → {emotion} (conf: {confidence:.3f})") + return True else: print(f"❌ Batch prediction failed: {response.status_code}") return False - + except Exception as e: - print(f"❌ Batch prediction error: {str(e)}") + print(f"❌ Batch prediction error: {e!s}") return False + def test_rate_limiting(): """Test rate limiting functionality.""" print("\n5. Testing rate limiting...") - + def make_request(): try: response = requests.post( f"{BASE_URL}/predict", json={"text": "Test rate limiting"}, - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) return response.status_code except: return 0 - + # Make rapid requests to test rate limiting print(" Making rapid requests to test rate limiting...") start_time = time.time() - + with ThreadPoolExecutor(max_workers=10) as executor: futures = [executor.submit(make_request) for _ in range(50)] results = [future.result() for future in as_completed(futures)] - + end_time = time.time() - + successful = sum(1 for code in results if code == 200) rate_limited = sum(1 for code in results if code == 429) failed = sum(1 for code in results if code not in [200, 429]) - + print(f" ✅ Rate limiting test completed in {end_time - start_time:.2f}s") print(f" 📊 Successful: {successful}, Rate limited: {rate_limited}, Failed: {failed}") - + if rate_limited > 0: print(f" ✅ Rate limiting is working (blocked {rate_limited} requests)") return True else: - print(f" ⚠️ No rate limiting detected (may need more requests)") + print(" ⚠️ No rate limiting detected (may need more requests)") return True + def test_error_handling(): """Test error handling.""" print("\n6. Testing error handling...") - + # Test missing text try: response = requests.post( - f"{BASE_URL}/predict", - json={}, - headers={"Content-Type": "application/json"} + f"{BASE_URL}/predict", json={}, headers={"Content-Type": "application/json"} ) if response.status_code == 400: print("✅ Missing text error handled correctly") @@ -209,15 +284,15 @@ def test_error_handling(): print(f"❌ Missing text error not handled: {response.status_code}") return False except Exception as e: - print(f"❌ Missing text test error: {str(e)}") + print(f"❌ Missing text test error: {e!s}") return False - + # Test empty text try: response = requests.post( f"{BASE_URL}/predict", json={"text": ""}, - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) if response.status_code == 400: print("✅ Empty text error handled correctly") @@ -225,15 +300,15 @@ def test_error_handling(): print(f"❌ Empty text error not handled: {response.status_code}") return False except Exception as e: - print(f"❌ Empty text test error: {str(e)}") + print(f"❌ Empty text test error: {e!s}") return False - + # Test invalid JSON try: response = requests.post( f"{BASE_URL}/predict", data="invalid json", - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) if response.status_code == 400: print("✅ Invalid JSON error handled correctly") @@ -241,54 +316,55 @@ def test_error_handling(): print(f"❌ Invalid JSON error not handled: {response.status_code}") return False except Exception as e: - print(f"❌ Invalid JSON test error: {str(e)}") + print(f"❌ Invalid JSON test error: {e!s}") return False - + return True + def test_performance(): """Test performance under load.""" print("\n7. Testing performance under load...") - + def make_prediction_request(): try: start_time = time.time() response = requests.post( f"{BASE_URL}/predict", json={"text": "Performance test"}, - headers={"Content-Type": "application/json"} + headers={"Content-Type": "application/json"}, ) end_time = time.time() return { - 'status_code': response.status_code, - 'response_time': (end_time - start_time) * 1000 + "status_code": response.status_code, + "response_time": (end_time - start_time) * 1000, } except Exception as e: - return {'status_code': 0, 'response_time': 0, 'error': str(e)} - + return {"status_code": 0, "response_time": 0, "error": str(e)} + # Test with concurrent requests print(" Testing with 20 concurrent requests...") start_time = time.time() - + with ThreadPoolExecutor(max_workers=5) as executor: futures = [executor.submit(make_prediction_request) for _ in range(20)] results = [future.result() for future in as_completed(futures)] - + end_time = time.time() - - successful = [r for r in results if r['status_code'] == 200] - failed = [r for r in results if r['status_code'] != 200] - + + successful = [r for r in results if r["status_code"] == 200] + failed = [r for r in results if r["status_code"] != 200] + if successful: - avg_response_time = sum(r['response_time'] for r in successful) / len(successful) - min_response_time = min(r['response_time'] for r in successful) - max_response_time = max(r['response_time'] for r in successful) - + avg_response_time = sum(r["response_time"] for r in successful) / len(successful) + min_response_time = min(r["response_time"] for r in successful) + max_response_time = max(r["response_time"] for r in successful) + print(f" ✅ Performance test completed in {end_time - start_time:.2f}s") print(f" 📊 Successful requests: {len(successful)}/{len(results)}") print(f" 📊 Average response time: {avg_response_time:.1f}ms") print(f" 📊 Response time range: {min_response_time:.1f}ms - {max_response_time:.1f}ms") - + if avg_response_time < 1000: # Less than 1 second print(" ✅ Performance is acceptable") return True @@ -299,15 +375,16 @@ def make_prediction_request(): print(" ❌ No successful requests in performance test") return False + def main(): """Run all tests.""" print("🧪 ENHANCED API TESTING") print("=" * 50) - + # Wait for server to start print("⏳ Waiting for server to start...") time.sleep(2) - + tests = [ ("Health Check", test_health_check), ("Metrics Endpoint", test_metrics_endpoint), @@ -315,12 +392,12 @@ def main(): ("Batch Predictions", test_batch_predictions), ("Rate Limiting", test_rate_limiting), ("Error Handling", test_error_handling), - ("Performance", test_performance) + ("Performance", test_performance), ] - + passed = 0 total = len(tests) - + for test_name, test_func in tests: try: if test_func(): @@ -328,12 +405,12 @@ def main(): else: print(f"❌ {test_name} failed") except Exception as e: - print(f"❌ {test_name} error: {str(e)}") - + print(f"❌ {test_name} error: {e!s}") + print("\n" + "=" * 50) - print(f"🎉 ENHANCED API TESTING COMPLETED!") + print("🎉 ENHANCED API TESTING COMPLETED!") print(f"📊 Results: {passed}/{total} tests passed") - + if passed == total: print("✅ All tests passed! Enhanced API is working correctly.") print("\n📋 Enhanced Features Verified:") @@ -348,5 +425,6 @@ def main(): print(f"❌ {total - passed} tests failed. Please check the implementation.") return 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/deployment/local/test_normalization.py b/deployment/local/test_normalization.py new file mode 100644 index 000000000..e69de29bb diff --git a/deployment/local/unified_api_server.py b/deployment/local/unified_api_server.py new file mode 100644 index 000000000..9b800fe37 --- /dev/null +++ b/deployment/local/unified_api_server.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +"""🚀 UNIFIED SAMO API SERVER WITH VOICE PROCESSING +============================================== +Complete API server with emotion detection, summarization, and voice processing. +Combines all SAMO models for comprehensive AI analysis. +""" + +import argparse +import logging +import os +import tempfile +import time +import uuid +import threading +from pathlib import Path + +import torch +from flask import Flask, request, jsonify +from flask_cors import CORS +from transformers import ( + AutoTokenizer, + AutoModelForSequenceClassification, + T5Tokenizer, + T5ForConditionalGeneration, +) + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) +CORS(app) # Enable CORS for all domains + +# Configure Flask for file uploads (16MB max) +app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 + +# Global variables for model state (thread-safe with locks) +emotion_model = None +emotion_tokenizer = None +emotion_mapping = None +voice_transcriber = None +summarizer_model = None +summarizer_tokenizer = None +model_loading = False +models_loaded = False +model_lock = threading.Lock() + +# GoEmotions emotion mapping for SAMO DeBERTa model +GOEMOTIONS_EMOTIONS = [ + "admiration", + "amusement", + "anger", + "annoyance", + "approval", + "caring", + "confusion", + "curiosity", + "desire", + "disappointment", + "disapproval", + "disgust", + "embarrassment", + "excitement", + "fear", + "gratitude", + "grief", + "joy", + "love", + "nervousness", + "optimism", + "pride", + "realization", + "relief", + "remorse", + "sadness", + "surprise", + "neutral", +] + +# Fallback mapping (if needed) +EMOTION_MAPPING = [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", +] + +# Constants +MAX_INPUT_LENGTH = 512 + + +def load_models(): + """Load all AI models: emotion detection, voice processing, and summarization""" + global \ + model_loading, \ + models_loaded, \ + emotion_model, \ + emotion_tokenizer, \ + emotion_mapping, \ + voice_transcriber, \ + summarizer_model, \ + summarizer_tokenizer + + with model_lock: + if model_loading or models_loaded: + return + model_loading = True + + logger.info("🔄 Starting unified model loading...") + + try: + # Load emotion detection model + logger.info("📥 Loading emotion detection model...") + model_path = Path("/app/model") # For production deployment + + # Fallback to local development path if production path doesn't exist + if not model_path.exists(): + logger.info("📁 Production model path not found, checking for local models...") + # For development, we'll use a basic emotion classifier + # This can be replaced with actual trained models + + logger.info("📥 Loading emotion model...") + try: + # Try to load production model first + emotion_model = AutoModelForSequenceClassification.from_pretrained(str(model_path)) + emotion_tokenizer = AutoTokenizer.from_pretrained(str(model_path)) + logger.info("✅ Production model loaded successfully") + except: + logger.warning("⚠️ Production model not found, loading REAL SAMO model") + samo_model_id = "duelker/samo-goemotions-deberta-v3-large" + logger.info(f"🚀 Loading REAL SAMO emotion model: {samo_model_id}") + emotion_model = AutoModelForSequenceClassification.from_pretrained(samo_model_id) + emotion_tokenizer = AutoTokenizer.from_pretrained(samo_model_id) + logger.info("✅ REAL SAMO emotion model loaded successfully") + + # Set device (CPU for compatibility) + device = torch.device("cpu") + emotion_model.to(device) + emotion_model.eval() + + # Use GoEmotions labels for SAMO DeBERTa model + try: + id2label = getattr(emotion_model.config, "id2label", None) + if id2label and len(id2label) == len(GOEMOTIONS_EMOTIONS): + # SAMO model uses GoEmotions labels - map from LABEL_X to actual emotions + emotion_mapping = GOEMOTIONS_EMOTIONS + logger.info("✅ Using REAL SAMO GoEmotions mapping: 28 emotions") + logger.info( + f"🎯 Sample emotions: {GOEMOTIONS_EMOTIONS[:5]}...{GOEMOTIONS_EMOTIONS[-3:]}" + ) + else: + emotion_mapping = EMOTION_MAPPING + logger.info("⚠️ Using fallback emotion mapping") + except Exception: + emotion_mapping = EMOTION_MAPPING + logger.info("⚠️ Using fallback emotion mapping due to error") + + logger.info(f"✅ Emotion model loaded successfully on {device}") + + # Load voice processing model (lightweight approach) + logger.info("🎙️ Loading voice processing model...") + try: + import whisper + + # Use smallest/fastest Whisper model for development + voice_transcriber = whisper.load_model("tiny") + logger.info("✅ Voice processing model (Whisper tiny) loaded successfully") + + except Exception as e: + logger.warning(f"⚠️ Voice processing model failed to load: {e}") + logger.info("📝 Voice processing will use fallback mock responses") + voice_transcriber = None + + # Load text summarization model + logger.info("📝 Loading text summarization model...") + try: + summarizer_model = T5ForConditionalGeneration.from_pretrained("t5-small") + summarizer_tokenizer = T5Tokenizer.from_pretrained("t5-small") + summarizer_model.eval() + logger.info("✅ T5 summarization model loaded successfully") + except Exception as e: + logger.warning(f"⚠️ Summarization model failed to load: {e}") + logger.info("📝 Summarization will use enhanced fallback") + summarizer_model = None + summarizer_tokenizer = None + + logger.info("🎉 All models loaded successfully!") + logger.info(f"🎯 Emotion mapping: {emotion_mapping}") + logger.info(f"📝 Summarization available: {summarizer_model is not None}") + + except Exception: + logger.exception("❌ Failed to load models") + # Continue without models for graceful degradation + finally: + with model_lock: + # Only set models_loaded = True when both model and tokenizer are present + models_loaded = emotion_model is not None and emotion_tokenizer is not None + model_loading = False + + +def summarize_text(text: str) -> dict: + """Generate real AI summary using T5 model""" + if summarizer_model is None or summarizer_tokenizer is None: + # Enhanced fallback with better extraction + sentences = text.split(".") + if len(sentences) <= 3: + return { + "summary": text, + "original_length": len(text), + "summary_length": len(text), + "compression_ratio": 1.0, + "model": "extractive_fallback", + } + + # Take first 2 and last 1 sentence for better context + important_sentences = sentences[:2] + sentences[-1:] + summary = ". ".join(sent.strip() for sent in important_sentences if sent.strip()) + "." + + return { + "summary": summary, + "original_length": len(text), + "summary_length": len(summary), + "compression_ratio": round(len(summary) / len(text), 2), + "model": "extractive_fallback", + } + + try: + # Prepare text for T5 + input_text = f"summarize: {text}" + + # Tokenize + inputs = summarizer_tokenizer.encode( + input_text, return_tensors="pt", max_length=512, truncation=True + ) + + # Generate summary + with torch.no_grad(): + summary_ids = summarizer_model.generate( + inputs, + max_length=150, + min_length=30, + length_penalty=2.0, + num_beams=4, + early_stopping=True, + ) + + # Decode summary + summary = summarizer_tokenizer.decode(summary_ids[0], skip_special_tokens=True) + + return { + "summary": summary, + "original_length": len(text), + "summary_length": len(summary), + "compression_ratio": round(len(summary) / len(text), 2), + "model": "T5-small", + } + + except Exception as e: + logger.warning(f"T5 summarization failed: {e}, using fallback") + # Fallback to extractive + words = text.split() + summary_length = max(20, len(words) // 4) + summary = " ".join(words[:summary_length]) + if len(words) > summary_length: + summary += "..." + + return { + "summary": summary, + "original_length": len(text), + "summary_length": len(summary), + "compression_ratio": round(len(summary) / len(text), 2), + "model": "extractive_fallback", + } + + +def predict_emotion(text: str) -> dict: + """Predict emotion for given text""" + if not models_loaded or emotion_model is None: + raise RuntimeError("Emotion model not loaded") + + # Input sanitization and length check + if not isinstance(text, str): + raise ValueError("Input text must be a string.") + if len(text) > MAX_INPUT_LENGTH: + raise ValueError(f"Input text too long (>{MAX_INPUT_LENGTH} characters).") + + # Tokenize + inputs = emotion_tokenizer( + text, + return_tensors="pt", + truncation=True, + max_length=MAX_INPUT_LENGTH, + padding=True, + ) + + # Predict + with torch.no_grad(): + outputs = emotion_model(**inputs) + + # Check if this is a multi-label classification model + is_multi_label = ( + getattr(emotion_model.config, "problem_type", "") == "multi_label_classification" + ) + + if is_multi_label: + # Use sigmoid for multi-label classification + scores = torch.sigmoid(outputs.logits)[0] + # Apply threshold for multi-label decisions + threshold = 0.5 + predicted_labels = (scores > threshold).nonzero(as_tuple=True)[0].tolist() + + if predicted_labels: + # Get the highest scoring label as primary + predicted_class = int(torch.argmax(scores).item()) + confidence = float(scores[predicted_class].item()) + else: + # No labels above threshold, use highest scoring + predicted_class = int(torch.argmax(scores).item()) + confidence = float(scores[predicted_class].item()) + else: + # Use softmax for single-label classification + scores = torch.softmax(outputs.logits, dim=-1)[0] + predicted_class = int(torch.argmax(scores).item()) + confidence = float(scores[predicted_class].item()) + + # Map to emotion name (use index if available, otherwise fallback) + if predicted_class < len(emotion_mapping): + emotion = emotion_mapping[predicted_class] + else: + emotion = "neutral" # Fallback + + # Create full emotions dictionary with all scores + emotions_dict = {} + for i, label in enumerate(emotion_mapping): + emotions_dict[label] = float(scores[i].item()) + + # Create top emotions array (sorted by confidence) + top_emotions = sorted( + [{"emotion": label, "confidence": score} for label, score in emotions_dict.items()], + key=lambda x: x["confidence"], + reverse=True, + )[:5] + + return { + "emotion": emotion, # Keep backward compatibility + "confidence": confidence, # Keep backward compatibility + "text": text, + "emotions": emotions_dict, # Add full emotions for demo + "predicted_emotion": emotion, # Add for demo compatibility + "top_emotions": top_emotions, # Add for demo compatibility + } + + +def transcribe_audio(audio_file) -> dict: + """Transcribe audio file to text with emotion analysis""" + if voice_transcriber is None: + raise RuntimeError("Voice processing model not available") + + # Save uploaded file temporarily + with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as temp_file: + audio_file.save(temp_file.name) + temp_path = temp_file.name + + try: + # Transcribe audio + result = voice_transcriber.transcribe(temp_path) + + if not result or "text" not in result: + raise RuntimeError("Transcription failed - no text returned") + + transcribed_text = result.get("text", "") + # Whisper doesn't provide a calibrated confidence; keep a placeholder + confidence = 0.9 + # Approximate duration from segments if available + segs = result.get("segments") or [] + duration = float(segs[-1]["end"]) if segs else 0.0 + + # Analyze emotions in transcribed text + emotion_analysis = predict_emotion(transcribed_text) + + # Create comprehensive response + return { + "transcription": { + "text": transcribed_text, + "confidence": confidence, + "duration": duration, + }, + "emotion_analysis": emotion_analysis, + "processing_info": { + "timestamp": time.time(), + "request_id": str(uuid.uuid4()), + "models_used": ["SAMO Whisper", "SAMO Emotion Detection"], + }, + } + + finally: + # Clean up temporary file + try: + os.unlink(temp_path) + except: + pass + + +def ensure_models_loaded(): + """Ensure models are loaded before processing requests""" + if not models_loaded and not model_loading: + load_models() + + if not models_loaded: + raise RuntimeError("Models not loaded") + + +def create_error_response(message: str, status_code: int = 500) -> tuple: + """Create standardized error response with request ID for debugging""" + request_id = str(uuid.uuid4()) + logger.exception(f"{message} [request_id={request_id}]") + return jsonify({"error": message, "request_id": request_id}), status_code + + +# API Routes + + +@app.route("/", methods=["GET"]) +def root(): + """Root endpoint""" + return jsonify( + { + "message": "SAMO Unified AI API - Voice, Emotion & Summarization", + "status": "running", + "models_loaded": models_loaded, + "timestamp": time.time(), + } + ) + + +@app.route("/health", methods=["GET"]) +def health_check(): + """Health check endpoint""" + return jsonify( + { + "status": "healthy", + "models_loaded": models_loaded, + "model_loading": model_loading, + "voice_available": voice_transcriber is not None, + "emotion_available": emotion_model is not None, + "summarization_available": summarizer_model is not None, + "timestamp": time.time(), + } + ) + + +@app.route("/analyze/emotion", methods=["POST"]) +def analyze_emotion(): + """Analyze emotion in text""" + try: + # Ensure models are loaded + ensure_models_loaded() + + # Get text from query params (to match frontend expectations) + text = request.args.get("text", "").strip() + if not text: + # Fallback to JSON body + data = request.get_json(silent=True) or {} + text = data.get("text", "").strip() + + if not text: + return jsonify({"error": "No text provided"}), 400 + + # Make prediction + result = predict_emotion(text) + + # Enhance response with additional metadata + result.update({"request_id": str(uuid.uuid4()), "timestamp": time.time()}) + + return jsonify(result) + + except Exception: + return create_error_response("Emotion analysis failed. Please try again later.") + + +@app.route("/analyze/voice-journal", methods=["POST"]) +def analyze_voice_journal(): + """Analyze voice recording with transcription and emotion detection""" + try: + # Ensure models are loaded + ensure_models_loaded() + + # Check for audio file in the request + if "audio_file" not in request.files: + return jsonify({"error": "No audio file provided"}), 400 + + audio_file = request.files["audio_file"] + if audio_file.filename == "": + return jsonify({"error": "No audio file selected"}), 400 + + # Validate MIME type + allowed_types = ["audio/webm", "audio/wav", "audio/mp4", "audio/mpeg"] + if audio_file.content_type not in allowed_types: + return ( + jsonify( + { + "error": ( + f"Unsupported audio format: {audio_file.content_type}. " + f"Supported: {', '.join(allowed_types)}" + ) + } + ), + 400, + ) + + logger.info( + f"🎙️ Processing voice journal: {audio_file.filename} ({audio_file.content_type})" + ) + + # Transcribe and analyze + if voice_transcriber is not None: + result = transcribe_audio(audio_file) + logger.info("✅ Voice journal processing successful") + return jsonify(result) + # Fallback to mock if voice model not available + logger.warning("⚠️ Voice model not available, using enhanced mock response") + return jsonify(create_enhanced_mock_response(audio_file.filename)) + + except Exception: + return create_error_response("Voice processing failed. Please try again later.") + + +def create_enhanced_mock_response(filename: str) -> dict: + """Create an enhanced mock response that looks more realistic""" + import random + + sample_texts = [ + ("Today has been a wonderful day filled with excitement and new opportunities."), + ("I'm feeling quite optimistic about the future and all the possibilities ahead."), + ("The voice processing feature is working amazingly well for transcription."), + ("I'm grateful for all the progress we've made on this project so far."), + ("This technology is truly impressive and will help many people."), + ] + + transcribed_text = random.choice(sample_texts) + + # Use real emotion analysis on the mock text + try: + if emotion_model is not None: + emotion_result = predict_emotion(transcribed_text) + else: + emotion_result = { + "emotion": "optimism", + "confidence": 0.85, + "text": transcribed_text, + } + except: + emotion_result = { + "emotion": "neutral", + "confidence": 0.75, + "text": transcribed_text, + } + + return { + "transcription": { + "text": transcribed_text, + "confidence": random.uniform(0.85, 0.95), + "duration": random.uniform(3.0, 8.0), + }, + "emotion_analysis": emotion_result, + "processing_info": { + "filename": filename, + "timestamp": time.time(), + "request_id": str(uuid.uuid4()), + "models_used": ["Enhanced Mock Whisper", "Real Emotion Analysis"], + "note": "Voice transcription simulated - emotion analysis is real", + }, + } + + +@app.route("/analyze/summarize", methods=["POST"]) +def analyze_summarize(): + """Summarize text (placeholder for future implementation)""" + try: + # Get text from query params + text = request.args.get("text", "").strip() + if not text: + data = request.get_json(silent=True) or {} + text = data.get("text", "").strip() + + if not text: + return jsonify({"error": "No text provided"}), 400 + + # Use REAL T5 AI summarization + summary_result = summarize_text(text) + + result = { + "summary": summary_result["summary"], + "original_length": summary_result["original_length"], + "summary_length": summary_result["summary_length"], + "compression_ratio": summary_result["compression_ratio"], + "model_used": summary_result["model"], + "request_id": str(uuid.uuid4()), + "timestamp": time.time(), + } + + return jsonify(result) + + except Exception: + return create_error_response("Text summarization failed. Please try again later.") + + +# Initialize models on startup +def initialize_models(): + """Initialize models before first request""" + try: + load_models() + except Exception: + logger.exception("Failed to initialize models on startup") + + +# Initialize models when module is imported +initialize_models() + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="SAMO Unified AI API Server") + parser.add_argument( + "--port", + type=int, + default=int(os.getenv("PORT", "8002")), + help="Port to run the server on (default: 8002)", + ) + parser.add_argument("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)") + parser.add_argument("--debug", action="store_true", help="Run in debug mode") + args = parser.parse_args() + + logger.info("🚀 STARTING SAMO UNIFIED AI API SERVER") + logger.info("=" * 50) + logger.info("🎙️ Voice Processing: SAMO Whisper Integration") + logger.info("😊 Emotion Detection: SAMO DeBERTa Model") + logger.info("📝 Text Summarization: SAMO T5 Model") + logger.info("🌐 API Endpoints:") + logger.info(" - GET / - Root endpoint") + logger.info(" - GET /health - Health check") + logger.info(" - POST /analyze/emotion - Text emotion analysis") + logger.info(" - POST /analyze/voice-journal - Voice transcription + emotion") + logger.info(" - POST /analyze/summarize - Text summarization") + logger.info("=" * 50) + + # Initialize models + try: + load_models() + except Exception: + logger.exception("Failed to load models on startup") + + print(f"🌐 Server starting at: http://{args.host}:{args.port}") + print("📁 Serving unified AI analysis with voice processing") + print("🔧 Real voice transcription and emotion analysis") + print("Press Ctrl+C to stop the server") + print("") + + app.run(host=args.host, port=args.port, debug=args.debug) diff --git a/deployment/secure_api_server.py b/deployment/secure_api_server.py index 8c78347ad..51e73a142 100644 --- a/deployment/secure_api_server.py +++ b/deployment/secure_api_server.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -""" -🔒 SECURE EMOTION DETECTION API SERVER +"""🔒 SECURE EMOTION DETECTION API SERVER ====================================== Production-ready Flask API server with comprehensive security features. @@ -38,18 +37,14 @@ from src.constants import EMOTION_MODEL_DIR # single source of truth except ImportError: EMOTION_MODEL_DIR = os.getenv( - 'EMOTION_MODEL_DIR', - '/app/models/emotion-english-distilroberta-base' + "EMOTION_MODEL_DIR", "/app/models/emotion-english-distilroberta-base" ) # Configure logging logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('secure_api_server.log'), - logging.StreamHandler() - ] + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("secure_api_server.log"), logging.StreamHandler()], ) logger = logging.getLogger(__name__) @@ -66,7 +61,7 @@ enable_ip_whitelist=False, whitelisted_ips=set(), enable_ip_blacklist=True, - blacklisted_ips=set() + blacklisted_ips=set(), ) sanitization_config = SanitizationConfig( @@ -77,7 +72,7 @@ enable_path_traversal_protection=True, enable_command_injection_protection=True, enable_unicode_normalization=True, - enable_content_type_validation=True + enable_content_type_validation=True, ) # Initialize security components @@ -87,95 +82,123 @@ # Monitoring metrics metrics = { - 'total_requests': 0, - 'successful_requests': 0, - 'failed_requests': 0, - 'rate_limited_requests': 0, - 'sanitization_warnings': 0, - 'security_violations': 0, - 'average_response_time': 0.0, - 'response_times': deque(maxlen=1000), - 'emotion_distribution': defaultdict(int), - 'error_counts': defaultdict(int), - 'start_time': datetime.now() + "total_requests": 0, + "successful_requests": 0, + "failed_requests": 0, + "rate_limited_requests": 0, + "sanitization_warnings": 0, + "security_violations": 0, + "average_response_time": 0.0, + "response_times": deque(maxlen=1000), + "emotion_distribution": defaultdict(int), + "error_counts": defaultdict(int), + "start_time": datetime.now(), } metrics_lock = threading.Lock() -def update_metrics(response_time, success=True, emotion=None, error_type=None, rate_limited=False, sanitization_warnings=0): + +def update_metrics( + response_time, + success=True, + emotion=None, + error_type=None, + rate_limited=False, + sanitization_warnings=0, +): """Update monitoring metrics.""" with metrics_lock: - metrics['total_requests'] += 1 - metrics['response_times'].append(response_time) - + metrics["total_requests"] += 1 + metrics["response_times"].append(response_time) + if rate_limited: - metrics['rate_limited_requests'] += 1 + metrics["rate_limited_requests"] += 1 elif success: - metrics['successful_requests'] += 1 + metrics["successful_requests"] += 1 if emotion: - metrics['emotion_distribution'][emotion] += 1 + metrics["emotion_distribution"][emotion] += 1 else: - metrics['failed_requests'] += 1 + metrics["failed_requests"] += 1 if error_type: - metrics['error_counts'][error_type] += 1 - + metrics["error_counts"][error_type] += 1 + if sanitization_warnings > 0: - metrics['sanitization_warnings'] += sanitization_warnings - + metrics["sanitization_warnings"] += sanitization_warnings + # Update average response time - if metrics['response_times']: - metrics['average_response_time'] = sum(metrics['response_times']) / len(metrics['response_times']) + if metrics["response_times"]: + metrics["average_response_time"] = sum(metrics["response_times"]) / len( + metrics["response_times"] + ) + def secure_endpoint(f): """Decorator for secure endpoint handling.""" + @wraps(f) def decorated_function(*args, **kwargs): start_time = time.time() client_ip = request.remote_addr - user_agent = request.headers.get('User-Agent', '') - + user_agent = request.headers.get("User-Agent", "") + try: # Rate limiting allowed, reason, rate_limit_meta = rate_limiter.allow_request(client_ip, user_agent) if not allowed: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='rate_limited', rate_limited=True) + update_metrics( + response_time, + success=False, + error_type="rate_limited", + rate_limited=True, + ) logger.warning(f"Rate limit exceeded: {reason} from {client_ip}") - return jsonify({ - 'error': 'Rate limit exceeded', - 'message': reason, - 'retry_after': rate_limit_config.window_size_seconds - }), 429 - + return ( + jsonify( + { + "error": "Rate limit exceeded", + "message": reason, + "retry_after": rate_limit_config.window_size_seconds, + } + ), + 429, + ) + # Content type validation - if request.method == 'POST': - content_type = request.headers.get('Content-Type', '') + if request.method == "POST": + content_type = request.headers.get("Content-Type", "") if not input_sanitizer.validate_content_type(content_type): response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='invalid_content_type') + update_metrics(response_time, success=False, error_type="invalid_content_type") logger.warning(f"Invalid content type: {content_type} from {client_ip}") - return jsonify({ - 'error': 'Invalid content type', - 'message': 'Content-Type must be application/json' - }), 400 - + return ( + jsonify( + { + "error": "Invalid content type", + "message": "Content-Type must be application/json", + } + ), + 400, + ) + # Process request result = f(*args, **kwargs) - + # Release rate limit slot rate_limiter.release_request(client_ip, user_agent) - + return result - + except Exception as e: # Release rate limit slot on error rate_limiter.release_request(client_ip, user_agent) - + response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='endpoint_error') - logger.error(f"Endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 - + update_metrics(response_time, success=False, error_type="endpoint_error") + # Log detailed error on server but return generic message to user + logger.error(f"Endpoint error: {e!s}", exc_info=True) + return jsonify({"error": "Internal server error occurred"}), 500 + return decorated_function @@ -193,15 +216,27 @@ class SecureEmotionDetectionModel: def __init__(self): """Initialize the secure emotion detection model.""" # Resolve model directory (allow override via env var for tests/dev) - default_model_dir = Path(__file__).resolve().parent.parent / 'model' - env_model_dir = os.environ.get("SECURE_MODEL_DIR") - self.model_path = Path(env_model_dir).expanduser().resolve() if env_model_dir else default_model_dir + default_model_dir = Path(__file__).resolve().parent.parent / "model" + env_model_dir = os.environ.get("SECURE_MODEL_DIR", "") + self.model_path = ( + Path(env_model_dir).expanduser().resolve() if env_model_dir else default_model_dir + ) logger.info(f"Loading secure model from: {self.model_path}") # Default emotions list available even if model isn't loaded self.emotions = [ - 'anxious', 'calm', 'content', 'excited', 'frustrated', 'grateful', - 'happy', 'hopeful', 'overwhelmed', 'proud', 'sad', 'tired' + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", ] self.loaded = False @@ -225,14 +260,12 @@ def __init__(self): # If directory exists but lacks required files, also stub to avoid HF hub lookups required_all = [ - self.model_path / 'config.json', - self.model_path / 'tokenizer.json', - self.model_path / 'tokenizer_config.json', + self.model_path / "config.json", + self.model_path / "tokenizer.json", + self.model_path / "tokenizer_config.json", ] if not all(p.exists() for p in required_all): - logger.warning( - "Secure model directory lacks expected files. Running in stub mode." - ) + logger.warning("Secure model directory lacks expected files. Running in stub mode.") self.tokenizer = None self.model = None self.loaded = False @@ -243,13 +276,17 @@ def __init__(self): from transformers import AutoTokenizer, AutoModelForSequenceClassification # type: ignore import torch # type: ignore - self.tokenizer = AutoTokenizer.from_pretrained(str(self.model_path), local_files_only=True) - self.model = AutoModelForSequenceClassification.from_pretrained(str(self.model_path), local_files_only=True) + self.tokenizer = AutoTokenizer.from_pretrained( + str(self.model_path), local_files_only=True + ) + self.model = AutoModelForSequenceClassification.from_pretrained( + str(self.model_path), local_files_only=True + ) # Move to GPU if available try: if torch.cuda.is_available(): - self.model = self.model.to('cuda') + self.model = self.model.to("cuda") logger.info("✅ Model moved to GPU") else: logger.info("⚠️ CUDA not available, using CPU") @@ -261,18 +298,20 @@ def __init__(self): logger.info("✅ Secure model loaded successfully") except Exception as e: - logger.error(f"❌ Failed to load secure model: {str(e)}. Falling back to stub mode.") + logger.error(f"❌ Failed to load secure model: {e!s}. Falling back to stub mode.") self.tokenizer = None self.model = None self.loaded = False - + def predict(self, text, confidence_threshold=None): """Make a secure prediction.""" start_time = time.time() - + try: - if not getattr(self, 'loaded', False): - raise RuntimeError("SecureEmotionDetectionModel is not loaded; prediction unavailable.") + if not getattr(self, "loaded", False): + raise RuntimeError( + "SecureEmotionDetectionModel is not loaded; prediction unavailable." + ) # Ensure torch is available within function scope for linter/runtime try: import torch # type: ignore @@ -283,20 +322,26 @@ def predict(self, text, confidence_threshold=None): sanitized_text, warnings = input_sanitizer.sanitize_text(text, "emotion") if warnings: logger.warning(f"Sanitization warnings: {warnings}") - + # Tokenize input - inputs = self.tokenizer(sanitized_text, return_tensors='pt', truncation=True, padding=True, max_length=512) - + inputs = self.tokenizer( + sanitized_text, + return_tensors="pt", + truncation=True, + padding=True, + max_length=512, + ) + if torch.cuda.is_available(): - inputs = {k: v.to('cuda') for k, v in inputs.items()} - + inputs = {k: v.to("cuda") for k, v in inputs.items()} + # Get prediction with torch.no_grad(): outputs = self.model(**inputs) probabilities = torch.softmax(outputs.logits, dim=1) predicted_label = torch.argmax(probabilities, dim=1).item() confidence = probabilities[0][predicted_label].item() - + # Apply confidence threshold if specified if confidence_threshold and confidence < confidence_threshold: predicted_emotion = "uncertain" @@ -307,56 +352,81 @@ def predict(self, text, confidence_threshold=None): predicted_emotion = self.model.config.id2label[str(predicted_label)] else: predicted_emotion = f"unknown_{predicted_label}" - + # Get all probabilities all_probs = probabilities[0].cpu().numpy() - + prediction_time = time.time() - start_time - logger.info(f"Secure prediction completed in {prediction_time:.3f}s: '{sanitized_text[:50]}...' → {predicted_emotion} (conf: {confidence:.3f})") - + # Log text length and hash instead of raw content to avoid PII exposure + import hashlib + + text_hash = hashlib.sha256(sanitized_text.encode("utf-8")).hexdigest()[:8] + logger.info( + "Secure prediction completed in %.3fs: text_len=%d, text_hash=%s → %s (conf: %.3f)", + prediction_time, + len(sanitized_text), + text_hash, + predicted_emotion, + confidence, + ) + # Create secure response return { - 'text': sanitized_text, - 'predicted_emotion': predicted_emotion, - 'confidence': float(confidence), - 'probabilities': { + "text": sanitized_text, + "predicted_emotion": predicted_emotion, + "confidence": float(confidence), + "probabilities": { emotion: float(prob) for emotion, prob in zip(self.emotions, all_probs) }, - 'model_version': '2.0', - 'model_type': 'secure_emotion_detection', - 'performance': { - 'basic_accuracy': '100.00%', - 'real_world_accuracy': '93.75%', - 'average_confidence': '83.9%' + "model_version": "2.0", + "model_type": "secure_emotion_detection", + "performance": { + "basic_accuracy": "100.00%", + "real_world_accuracy": "93.75%", + "average_confidence": "83.9%", + }, + "prediction_time_ms": round(prediction_time * 1000, 2), + "security": { + "sanitization_warnings": warnings, + "request_id": getattr(g, "request_id", None), + "correlation_id": getattr(g, "correlation_id", None), }, - 'prediction_time_ms': round(prediction_time * 1000, 2), - 'security': { - 'sanitization_warnings': warnings, - 'request_id': getattr(g, 'request_id', None), - 'correlation_id': getattr(g, 'correlation_id', None) - } } - + except Exception as e: prediction_time = time.time() - start_time - logger.error(f"Secure prediction failed after {prediction_time:.3f}s: {str(e)}") + logger.error(f"Secure prediction failed after {prediction_time:.3f}s: {e!s}") raise + # Secure model factory for explicit creation and testability logger.info("🔒 Secure model will be created via factory function") + def create_secure_model(): """Factory function to create a SecureEmotionDetectionModel or a stub in CI/TEST. This avoids implicit global state and makes the creation path explicit and mockable in tests. """ if os.environ.get("TESTING") or os.environ.get("CI"): + class _Stub: emotions = [ - 'anxious', 'calm', 'content', 'excited', 'frustrated', 'grateful', - 'happy', 'hopeful', 'overwhelmed', 'proud', 'sad', 'tired' + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", ] loaded = False + return _Stub() return SecureEmotionDetectionModel() @@ -397,7 +467,7 @@ def get_emotion_service(): def _parse_single_text_payload(data: dict) -> str: """Validate and extract 'text' from request payload.""" - text = data.get('text') if isinstance(data, dict) else None + text = data.get("text") if isinstance(data, dict) else None if not isinstance(text, str) or not text.strip(): raise ValueError('Field "text" must be a non-empty string') return text @@ -416,12 +486,13 @@ def _sanitize_texts_batch(texts: List[str]) -> Tuple[List[str], int]: def _build_provider_info() -> dict: """Build provider info dict reflecting local-only mode and model_dir.""" - local_only_env = str(os.environ.get('EMOTION_LOCAL_ONLY', '')).strip().lower() + local_only_env = str(os.environ.get("EMOTION_LOCAL_ONLY", "")).strip().lower() return { - 'local_only': local_only_env in ('1', 'true', 'yes', 'on'), - 'model_dir': os.environ.get('EMOTION_MODEL_DIR', '') or EMOTION_MODEL_DIR, + "local_only": local_only_env in ("1", "true", "yes", "on"), + "model_dir": os.environ.get("EMOTION_MODEL_DIR", "") or EMOTION_MODEL_DIR, } + # Read admin API key per-request to reflect environment changes during tests def get_admin_api_key() -> str | None: """Fetch the admin API key from the environment on each call. @@ -431,7 +502,9 @@ def get_admin_api_key() -> str | None: per-request read may introduce race conditions if the environment variable changes mid-request; callers should treat the value as ephemeral per call. """ - return os.environ.get("ADMIN_API_KEY") + admin_key = os.environ.get("ADMIN_API_KEY", "").strip() + return admin_key if admin_key else None + def require_admin_api_key(f): """Decorator to require admin API key via X-Admin-API-Key header. @@ -439,14 +512,16 @@ def require_admin_api_key(f): Reads the expected key via ``get_admin_api_key()`` for each request and does not cache it. See ``get_admin_api_key`` for concurrency considerations. """ + @wraps(f) def decorated_function(*args, **kwargs): api_key = request.headers.get("X-Admin-API-Key") expected_key = get_admin_api_key() - if not expected_key or api_key != expected_key: + if expected_key is None or api_key != expected_key: logger.warning(f"Unauthorized admin access attempt from {request.remote_addr}") return jsonify({"error": "Unauthorized: admin API key required"}), 403 return f(*args, **kwargs) + return decorated_function @@ -454,36 +529,32 @@ def _get_json_payload_or_raise() -> Dict[str, Any]: """Return JSON payload or raise _ClientError for invalid JSON.""" data = request.get_json(silent=True) if data is None: - raise _ClientError('Invalid JSON format', 400, 'invalid_json') + raise _ClientError("Invalid JSON format", 400, "invalid_json") return data def _extract_and_filter_texts_or_raise( - data: Dict[str, Any] + data: Dict[str, Any], ) -> Tuple[List[str], List[str], int]: """Extract 'texts' list, filter invalid entries, and return tuple. Returns (original_texts, filtered_texts, num_filtered). """ - if not data or 'texts' not in data or not isinstance(data['texts'], list): - raise _ClientError( - 'Field "texts" must be a list of strings', 400, 'validation_error' - ) - original_texts = data['texts'] + if not data or "texts" not in data or not isinstance(data["texts"], list): + raise _ClientError('Field "texts" must be a list of strings', 400, "validation_error") + original_texts = data["texts"] texts = [t for t in original_texts if isinstance(t, str) and t.strip()] num_filtered = len(original_texts) - len(texts) if not texts: - raise _ClientError('No valid texts provided', 400, 'validation_error') + raise _ClientError("No valid texts provided", 400, "validation_error") return original_texts, texts, num_filtered -def _validate_alignment_count_or_raise( - results: Any, expected_count: int -) -> bool: +def _validate_alignment_count_or_raise(results: Any, expected_count: int) -> bool: """Ensure provider results match expected count or raise _ClientError.""" if (not isinstance(results, list)) or (len(results) != expected_count): raise _ClientError( - 'Provider returned mismatched result count', 502, 'provider_misalignment' + "Provider returned mismatched result count", 502, "provider_misalignment" ) return True @@ -494,261 +565,234 @@ def _validate_single_results_or_raise(results: Any) -> List[Dict[str, Any]]: Expects results to be List[List[Dict[str, Any]]], with len(results) == 1. Raises _ClientError(502) on invalid shape. """ - if ( - (not isinstance(results, list)) - or (len(results) != 1) - or (not isinstance(results[0], list)) - ): + if (not isinstance(results, list)) or (len(results) != 1) or (not isinstance(results[0], list)): outer_type = type(results).__name__ - outer_len = ( - len(results) if isinstance(results, list) else 'N/A' - ) - inner_type = ( - type(results[0]).__name__ - if isinstance(results, list) and results - else 'N/A' - ) + outer_len = len(results) if isinstance(results, list) else "N/A" + inner_type = type(results[0]).__name__ if isinstance(results, list) and results else "N/A" logger.error( - "Provider returned invalid shape for single input: " - "type=%s len=%s inner_type=%s", + "Provider returned invalid shape for single input: type=%s len=%s inner_type=%s", outer_type, outer_len, inner_type, ) raise _ClientError( - 'Provider returned mismatched result count', - 502, - 'provider_misalignment' + "Provider returned mismatched result count", 502, "provider_misalignment" ) dist = results[0] - if dist and not ( - isinstance(dist[0], dict) - and 'label' in dist[0] - and 'score' in dist[0] - ): - inner_first_type = ( - type(dist[0]).__name__ if dist else 'N/A' - ) - inner_keys = ( - list(dist[0].keys()) if isinstance(dist[0], dict) else 'N/A' - ) + if dist and not (isinstance(dist[0], dict) and "label" in dist[0] and "score" in dist[0]): + inner_first_type = type(dist[0]).__name__ if dist else "N/A" + inner_keys = list(dist[0].keys()) if isinstance(dist[0], dict) else "N/A" logger.error( - "Provider returned invalid inner element: " - "inner_first_type=%s keys=%s", + "Provider returned invalid inner element: inner_first_type=%s keys=%s", inner_first_type, inner_keys, ) raise _ClientError( - 'Provider returned mismatched result count', - 502, - 'provider_misalignment' + "Provider returned mismatched result count", 502, "provider_misalignment" ) return dist def _build_single_response( - sanitized_text: str, - dist: List[Dict[str, Any]], - warnings: List[Any] + sanitized_text: str, dist: List[Dict[str, Any]], warnings: List[Any] ) -> Dict[str, Any]: """Build JSON response payload for the single-input endpoint.""" return { - 'text': sanitized_text, - 'scores': dist, - 'provider': os.environ.get("EMOTION_PROVIDER", EMOTION_PROVIDER).lower(), - 'provider_info': _build_provider_info(), - 'timestamp': time.time(), - 'security': { - 'sanitization_warnings': warnings, - 'request_id': getattr(g, 'request_id', None), - 'correlation_id': getattr(g, 'correlation_id', None) - } + "text": sanitized_text, + "scores": dist, + "provider": os.environ.get("EMOTION_PROVIDER", EMOTION_PROVIDER).lower(), + "provider_info": _build_provider_info(), + "timestamp": time.time(), + "security": { + "sanitization_warnings": warnings, + "request_id": getattr(g, "request_id", None), + "correlation_id": getattr(g, "correlation_id", None), + }, } -@app.route('/health', methods=['GET']) + +@app.route("/health", methods=["GET"]) @secure_endpoint def health_check(): """Secure health check endpoint.""" start_time = time.time() - + try: mdl = get_secure_model() response = { - 'status': 'healthy', - 'model_loaded': getattr(mdl, 'loaded', False), - 'model_version': '2.0', - 'emotions': getattr(mdl, 'emotions', []), - 'uptime_seconds': (datetime.now() - metrics['start_time']).total_seconds(), - 'security': { - 'rate_limiting': rate_limiter.get_stats(), - 'sanitization': input_sanitizer.get_sanitization_stats(), - 'security_headers': security_middleware.get_security_stats() + "status": "healthy", + "model_loaded": getattr(mdl, "loaded", False), + "model_version": "2.0", + "emotions": getattr(mdl, "emotions", []), + "uptime_seconds": (datetime.now() - metrics["start_time"]).total_seconds(), + "security": { + "rate_limiting": rate_limiter.get_stats(), + "sanitization": input_sanitizer.get_sanitization_stats(), + "security_headers": security_middleware.get_security_stats(), + }, + "metrics": { + "total_requests": metrics["total_requests"], + "successful_requests": metrics["successful_requests"], + "failed_requests": metrics["failed_requests"], + "rate_limited_requests": metrics["rate_limited_requests"], + "sanitization_warnings": metrics["sanitization_warnings"], + "average_response_time_ms": round(metrics["average_response_time"] * 1000, 2), }, - 'metrics': { - 'total_requests': metrics['total_requests'], - 'successful_requests': metrics['successful_requests'], - 'failed_requests': metrics['failed_requests'], - 'rate_limited_requests': metrics['rate_limited_requests'], - 'sanitization_warnings': metrics['sanitization_warnings'], - 'average_response_time_ms': round(metrics['average_response_time'] * 1000, 2) - } } - + response_time = time.time() - start_time update_metrics(response_time, success=True) - + return jsonify(response) - + except Exception as e: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='health_check_error') - logger.error(f"Health check failed: {str(e)}") - return jsonify({'error': str(e)}), 500 + update_metrics(response_time, success=False, error_type="health_check_error") + logger.error(f"Health check failed: {e!s}", exc_info=True) + return jsonify({"error": "Health check failed"}), 500 + -@app.route('/predict', methods=['POST']) +@app.route("/predict", methods=["POST"]) @secure_endpoint def predict(): """Secure prediction endpoint.""" start_time = time.time() - + try: # Parse and validate request data try: data = request.get_json() except werkzeug.exceptions.BadRequest: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='invalid_json') + update_metrics(response_time, success=False, error_type="invalid_json") logger.error(f"Invalid JSON in request from {request.remote_addr}") - return jsonify({'error': 'Invalid JSON format'}), 400 - + return jsonify({"error": "Invalid JSON format"}), 400 + if not data: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='missing_data') - return jsonify({'error': 'No data provided'}), 400 - + update_metrics(response_time, success=False, error_type="missing_data") + return jsonify({"error": "No data provided"}), 400 + # Sanitize and validate request try: sanitized_data, warnings = input_sanitizer.validate_emotion_request(data) except ValueError as e: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='validation_error') - logger.warning(f"Validation error: {str(e)} from {request.remote_addr}") - return jsonify({'error': str(e)}), 400 - + update_metrics(response_time, success=False, error_type="validation_error") + logger.warning(f"Validation error: {e!s} from {request.remote_addr}") + return jsonify({"error": str(e)}), 400 + # Detect anomalies anomalies = input_sanitizer.detect_anomalies(data) if anomalies: logger.warning(f"Security anomalies detected: {anomalies}") with metrics_lock: - metrics['security_violations'] += 1 - + metrics["security_violations"] += 1 + # Make secure prediction model_instance = get_secure_model() - if not getattr(model_instance, 'loaded', False): - return jsonify({'error': 'Secure model not loaded'}), 503 + if not getattr(model_instance, "loaded", False): + return jsonify({"error": "Secure model not loaded"}), 503 result = model_instance.predict( - sanitized_data['text'], - confidence_threshold=sanitized_data.get('confidence_threshold') + sanitized_data["text"], + confidence_threshold=sanitized_data.get("confidence_threshold"), ) - + # Add sanitization warnings to response if warnings: - result['security']['sanitization_warnings'] = warnings - + result["security"]["sanitization_warnings"] = warnings + response_time = time.time() - start_time update_metrics( - response_time, - success=True, - emotion=result['predicted_emotion'], - sanitization_warnings=len(warnings) + response_time, + success=True, + emotion=result["predicted_emotion"], + sanitization_warnings=len(warnings), ) - + return jsonify(result) - + except Exception as e: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='prediction_error') - logger.error(f"Secure prediction endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 + update_metrics(response_time, success=False, error_type="prediction_error") + logger.error(f"Secure prediction endpoint error: {e!s}", exc_info=True) + return jsonify({"error": "Prediction failed"}), 500 + -@app.route('/predict_batch', methods=['POST']) +@app.route("/predict_batch", methods=["POST"]) @secure_endpoint def predict_batch(): """Secure batch prediction endpoint.""" start_time = time.time() - + try: # Parse and validate request data try: data = request.get_json() except werkzeug.exceptions.BadRequest: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='invalid_json') + update_metrics(response_time, success=False, error_type="invalid_json") logger.error(f"Invalid JSON in batch request from {request.remote_addr}") - return jsonify({'error': 'Invalid JSON format'}), 400 - + return jsonify({"error": "Invalid JSON format"}), 400 + if not data: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='missing_data') - return jsonify({'error': 'No data provided'}), 400 - + update_metrics(response_time, success=False, error_type="missing_data") + return jsonify({"error": "No data provided"}), 400 + # Sanitize and validate request try: sanitized_data, warnings = input_sanitizer.validate_batch_request(data) except ValueError as e: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='validation_error') - logger.warning(f"Batch validation error: {str(e)} from {request.remote_addr}") - return jsonify({'error': str(e)}), 400 - + update_metrics(response_time, success=False, error_type="validation_error") + logger.warning(f"Batch validation error: {e!s} from {request.remote_addr}") + return jsonify({"error": str(e)}), 400 + # Detect anomalies anomalies = input_sanitizer.detect_anomalies(data) if anomalies: logger.warning(f"Security anomalies detected in batch: {anomalies}") with metrics_lock: - metrics['security_violations'] += 1 - + metrics["security_violations"] += 1 + # Make secure batch predictions results = [] model_instance = get_secure_model() - if not getattr(model_instance, 'loaded', False): - return jsonify({'error': 'Secure model not loaded'}), 503 - for text in sanitized_data['texts']: + if not getattr(model_instance, "loaded", False): + return jsonify({"error": "Secure model not loaded"}), 503 + for text in sanitized_data["texts"]: if text.strip(): result = model_instance.predict( text, - confidence_threshold=sanitized_data.get('confidence_threshold') + confidence_threshold=sanitized_data.get("confidence_threshold"), ) results.append(result) - + response_time = time.time() - start_time - update_metrics( - response_time, - success=True, - sanitization_warnings=len(warnings) - ) - - return jsonify({ - 'predictions': results, - 'count': len(results), - 'batch_processing_time_ms': round(response_time * 1000, 2), - 'security': { - 'sanitization_warnings': warnings, - 'request_id': getattr(g, 'request_id', None), - 'correlation_id': getattr(g, 'correlation_id', None) + update_metrics(response_time, success=True, sanitization_warnings=len(warnings)) + + return jsonify( + { + "predictions": results, + "count": len(results), + "batch_processing_time_ms": round(response_time * 1000, 2), + "security": { + "sanitization_warnings": warnings, + "request_id": getattr(g, "request_id", None), + "correlation_id": getattr(g, "correlation_id", None), + }, } - }) - + ) + except Exception as e: response_time = time.time() - start_time - update_metrics( - response_time, success=False, error_type='batch_prediction_error' - ) + update_metrics(response_time, success=False, error_type="batch_prediction_error") logger.error("NLP emotion batch error: %s", e) - return jsonify({'error': 'An internal server error occurred.'}), 500 + return jsonify({"error": "An internal server error occurred."}), 500 -@app.route('/nlp/emotion', methods=['POST']) +@app.route("/nlp/emotion", methods=["POST"]) @secure_endpoint def nlp_emotion(): """Classify emotion distribution for a single input text.""" @@ -764,14 +808,14 @@ def nlp_emotion(): service = get_emotion_service() except (ImportError, ValueError): response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='provider_error') + update_metrics(response_time, success=False, error_type="provider_error") logger.exception("Emotion provider misconfiguration") - return jsonify({'error': 'Emotion provider misconfiguration.'}), 503 + return jsonify({"error": "Emotion provider misconfiguration."}), 503 except Exception: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='provider_error') + update_metrics(response_time, success=False, error_type="provider_error") logger.exception("Unknown provider error in /nlp/emotion") - return jsonify({'error': 'Internal server error'}), 500 + return jsonify({"error": "Internal server error"}), 500 results = service.classify(sanitized_text) dist = _validate_single_results_or_raise(results) @@ -780,29 +824,29 @@ def nlp_emotion(): # Update distribution metric by top label try: - top = max(dist, key=lambda x: x.get('score', 0.0)) if dist else None + top = max(dist, key=lambda x: x.get("score", 0.0)) if dist else None update_metrics( time.time() - start_time, success=True, - emotion=(top.get('label') if top else None), - sanitization_warnings=len(warnings) + emotion=(top.get("label") if top else None), + sanitization_warnings=len(warnings), ) except Exception: update_metrics( time.time() - start_time, success=True, - sanitization_warnings=len(warnings) + sanitization_warnings=len(warnings), ) return jsonify(response) except Exception: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='prediction_error') + update_metrics(response_time, success=False, error_type="prediction_error") logger.exception("NLP emotion error") - return jsonify({'error': 'An internal error occurred.'}), 500 + return jsonify({"error": "An internal error occurred."}), 500 -@app.route('/nlp/emotion/batch', methods=['POST']) +@app.route("/nlp/emotion/batch", methods=["POST"]) @secure_endpoint def nlp_emotion_batch(): """Classify emotion distributions for a batch of input texts.""" @@ -811,9 +855,7 @@ def nlp_emotion_batch(): data = _get_json_payload_or_raise() _original_texts, texts, num_filtered = _extract_and_filter_texts_or_raise(data) if num_filtered > 0: - logger.warning( - "%s invalid texts filtered out from input batch.", num_filtered - ) + logger.warning("%s invalid texts filtered out from input batch.", num_filtered) sanitized, total_warnings = _sanitize_texts_batch(texts) @@ -821,18 +863,14 @@ def nlp_emotion_batch(): service = get_emotion_service() except (ImportError, ValueError): response_time = time.time() - start_time - update_metrics( - response_time, success=False, error_type='provider_error' - ) + update_metrics(response_time, success=False, error_type="provider_error") logger.exception("Emotion provider misconfiguration") - return jsonify({'error': 'Emotion provider misconfiguration.'}), 503 + return jsonify({"error": "Emotion provider misconfiguration."}), 503 except Exception: response_time = time.time() - start_time - update_metrics( - response_time, success=False, error_type='provider_error' - ) + update_metrics(response_time, success=False, error_type="provider_error") logger.exception("Unknown provider error in /nlp/emotion/batch") - return jsonify({'error': 'Internal server error'}), 500 + return jsonify({"error": "Internal server error"}), 500 results = service.classify(sanitized) _validate_alignment_count_or_raise(results, len(sanitized)) @@ -841,205 +879,216 @@ def nlp_emotion_batch(): for text, dist in zip(sanitized, results): dist = dist if isinstance(dist, list) else [] top = ( - max(dist, key=lambda x: x.get('score', 0.0)) - if dist else {'label': 'unknown', 'score': 0.0} + max(dist, key=lambda x: x.get("score", 0.0)) + if dist + else {"label": "unknown", "score": 0.0} + ) + responses.append( + { + "text": text, + "scores": dist, + "top_label": top.get("label"), + "top_score": top.get("score"), + } ) - responses.append({ - 'text': text, - 'scores': dist, - 'top_label': top.get('label'), - 'top_score': top.get('score') - }) response_time = time.time() - start_time try: first_top = ( - max(results[0], key=lambda x: x.get('score', 0.0)) - if results and results[0] else None + max(results[0], key=lambda x: x.get("score", 0.0)) + if results and results[0] + else None ) update_metrics( response_time, success=True, - emotion=(first_top.get('label') if first_top else None), - sanitization_warnings=total_warnings + emotion=(first_top.get("label") if first_top else None), + sanitization_warnings=total_warnings, ) except Exception: - update_metrics( - response_time, success=True, sanitization_warnings=total_warnings - ) - - return jsonify({ - 'results': responses, - 'count': len(responses), - 'provider': os.environ.get("EMOTION_PROVIDER", EMOTION_PROVIDER).lower(), - 'provider_info': _build_provider_info(), - 'batch_processing_time_ms': round(response_time * 1000, 2), - 'security': { - 'sanitization_warnings': total_warnings, - 'request_id': getattr(g, 'request_id', None), - 'correlation_id': getattr(g, 'correlation_id', None) + update_metrics(response_time, success=True, sanitization_warnings=total_warnings) + + return jsonify( + { + "results": responses, + "count": len(responses), + "provider": os.environ.get("EMOTION_PROVIDER", EMOTION_PROVIDER).lower(), + "provider_info": _build_provider_info(), + "batch_processing_time_ms": round(response_time * 1000, 2), + "security": { + "sanitization_warnings": total_warnings, + "request_id": getattr(g, "request_id", None), + "correlation_id": getattr(g, "correlation_id", None), + }, } - }) + ) except _ClientError as ce: response_time = time.time() - start_time update_metrics(response_time, success=False, error_type=ce.error_type) - if ce.error_type == 'provider_misalignment': + if ce.error_type == "provider_misalignment": logger.error(ce.message) else: logger.warning(ce.message) - return jsonify({'error': ce.message}), ce.status_code + return jsonify({"error": ce.message}), ce.status_code except Exception as e: response_time = time.time() - start_time - update_metrics( - response_time, success=False, error_type='batch_prediction_error' - ) + update_metrics(response_time, success=False, error_type="batch_prediction_error") logger.error("NLP emotion batch error: %s", e) - return jsonify({'error': "An internal error has occurred."}), 500 + return jsonify({"error": "An internal error has occurred."}), 500 + -@app.route('/metrics', methods=['GET']) +@app.route("/metrics", methods=["GET"]) def get_metrics(): """Get detailed security metrics endpoint.""" with metrics_lock: - return jsonify({ - 'server_metrics': { - 'uptime_seconds': (datetime.now() - metrics['start_time']).total_seconds(), - 'total_requests': metrics['total_requests'], - 'successful_requests': metrics['successful_requests'], - 'failed_requests': metrics['failed_requests'], - 'rate_limited_requests': metrics['rate_limited_requests'], - 'sanitization_warnings': metrics['sanitization_warnings'], - 'security_violations': metrics['security_violations'], - 'success_rate': f"{(metrics['successful_requests'] / max(metrics['total_requests'], 1)) * 100:.2f}%", - 'average_response_time_ms': round(metrics['average_response_time'] * 1000, 2), - 'requests_per_minute': metrics['total_requests'] / max((datetime.now() - metrics['start_time']).total_seconds() / 60, 1) - }, - 'emotion_distribution': dict(metrics['emotion_distribution']), - 'error_counts': dict(metrics['error_counts']), - 'security': { - 'rate_limiting': rate_limiter.get_stats(), - 'sanitization': input_sanitizer.get_sanitization_stats(), - 'security_headers': security_middleware.get_security_stats() + return jsonify( + { + "server_metrics": { + "uptime_seconds": (datetime.now() - metrics["start_time"]).total_seconds(), + "total_requests": metrics["total_requests"], + "successful_requests": metrics["successful_requests"], + "failed_requests": metrics["failed_requests"], + "rate_limited_requests": metrics["rate_limited_requests"], + "sanitization_warnings": metrics["sanitization_warnings"], + "security_violations": metrics["security_violations"], + "success_rate": f"{(metrics['successful_requests'] / max(metrics['total_requests'], 1)) * 100:.2f}%", + "average_response_time_ms": round(metrics["average_response_time"] * 1000, 2), + "requests_per_minute": metrics["total_requests"] + / max((datetime.now() - metrics["start_time"]).total_seconds() / 60, 1), + }, + "emotion_distribution": dict(metrics["emotion_distribution"]), + "error_counts": dict(metrics["error_counts"]), + "security": { + "rate_limiting": rate_limiter.get_stats(), + "sanitization": input_sanitizer.get_sanitization_stats(), + "security_headers": security_middleware.get_security_stats(), + }, } - }) + ) -@app.route('/security/blacklist', methods=['POST']) + +@app.route("/security/blacklist", methods=["POST"]) @require_admin_api_key def add_to_blacklist(): """Add IP to blacklist (admin endpoint).""" try: data = request.get_json() - if not data or 'ip' not in data: - return jsonify({'error': 'IP address required'}), 400 - - ip = data['ip'] + if not data or "ip" not in data: + return jsonify({"error": "IP address required"}), 400 + + ip = data["ip"] rate_limiter.add_to_blacklist(ip) logger.info(f"Added {ip} to blacklist") - return jsonify({'message': f'Added {ip} to blacklist'}) + return jsonify({"message": f"Added {ip} to blacklist"}) except Exception as e: - logger.error(f"Blacklist error: {str(e)}") - return jsonify({'error': str(e)}), 500 + logger.error(f"Blacklist error: {e!s}", exc_info=True) + return jsonify({"error": "Blacklist operation failed"}), 500 -@app.route('/security/whitelist', methods=['POST']) + +@app.route("/security/whitelist", methods=["POST"]) @require_admin_api_key def add_to_whitelist(): """Add IP to whitelist (admin endpoint).""" try: data = request.get_json() - if not data or 'ip' not in data: - return jsonify({'error': 'IP address required'}), 400 - - ip = data['ip'] + if not data or "ip" not in data: + return jsonify({"error": "IP address required"}), 400 + + ip = data["ip"] rate_limiter.add_to_whitelist(ip) logger.info(f"Added {ip} to whitelist") - return jsonify({'message': f'Added {ip} to whitelist'}) + return jsonify({"message": f"Added {ip} to whitelist"}) except Exception as e: - logger.error(f"Whitelist error: {str(e)}") - return jsonify({'error': str(e)}), 500 + logger.error(f"Whitelist error: {e!s}", exc_info=True) + return jsonify({"error": "Whitelist operation failed"}), 500 -@app.route('/', methods=['GET']) + +@app.route("/", methods=["GET"]) @secure_endpoint def home(): """Secure home endpoint with API documentation.""" start_time = time.time() - + try: response = { - 'message': 'Secure Emotion Detection API', - 'version': '2.0', - 'security_features': { - 'rate_limiting': f'{rate_limit_config.requests_per_minute} requests per minute', - 'input_sanitization': 'XSS, SQL injection, and command injection protection', - 'security_headers': 'CSP, HSTS, X-Frame-Options, and more', - 'abuse_detection': 'Automatic blocking of abusive clients', - 'request_correlation': 'Request ID and correlation ID tracking', - 'audit_logging': 'Comprehensive security event logging' + "message": "Secure Emotion Detection API", + "version": "2.0", + "security_features": { + "rate_limiting": f"{rate_limit_config.requests_per_minute} requests per minute", + "input_sanitization": "XSS, SQL injection, and command injection protection", + "security_headers": "CSP, HSTS, X-Frame-Options, and more", + "abuse_detection": "Automatic blocking of abusive clients", + "request_correlation": "Request ID and correlation ID tracking", + "audit_logging": "Comprehensive security event logging", }, - 'endpoints': { - 'GET /': 'This documentation', - 'GET /health': 'Health check with security metrics', - 'GET /metrics': 'Detailed security metrics', - 'POST /predict': 'Secure single prediction', - 'POST /predict_batch': 'Secure batch prediction', - 'POST /nlp/emotion': 'HF-backed text emotion classification', - 'POST /nlp/emotion/batch': ( - 'HF-backed batch text emotion classification' - ), - 'POST /security/blacklist': 'Add IP to blacklist (admin)', - 'POST /security/whitelist': 'Add IP to whitelist (admin)' + "endpoints": { + "GET /": "This documentation", + "GET /health": "Health check with security metrics", + "GET /metrics": "Detailed security metrics", + "POST /predict": "Secure single prediction", + "POST /predict_batch": "Secure batch prediction", + "POST /nlp/emotion": "HF-backed text emotion classification", + "POST /nlp/emotion/batch": ("HF-backed batch text emotion classification"), + "POST /security/blacklist": "Add IP to blacklist (admin)", + "POST /security/whitelist": "Add IP to whitelist (admin)", }, - 'model_info': { - 'emotions': getattr(get_secure_model(), 'emotions', []), - 'performance': { - 'basic_accuracy': '100.00%', - 'real_world_accuracy': '93.75%', - 'average_confidence': '83.9%' - } + "model_info": { + "emotions": getattr(get_secure_model(), "emotions", []), + "performance": { + "basic_accuracy": "100.00%", + "real_world_accuracy": "93.75%", + "average_confidence": "83.9%", + }, }, - 'example_usage': { - 'single_prediction': { - 'url': 'POST /predict', - 'body': '{"text": "I am feeling happy today!"}', - 'headers': '{"Content-Type": "application/json"}' + "example_usage": { + "single_prediction": { + "url": "POST /predict", + "body": '{"text": "I am feeling happy today!"}', + "headers": '{"Content-Type": "application/json"}', }, - 'batch_prediction': { - 'url': 'POST /predict_batch', - 'body': '{"texts": ["I am happy", "I feel sad", "I am excited"]}', - 'headers': '{"Content-Type": "application/json"}' - } - } + "batch_prediction": { + "url": "POST /predict_batch", + "body": '{"texts": ["I am happy", "I feel sad", "I am excited"]}', + "headers": '{"Content-Type": "application/json"}', + }, + }, } - + response_time = time.time() - start_time update_metrics(response_time, success=True) - + return jsonify(response) - + except Exception as e: response_time = time.time() - start_time - update_metrics(response_time, success=False, error_type='documentation_error') - logger.error(f"Documentation endpoint error: {str(e)}") - return jsonify({'error': str(e)}), 500 + update_metrics(response_time, success=False, error_type="documentation_error") + logger.error(f"Documentation endpoint error: {e!s}", exc_info=True) + return jsonify({"error": "Documentation service unavailable"}), 500 + @app.errorhandler(werkzeug.exceptions.BadRequest) def handle_bad_request(e): """Handle BadRequest exceptions (invalid JSON, etc.).""" - logger.error(f"BadRequest error: {str(e)}") - update_metrics(0.0, success=False, error_type='invalid_json') - return jsonify({'error': 'Invalid JSON format'}), 400 + logger.error(f"BadRequest error: {e!s}") + update_metrics(0.0, success=False, error_type="invalid_json") + return jsonify({"error": "Invalid JSON format"}), 400 + @app.errorhandler(404) def handle_not_found(e): """Handle 404 errors.""" logger.warning(f"404 error: {request.path} from {request.remote_addr}") - return jsonify({'error': 'Endpoint not found'}), 404 + return jsonify({"error": "Endpoint not found"}), 404 + @app.errorhandler(500) def handle_internal_error(e): """Handle 500 errors.""" - logger.error(f"Internal server error: {str(e)}") - return jsonify({'error': 'Internal server error'}), 500 + logger.error(f"Internal server error: {e!s}") + return jsonify({"error": "Internal server error"}), 500 -if __name__ == '__main__': + +if __name__ == "__main__": logger.info("🔒 Starting Secure Emotion Detection API Server") logger.info("=" * 60) logger.info("🛡️ Security Features Enabled:") @@ -1064,10 +1113,23 @@ def handle_internal_error(e): logger.info("📝 Example usage:") logger.info(" curl -X POST http://localhost:8000/predict \\") logger.info(" -H 'Content-Type: application/json' \\") - logger.info(" -d '{\"text\": \"I am feeling happy today!\"}'") + logger.info(' -d \'{"text": "I am feeling happy today!"}\'') logger.info("") logger.info(f"🔒 Rate limiting: {rate_limit_config.requests_per_minute} requests per minute") logger.info("🛡️ Security monitoring: Comprehensive logging and metrics enabled") logger.info("=" * 60) - - app.run(host='0.0.0.0', port=8000, debug=False) \ No newline at end of file + + # Use centralized security-first host binding configuration + from src.security.host_binding import ( + get_secure_host_binding, + validate_host_binding, + get_binding_security_summary, + ) + + host, port = get_secure_host_binding(default_port=8000) + validate_host_binding(host, port) + + security_summary = get_binding_security_summary(host, port) + logger.info("Security Summary: %s", security_summary) + + app.run(host=host, port=port, debug=False) diff --git a/deployment/test_examples.py b/deployment/test_examples.py index fa1cb949f..c2f976ba9 100644 --- a/deployment/test_examples.py +++ b/deployment/test_examples.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 -""" -🧪 TEST EMOTION DETECTION MODEL +"""🧪 TEST EMOTION DETECTION MODEL =============================== Test the trained model with various examples. """ from inference import EmotionDetector + def test_model(): """Test the emotion detection model""" print("🧪 EMOTION DETECTION MODEL TESTING") print("=" * 50) - + # Initialize detector try: detector = EmotionDetector() @@ -19,7 +19,7 @@ def test_model(): except Exception as e: print(f"❌ Failed to load model: {e}") return - + # Test cases test_cases = [ # Happy emotions @@ -27,39 +27,35 @@ def test_model(): "I'm excited about the new opportunities ahead.", "I'm grateful for all the support I've received.", "I'm proud of what I've accomplished so far.", - # Negative emotions "I'm so frustrated with this project. Nothing is working.", "I feel anxious about the upcoming presentation.", "I'm feeling sad and lonely today.", "I'm feeling overwhelmed with all these tasks.", - # Neutral emotions "I feel calm and peaceful right now.", "I'm content with how things are going.", "I'm hopeful that things will get better.", - "I'm tired and need some rest." + "I'm tired and need some rest.", ] - + print("\n📊 Testing Results:") print("=" * 50) - - correct_predictions = 0 - total_predictions = len(test_cases) - + + len(test_cases) + for i, text in enumerate(test_cases, 1): result = detector.predict(text) - + print(f"{i:2d}. Text: {text}") print(f" Predicted: {result['emotion']} (confidence: {result['confidence']:.3f})") - - # Show top 3 predictions - sorted_probs = sorted(result['probabilities'].items(), key=lambda x: x[1], reverse=True) - print(f" Top 3: {', '.join([f'{emotion}({prob:.3f})' for emotion, prob in sorted_probs[:3]])}") print() - + print("🎉 Testing completed!") - print(f"📊 Model confidence range: {min([detector.predict(text)['confidence'] for text in test_cases]):.3f} - {max([detector.predict(text)['confidence'] for text in test_cases]):.3f}") + print( + f"📊 Model confidence range: {min([detector.predict(text)['confidence'] for text in test_cases]):.3f} - {max([detector.predict(text)['confidence'] for text in test_cases]):.3f}" + ) + if __name__ == "__main__": test_model() diff --git a/docs/.code-review.md b/docs/.code-review.md index 6e0f97e96..5059bcd50 100644 --- a/docs/.code-review.md +++ b/docs/.code-review.md @@ -152,7 +152,7 @@ - **Problem**: Installing curl adds unnecessary attack surface - **Solution**: Replaced curl health check with Python-based approach using `urllib.request` - **File**: `deployment/gcp/Dockerfile` -- **Change**: +- **Change**: ```dockerfile # Before: RUN apt-get install -y curl # After: Removed curl installation diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/DEPLOYMENT_GUIDE.md index 188531ed6..916725988 100644 --- a/docs/DEPLOYMENT_GUIDE.md +++ b/docs/DEPLOYMENT_GUIDE.md @@ -34,7 +34,7 @@ This guide covers deployment of the SAMO Emotion Detection API for both local de # Create virtual environment python -m venv .venv source .venv/bin/activate # On Windows: .venv\Scripts\activate - + # Install dependencies pip install -r requirements.txt ``` @@ -356,10 +356,10 @@ CMD ["gunicorn", "--bind", "0.0.0.0:8000", "api_server:app"] ```bash # Build image docker build -t samo-emotion-api . - + # Tag for Azure docker tag samo-emotion-api your-registry.azurecr.io/samo-emotion-api:latest - + # Push to Azure Container Registry docker push your-registry.azurecr.io/samo-emotion-api:latest ``` @@ -491,7 +491,7 @@ LOG_LEVEL=INFO 2. **ONNX Export** ```python import torch.onnx - + # Export model to ONNX torch.onnx.export(model, dummy_input, "model.onnx") ``` @@ -507,9 +507,9 @@ LOG_LEVEL=INFO 2. **Enable Caching** ```python from flask_caching import Cache - + cache = Cache(app, config={'CACHE_TYPE': 'simple'}) - + @cache.memoize(timeout=300) def cached_predict(text): return model.predict(text) @@ -548,7 +548,7 @@ cp local_deployment/api_server.py.backup local_deployment/api_server.py server 127.0.0.1:8001; server 127.0.0.1:8002; } - + server { listen 80; location / { @@ -571,7 +571,7 @@ cp local_deployment/api_server.py.backup local_deployment/api_server.py ```bash # For Docker docker run -p 8000:8000 --memory=4g --cpus=2 samo-emotion-api - + # For Kubernetes resources: requests: @@ -615,10 +615,10 @@ cp local_deployment/api_server.py.backup local_deployment/api_server.py ```bash # Backup current model cp -r local_deployment/model local_deployment/model_backup_$(date +%Y%m%d) - + # Deploy new model cp -r new_model/* local_deployment/model/ - + # Restart server pkill -f api_server python api_server.py & @@ -628,10 +628,10 @@ cp local_deployment/api_server.py.backup local_deployment/api_server.py ```bash # Pull latest code git pull origin main - + # Update dependencies pip install -r requirements.txt - + # Restart server pkill -f api_server python api_server.py & diff --git a/docs/SAMO-DL-PRD.md b/docs/SAMO-DL-PRD.md index f3ce93850..5ac494e00 100644 --- a/docs/SAMO-DL-PRD.md +++ b/docs/SAMO-DL-PRD.md @@ -253,7 +253,7 @@ The SAMO Deep Learning track is responsible for building the core AI intelligenc #### Emotion Detection Pipeline (Colab-trained model) -- **Base Model**: `DistilRoBERTa` fine-tuned on custom dataset +- **Base Model**: `DistilRoBERTa` fine-tuned on custom dataset - **Output**: 12-dimensional probability vector for journal-optimized emotions - **Preprocessing**: Tokenization with 128 max sequence length - **Training Strategy**: Transfer learning with focal loss and class weighting @@ -509,7 +509,7 @@ Response: - **Metrics**: `GET /metrics` - Prometheus monitoring metrics **Model Details**: -- **Architecture**: DistilRoBERTa +- **Architecture**: DistilRoBERTa - **Emotions**: 12 classes (anxious, calm, content, excited, frustrated, grateful, happy, hopeful, overwhelmed, proud, sad, tired) - **Performance**: 90.70% accuracy, 0.1-0.6s inference time - **Training**: 240+ samples with augmentation, 5 epochs, focal loss @@ -548,14 +548,14 @@ The Deep Learning track will be considered successful when: ## **📊 Current Development Session Summary - Code Review Excellence** -**Session Date**: Current Development Session -**Focus Area**: Comprehensive Code Review & Quality Assurance +**Session Date**: Current Development Session +**Focus Area**: Comprehensive Code Review & Quality Assurance **Status**: ✅ **COMPLETED** - All Critical Issues Resolved ### **🎯 Session Objectives Achieved** -**Primary Goal**: Conduct systematic code review to identify and resolve quality issues while maintaining 100% production uptime -**Secondary Goal**: Enhance code robustness and prepare foundation for tomorrow's critical features +**Primary Goal**: Conduct systematic code review to identify and resolve quality issues while maintaining 100% production uptime +**Secondary Goal**: Enhance code robustness and prepare foundation for tomorrow's critical features **Result**: ✅ **100% SUCCESS** - All 6 critical and medium-priority issues resolved ### **🔧 Technical Achievements** diff --git a/docs/api/API_DOCUMENTATION.md b/docs/api/API_DOCUMENTATION.md index 25af658e0..3cf6b5a4e 100644 --- a/docs/api/API_DOCUMENTATION.md +++ b/docs/api/API_DOCUMENTATION.md @@ -254,7 +254,7 @@ def detect_emotion(text: str) -> dict: url = "https://samo-emotion-api-xxxxx-ew.a.run.app/predict" headers = {"Content-Type": "application/json"} data = {"text": text} - + try: response = requests.post(url, json=data, headers=headers, timeout=10) response.raise_for_status() @@ -280,14 +280,14 @@ async function detectEmotion(text) { }, body: JSON.stringify({ text }) }; - + try { const response = await fetch(url, options); - + if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - + return await response.json(); } catch (error) { console.error('API Error:', error); @@ -464,4 +464,4 @@ scrape_configs: ## License -This API is part of the SAMO-DL project. See the main project repository for licensing information. \ No newline at end of file +This API is part of the SAMO-DL project. See the main project repository for licensing information. \ No newline at end of file diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6d9b3d70d..2b03c4a95 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3,27 +3,27 @@ info: title: SAMO-DL Emotion Detection API description: | # SAMO-DL Emotion Detection API - + A production-ready API for emotion detection using advanced deep learning models. - + ## Features - Real-time emotion detection from text - Batch processing capabilities - High accuracy (99.48% F1 Score) - Production-grade security and monitoring - + ## Supported Emotions - anxious, calm, content, excited, frustrated, grateful - happy, hopeful, overwhelmed, proud, sad, tired - + ## Authentication This API requires authentication using API keys. Include your API key in the `X-API-Key` header. - + ## Rate Limiting - 60 requests per minute per API key - 100 requests per hour per user - Batch requests count as individual requests - + ## Security - All endpoints use HTTPS - Input validation and sanitization @@ -374,4 +374,4 @@ tags: - name: Prediction description: Emotion prediction endpoints - name: Information - description: Information and status endpoints \ No newline at end of file + description: Information and status endpoints \ No newline at end of file diff --git a/docs/ci/CI_PIPELINE_GUIDE.md b/docs/ci/CI_PIPELINE_GUIDE.md index 29fe3e1a9..87629c583 100644 --- a/docs/ci/CI_PIPELINE_GUIDE.md +++ b/docs/ci/CI_PIPELINE_GUIDE.md @@ -246,6 +246,6 @@ git push origin feature/new-feature --- -**Last Updated**: July 31, 2025 -**Version**: 1.0.0 -**Status**: Production Ready ✅ \ No newline at end of file +**Last Updated**: July 31, 2025 +**Version**: 1.0.0 +**Status**: Production Ready ✅ \ No newline at end of file diff --git a/docs/ci/ci-fixes-summary.md b/docs/ci/ci-fixes-summary.md index a6886aa80..9e38f131e 100644 --- a/docs/ci/ci-fixes-summary.md +++ b/docs/ci/ci-fixes-summary.md @@ -2,11 +2,11 @@ ## Executive Summary -**Date:** August 5, 2025 -**Status:** CRITICAL FIX APPLIED - CI Pipeline Broken -**Root Cause:** Conda command not found in PATH during CircleCI execution -**Impact:** All conda-dependent jobs failing (unit-tests, lint-and-format, etc.) -**Resolution:** Updated CircleCI config to use full conda path +**Date:** August 5, 2025 +**Status:** CRITICAL FIX APPLIED - CI Pipeline Broken +**Root Cause:** Conda command not found in PATH during CircleCI execution +**Impact:** All conda-dependent jobs failing (unit-tests, lint-and-format, etc.) +**Resolution:** Updated CircleCI config to use full conda path ## What We Just Did diff --git a/docs/ci/circleci-debug-prompt.md b/docs/ci/circleci-debug-prompt.md index 035bb4460..ff23a2580 100644 --- a/docs/ci/circleci-debug-prompt.md +++ b/docs/ci/circleci-debug-prompt.md @@ -99,7 +99,7 @@ For each identified issue: Fix Type: [code/config/dependency/resource] Files to modify: - [FILE_PATH] - + Changes needed: [SPECIFIC CHANGES] ``` diff --git a/docs/ci/circleci-fix-summary.md b/docs/ci/circleci-fix-summary.md index 78c5a74d3..b19e6afc1 100644 --- a/docs/ci/circleci-fix-summary.md +++ b/docs/ci/circleci-fix-summary.md @@ -40,7 +40,7 @@ run_in_conda: ### **All `run_in_conda` Usages Updated** - ✅ Pre-warm Models -- ✅ Ruff Linting +- ✅ Ruff Linting - ✅ Ruff Formatting Check - ✅ Type Checking (MyPy) - ✅ Bandit Security Scan @@ -88,7 +88,7 @@ run_in_conda: 3. **Test Pipeline Stages** - Stage 1: Linting and unit tests (<3 minutes) - - Stage 2: Integration and security tests (<8 minutes) + - Stage 2: Integration and security tests (<8 minutes) - Stage 3: E2E tests and performance (<15 minutes) ## 📝 Documentation Updated @@ -102,7 +102,7 @@ run_in_conda: ### **CircleCI Parameter Restrictions** CircleCI reserves these parameter names and they cannot be used in custom command definitions: - `name` -- `command` +- `command` - `shell` - `environment` - `working_directory` @@ -120,4 +120,4 @@ CircleCI reserves these parameter names and they cannot be used in custom comman **Status**: ✅ **CRITICAL FIX COMPLETE** - Ready for testing **Priority**: 🔴 **HIGH** - Blocking all CI/CD operations -**Next Action**: Push changes and monitor CircleCI pipeline \ No newline at end of file +**Next Action**: Push changes and monitor CircleCI pipeline \ No newline at end of file diff --git a/docs/colab-gpu-development-guide.md b/docs/colab-gpu-development-guide.md index fe359be76..95b90ead1 100644 --- a/docs/colab-gpu-development-guide.md +++ b/docs/colab-gpu-development-guide.md @@ -77,12 +77,12 @@ class DomainAdaptedEmotionClassifier(nn.Module): super().__init__() self.bert = AutoModel.from_pretrained(model_name) self.dropout = nn.Dropout(dropout) - + # FIXED: Use dynamic num_labels instead of hardcoded 12 if num_labels is None: num_labels = 12 # Default fallback self.num_labels = num_labels - + self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels) # ... rest of the model @@ -120,19 +120,19 @@ The critical insight driving REQ-DL-012: ```python class DomainAdaptedEmotionClassifier(nn.Module): """BERT-based emotion classifier with domain adaptation capabilities.""" - + def __init__(self, model_name="bert-base-uncased", num_labels=None, dropout=0.3): super().__init__() self.bert = AutoModel.from_pretrained(model_name) self.dropout = nn.Dropout(dropout) - + # FIXED: Use dynamic num_labels instead of hardcoded 12 if num_labels is None: num_labels = 12 # Default fallback self.num_labels = num_labels - + self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels) - + # Domain adaptation layer self.domain_classifier = nn.Sequential( nn.Linear(self.bert.config.hidden_size, 512), @@ -140,17 +140,17 @@ class DomainAdaptedEmotionClassifier(nn.Module): nn.Dropout(0.3), nn.Linear(512, 2) # 2 domains: GoEmotions vs Journal ) - + def forward(self, input_ids, attention_mask, domain_labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) pooled_output = outputs.pooler_output - + # Emotion classification emotion_logits = self.classifier(self.dropout(pooled_output)) - + # Domain classification (for domain adaptation) domain_logits = self.domain_classifier(pooled_output) - + if domain_labels is not None: return emotion_logits, domain_logits return emotion_logits @@ -161,18 +161,18 @@ class DomainAdaptedEmotionClassifier(nn.Module): ```python class FocalLoss(nn.Module): """Focal Loss for addressing class imbalance in emotion detection.""" - + def __init__(self, alpha=1, gamma=2, reduction='mean'): super(FocalLoss, self).__init__() self.alpha = alpha self.gamma = gamma self.reduction = reduction - + def forward(self, inputs, targets): ce_loss = F.cross_entropy(inputs, targets, reduction='none') pt = torch.exp(-ce_loss) focal_loss = self.alpha * (1 - pt) ** self.gamma * ce_loss - + if self.reduction == 'mean': return focal_loss.mean() elif self.reduction == 'sum': @@ -217,9 +217,9 @@ combined_dataset = ConcatDataset([go_dataset, journal_dataset]) def analyze_writing_style(texts, domain_name): avg_length = np.mean([len(text.split()) for text in texts]) personal_pronouns = sum(['I ' in text or 'my ' in text for text in texts]) / len(texts) - reflection_words = sum(['think' in text.lower() or 'feel' in text.lower() + reflection_words = sum(['think' in text.lower() or 'feel' in text.lower() for text in texts]) / len(texts) - + print(f"{domain_name} Style Analysis:") print(f" Average length: {avg_length:.1f} words") print(f" Personal pronouns: {personal_pronouns:.1%}") @@ -234,12 +234,12 @@ for epoch in range(num_epochs): for batch in go_loader: domain_labels = torch.zeros(batch['input_ids'].size(0), dtype=torch.long) losses = trainer.train_step(batch, domain_labels, lambda_domain=0.1) - + # Train on journal data for batch in journal_train_loader: domain_labels = torch.ones(batch['input_ids'].size(0), dtype=torch.long) losses = trainer.train_step(batch, domain_labels, lambda_domain=0.1) - + # Validate on journal test set val_results = trainer.evaluate(journal_val_loader) print(f"Epoch {epoch}: F1 = {val_results['f1_macro']:.4f}") @@ -252,23 +252,23 @@ def calibrate_model(model, val_loader): model.eval() logits_list = [] labels_list = [] - + with torch.no_grad(): for batch in val_loader: logits = model(batch['input_ids'], batch['attention_mask']) logits_list.append(logits) labels_list.append(batch['labels']) - + # Fit temperature scaling temperature = nn.Parameter(torch.ones(1) * 1.5) optimizer = torch.optim.LBFGS([temperature], lr=0.01, max_iter=50) - + def eval(): optimizer.zero_grad() loss = F.cross_entropy(logits / temperature, labels) loss.backward() return loss - + optimizer.step(eval) return temperature.item() ``` @@ -372,10 +372,10 @@ class GradientReversalLayer(nn.Module): def __init__(self, alpha=1.0): super().__init__() self.alpha = alpha - + def forward(self, x): return x - + def backward(self, grad_output): return -self.alpha * grad_output @@ -470,26 +470,26 @@ This script will: ```python def validate_req_dl_012(): """Comprehensive validation for REQ-DL-012.""" - + # Load best model model.load_state_dict(torch.load('best_domain_adapted_model.pth')) - + # Test on journal dataset journal_results = evaluate_on_journal_dataset(model) - + # Test on GoEmotions dataset go_emotions_results = evaluate_on_go_emotions_dataset(model) - + # Validate requirements journal_f1 = journal_results['f1_macro'] go_emotions_f1 = go_emotions_results['f1_macro'] - + print("🎯 REQ-DL-012 Validation Results:") print(f" Journal F1 Score: {journal_f1:.4f} (Target: ≥0.70)") print(f" GoEmotions F1 Score: {go_emotions_f1:.4f} (Target: ≥0.75)") print(f" Journal Target Met: {'✅' if journal_f1 >= 0.7 else '❌'}") print(f" GoEmotions Target Met: {'✅' if go_emotions_f1 >= 0.75 else '❌'}") - + return journal_f1 >= 0.7 and go_emotions_f1 >= 0.75 ``` @@ -566,8 +566,8 @@ If you encounter the `torch.sparse._triton_ops_meta` error: --- -**Last Updated**: July 31, 2025 -**Version**: 2.0.0 -**Status**: Fixed and Ready for Colab Development 🚀 -**Target**: REQ-DL-012 Domain Adaptation Success ✅ -**Critical Fixes**: PyTorch/Transformers compatibility, dynamic num_labels, comprehensive error handling \ No newline at end of file +**Last Updated**: July 31, 2025 +**Version**: 2.0.0 +**Status**: Fixed and Ready for Colab Development 🚀 +**Target**: REQ-DL-012 Domain Adaptation Success ✅ +**Critical Fixes**: PyTorch/Transformers compatibility, dynamic num_labels, comprehensive error handling \ No newline at end of file diff --git a/docs/deployment/CLOUD_BUILD_DEPLOYMENT.md b/docs/deployment/CLOUD_BUILD_DEPLOYMENT.md index debd17e63..7db579ae9 100644 --- a/docs/deployment/CLOUD_BUILD_DEPLOYMENT.md +++ b/docs/deployment/CLOUD_BUILD_DEPLOYMENT.md @@ -207,7 +207,7 @@ options: **Customization Options:** - **E2_STANDARD_2**: 2 vCPUs, 8GB RAM (~10-15 min build time) -- **E2_HIGHCPU_4**: 4 vCPUs, 16GB RAM (~7-10 min build time) +- **E2_HIGHCPU_4**: 4 vCPUs, 16GB RAM (~7-10 min build time) - **E2_HIGHCPU_8**: 8 vCPUs, 32GB RAM (~5-8 min build time) ## 🧪 Testing the Deployment diff --git a/docs/deployment/PRODUCTION_DEPLOYMENT_GUIDE.md b/docs/deployment/PRODUCTION_DEPLOYMENT_GUIDE.md index eb2dfc39d..5460e083f 100644 --- a/docs/deployment/PRODUCTION_DEPLOYMENT_GUIDE.md +++ b/docs/deployment/PRODUCTION_DEPLOYMENT_GUIDE.md @@ -303,10 +303,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - + - name: Build and push Docker image uses: docker/build-push-action@v4 with: @@ -314,7 +314,7 @@ jobs: file: ./deployment/cloud-run/Dockerfile push: true tags: gcr.io/${{ secrets.GCP_PROJECT_ID }}/samo-dl-api:${{ github.sha }} - + - name: Deploy to Cloud Run uses: google-github-actions/deploy-cloudrun@v1 with: @@ -457,4 +457,4 @@ conn.close() **Last Updated**: August 5, 2025 **Version**: 1.0.0 -**Maintainer**: SAMO-DL Team \ No newline at end of file +**Maintainer**: SAMO-DL Team \ No newline at end of file diff --git a/docs/expanded-training-next-steps.md b/docs/expanded-training-next-steps.md index cfaa4bef6..9e8a055a7 100644 --- a/docs/expanded-training-next-steps.md +++ b/docs/expanded-training-next-steps.md @@ -11,7 +11,7 @@ ## 🎯 Target & Expected Results **Primary Goal**: Achieve 75-85% F1 Score (8-18% improvement) -**Success Criteria**: +**Success Criteria**: - F1 Score ≥ 70% on journal entries - Eliminate all dependency conflicts - Achieve production-ready code quality @@ -233,7 +233,7 @@ The improved notebook (`notebooks/expanded_dataset_training_improved.ipynb`) is --- -**Last Updated**: August 3, 2025 -**Status**: Ready for Colab Execution 🚀 -**Target**: 75-85% F1 Score Achievement ✅ -**Confidence**: High (based on 67% baseline + 6.6x dataset expansion + optimizations) \ No newline at end of file +**Last Updated**: August 3, 2025 +**Status**: Ready for Colab Execution 🚀 +**Target**: 75-85% F1 Score Achievement ✅ +**Confidence**: High (based on 67% baseline + 6.6x dataset expansion + optimizations) \ No newline at end of file diff --git a/docs/guides/COLAB_TROUBLESHOOTING.md b/docs/guides/COLAB_TROUBLESHOOTING.md index d19325090..1a3619891 100644 --- a/docs/guides/COLAB_TROUBLESHOOTING.md +++ b/docs/guides/COLAB_TROUBLESHOOTING.md @@ -13,12 +13,12 @@ # Add this to your notebook to prevent disconnection import time import threading - + def keep_alive(): while True: time.sleep(60) print("Still alive...") - + # Start keep-alive thread thread = threading.Thread(target=keep_alive, daemon=True) thread.start() @@ -146,4 +146,4 @@ torch.cuda.empty_cache() --- -**Remember**: Most issues can be resolved by restarting the runtime and ensuring proper setup. Always save your work frequently! \ No newline at end of file +**Remember**: Most issues can be resolved by restarting the runtime and ensuring proper setup. Always save your work frequently! \ No newline at end of file diff --git a/docs/guides/GITHUB_PAGES_DEPLOYMENT.md b/docs/guides/GITHUB_PAGES_DEPLOYMENT.md index e0d8243de..22a19a471 100644 --- a/docs/guides/GITHUB_PAGES_DEPLOYMENT.md +++ b/docs/guides/GITHUB_PAGES_DEPLOYMENT.md @@ -118,4 +118,4 @@ If you encounter any issues: --- -**Your SAMO-DL website is now ready for professional portfolio presentation!** 🎉 \ No newline at end of file +**Your SAMO-DL website is now ready for professional portfolio presentation!** 🎉 \ No newline at end of file diff --git a/docs/guides/INTEGRATION_GUIDE.md b/docs/guides/INTEGRATION_GUIDE.md index 3e446a9e7..b769382c6 100644 --- a/docs/guides/INTEGRATION_GUIDE.md +++ b/docs/guides/INTEGRATION_GUIDE.md @@ -26,7 +26,7 @@ curl -X POST https://samo-emotion-api-xxxxx-ew.a.run.app/predict \ "confidence": 0.89 }, { - "emotion": "excitement", + "emotion": "excitement", "confidence": 0.76 } ] @@ -74,13 +74,13 @@ app = Flask(__name__) def analyze_user_feedback(): data = request.get_json() user_text = data.get('text', '') - + if not user_text: return jsonify({"error": "No text provided"}), 400 - + # Call SAMO-DL API emotions = detect_emotion(user_text) - + return jsonify({ "user_text": user_text, "emotions": emotions, @@ -104,18 +104,18 @@ def analyze_emotion(request): if request.method == 'POST': data = json.loads(request.body) text = data.get('text', '') - + if not text: return JsonResponse({"error": "No text provided"}, status=400) - + # Call SAMO-DL API emotions = detect_emotion(text) - + return JsonResponse({ "text": text, "emotions": emotions }) - + return JsonResponse({"error": "Method not allowed"}, status=405) ``` @@ -157,11 +157,11 @@ app.use(express.json()); // Middleware for emotion analysis const emotionAnalysis = async (req, res, next) => { const text = req.body.text; - + if (!text) { return res.status(400).json({ error: 'No text provided' }); } - + try { const emotions = await detectEmotion(text); req.emotions = emotions; @@ -203,7 +203,7 @@ const useEmotionDetection = () => { const analyzeEmotion = async (text) => { setLoading(true); setError(null); - + try { const response = await fetch( 'https://samo-emotion-api-xxxxx-ew.a.run.app/predict', @@ -213,11 +213,11 @@ const useEmotionDetection = () => { body: JSON.stringify({ text }) } ); - + if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - + const data = await response.json(); setEmotions(data); } catch (err) { @@ -257,8 +257,8 @@ const EmotionAnalyzer = () => { className="form-control mb-3" rows="4" /> - - - - - - -
-
-
-
-

- Live Emotion Detection Demo -

-

- Experience the power of SAMO-DL's emotion detection API in real-time. - Test with your own text and see instant results with confidence scores. -

- -
-
-
-
-
-

>90%

-

F1 Score

-
-
-
-
-

<50ms

-

Latency

-
-
-
-
-

28

-

Emotions

-
-
-
-
-

2.3x

-

Faster

-
-
-
-
-
-
-
- - -
-
-
-
-
-

Interactive Emotion Detection

-

- Enter any text below and watch our AI analyze emotions in real-time -

-
-
- - -
-
-
- - -
- -
- - -
-
-
- - -
-
- Loading... -
-
Analyzing emotions...
-

Processing your text with our advanced AI model

-
- - -
-
-
-

Analysis Results

- - -
-
-
Detected Emotions:
-
-
-
- - -
-
-
-
-
Confidence Distribution
- -
-
-
-
-
-
-
Emotion Categories
- -
-
-
-
-
-
-
- - -
-
-
-
-
API Information
-
-
-
- -

Response Time

- - -
-
-
-
- -

Status

- Ready -
-
-
-
- -

Confidence

- - -
-
-
-
- -

Model

- ONNX Optimized -
-
-
-
-
-
-
-
-
-
- - -
-
-
-
-

Why Choose SAMO-DL?

-

- Enterprise-grade emotion detection with cutting-edge performance -

-
-
-
-
-
-
-
- -
-
Lightning Fast
-

- Sub-50ms response times with ONNX optimization for real-time applications. -

-
-
-
-
-
-
-
- -
-
High Accuracy
-

- >90% F1 score with comprehensive emotion detection across 28 categories. -

-
-
-
-
-
-
-
- -
-
Production Ready
-

- Deployed on Google Cloud Run with 99.9% uptime and enterprise security. -

-
-
-
-
-
-
- - -
-
-
-
-
- - SAMO-DL -
-

- Production-ready emotion detection API with enterprise-grade reliability and performance. -

-
-
-
Product
- -
-
-
Resources
- -
-
-
Company
- -
-
-
Connect
- -
-
-
-
-
-

- © 2025 SAMO-DL. All rights reserved. -

-
-
-

- Built with ❤️ for the developer community -

-
-
-
-
- - - - - - - - \ No newline at end of file diff --git a/website/favicon.ico b/website/favicon.ico new file mode 100644 index 000000000..cb37ebb78 --- /dev/null +++ b/website/favicon.ico @@ -0,0 +1,6 @@ + +301 Moved +

301 Moved

+The document has moved +here. + diff --git a/website/index.html b/website/index.html index af02fd77b..325f7972c 100644 --- a/website/index.html +++ b/website/index.html @@ -5,7 +5,10 @@ SAMO Deep Learning - Production Emotion Detection API - + + + + @@ -18,7 +21,7 @@ --secondary-gradient: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #3730a3 100%); --dark-gradient: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%); --accent-gradient: linear-gradient(135deg, #7c3aed 0%, #9333ea 50%, #c084fc 100%); - + --primary-color: #8b5cf6; --secondary-color: #a855f7; --accent-color: #c084fc; @@ -27,7 +30,7 @@ --light-accent: #e9d5ff; --glass-bg: rgba(139, 92, 246, 0.1); --glass-border: rgba(139, 92, 246, 0.2); - + /* Performance and animation settings */ --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); --transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); @@ -64,7 +67,7 @@ left: 0; right: 0; bottom: 0; - background: + background: radial-gradient(circle at 20% 50%, rgba(139, 92, 246, 0.3) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(168, 85, 247, 0.2) 0%, transparent 50%), radial-gradient(circle at 40% 80%, rgba(192, 132, 252, 0.2) 0%, transparent 50%); @@ -344,12 +347,12 @@ .hero-section { padding: 80px 0; } - + .integration-card { padding: 25px; margin-bottom: 20px; } - + .feature-icon { width: 60px; height: 60px; @@ -376,19 +379,10 @@ Features - - - @@ -401,22 +395,17 @@

- 🚀 Complete AI Integration Platform + SAMO Emotion Pipeline

- 100% Priority 1 Features Complete! Enterprise-grade AI platform with JWT authentication, - voice transcription, text summarization, real-time processing, and comprehensive monitoring. + AI platform for voice transcription, text summarization, and emotion detection with secure authentication and real-time processing. Production-ready with >90% F1 score and 2.3x performance optimization.

- -
-
-
-
-

🎯 Priority 1 Features - 100% Complete

-

- All critical features implemented with enterprise-grade quality and comprehensive testing -

-
-
-
-
-
-
-
- -
-
JWT Authentication System
-

- Complete token lifecycle management with register, login, refresh, logout, and profile endpoints. - Secure with blacklist tracking and permission-based access control. -

-
✅ Complete
-
-
-
-
-
-
-
- -
-
Enhanced Voice Transcription
-

- Advanced Whisper integration with batch processing, real-time streaming, and comprehensive - error handling. Supports multiple audio formats with file validation. -

-
✅ Complete
-
-
-
-
-
-
-
- -
-
Text Summarization & Analysis
-

- Multi-model T5 summarization with emotional analysis, key point extraction, and - customizable compression ratios. Real-time processing with confidence scoring. -

-
✅ Complete
-
-
-
-
-
-
-
- -
-
Real-time Batch Processing
-

- WebSocket-based real-time processing with progress tracking, partial results, and - comprehensive error handling. Supports concurrent processing with rate limiting. -

-
✅ Complete
-
-
-
-
-
-
-
- -
-
Comprehensive Monitoring
-

- Real-time dashboard with system metrics, model performance tracking, error rate monitoring, - and health status alerts. Production-ready observability. -

-
✅ Complete
-
-
-
-
-
-
-
- -
-
Comprehensive Testing
-

- Complete test suite with 1,094 lines of integration tests covering all endpoints, - edge cases, error scenarios, and security validation. 100% code review issues resolved. -

-
✅ Complete
-
-
-
-
-
-
-

🚀 Enterprise-Grade AI Platform

-

- Complete AI integration platform with authentication, voice processing, text analysis, and real-time monitoring +

AI Processing Features

+

+ Voice transcription, text summarization, and emotion detection with authentication and monitoring

@@ -580,7 +464,7 @@

🚀 Enterprise-Grade AI Platform

Production Ready
-

+

Deployed on Google Cloud Run with 99.9% uptime, auto-scaling, and comprehensive monitoring.

@@ -593,7 +477,7 @@
Production Ready
High Performance
-

+

>90% F1 score with 2.3x speedup using ONNX optimization and efficient tokenization.

@@ -606,7 +490,7 @@
High Performance
Enterprise Security
-

+

Rate limiting, input sanitization, CORS protection, and API key authentication.

@@ -619,7 +503,7 @@
Enterprise Security
Easy Integration
-

+

Simple REST API with comprehensive documentation and examples for all frameworks.

@@ -632,7 +516,7 @@
Easy Integration
Real-time Monitoring
-

+

Prometheus metrics, health checks, and comprehensive logging for observability.

@@ -645,7 +529,7 @@
Real-time Monitoring
Team Ready
-

+

Integration guides for backend, frontend, UX, and data science teams.

@@ -655,107 +539,57 @@
Team Ready
- -
-
-
-
-

🏆 Technical Achievements

-

- Comprehensive implementation with enterprise-grade quality and security -

-
-
-
-
-
-
2,296
-

Lines of Code Added

-
-
-
-
-
1,094
-

Test Lines

-
-
-
-
-
15
-

Code Review Issues Fixed

-
-
-
-
-
100%
-

Security Validated

-
-
-
-
-
-
-
-
🔧 Key Technical Improvements
-
-
-
    -
  • JWT Token Blacklist with Dict Performance
  • -
  • WebSocket Authentication & Rate Limiting
  • -
  • Comprehensive Error Handling
  • -
  • Async/Await Optimization
  • -
-
-
-
    -
  • Input Sanitization & Validation
  • -
  • Real-time Monitoring Dashboard
  • -
  • Comprehensive Test Coverage
  • -
  • Production-Ready Security
  • -
-
-
-
-
-
-
-
-
- -
-
-
-
-

🤝 Complete Team Integration

-

- Comprehensive integration guides for Backend, Frontend, Data Science, and UX teams with live API endpoints -

-
-
-
-
-
-
- - Backend Integration -
-
-
import requests
-from typing import BinaryIO, IO
-
-class SAMO_API_Client:
-    def __init__(self, base_url="https://api.example.com"):
-        # Replace 'https://api.example.com' with your actual deployment URL
-        self.base_url = base_url
-        self.session = requests.Session()
-
-    def analyze_emotion(self, text: str) -> dict:
-        try:
-            response = self.session.post(
-                f"{self.base_url}/predict",
-                json={"text": text},
-                headers={"Content-Type": "application/json"},
-                timeout=10
-            )
-            response.raise_for_status()
-            return response.json()
-        except requests.exceptions.RequestException as e:
-            return {"error": str(e)}
-
-    def transcribe_voice(self, audio_file: BinaryIO | IO[bytes]) -> dict:
-        if not hasattr(audio_file, "read"):
-            return {"error": "audio_file must be a binary file-like object (supports .read())"}
-        files = {"audio_file": audio_file}
-        try:
-            response = self.session.post(
-                f"{self.base_url}/transcribe/voice",
-                files=files,
-                timeout=30
-            )
-            response.raise_for_status()
-            return response.json()
-        except requests.exceptions.RequestException as e:
-            return {"error": str(e)}
-
-# Example usage
-client = SAMO_API_Client()
-emotions = client.analyze_emotion("I'm excited!")
-# Returns: [{"emotion": "excitement", "confidence": 0.92}]
-
-
-
-
-
-
- - Frontend Integration -
-
-
async function analyzeEmotion(text) {
-  const response = await fetch(
-    'https://samo-unified-api-frrnetyhfa-uc.a.run.app/analyze/journal',
-    {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ text, generate_summary: false })
-    }
-  );
-  return await response.json();
-}
-
-// Example usage
-const analysis = await analyzeEmotion("This is amazing!");
-console.log(analysis.emotion_analysis?.emotions);
-
-
-
-
-
-
- - Data Science Integration -
-
-
import pandas as pd
-import requests
-
-def analyze_dataset(texts: list) -> pd.DataFrame:
-    results = []
-    for text in texts:
-        analysis = requests.post(
-            "https://samo-unified-api-frrnetyhfa-uc.a.run.app/analyze/journal",
-            json={"text": text, "generate_summary": False}
-        ).json()
-        results.append({
-            'text': text,
-            'emotions': analysis.get('emotion_analysis', {}).get('emotions', {})
-        })
-    return pd.DataFrame(results)
-
-
-
-
-
-
- - Mobile Integration -
-
-
// React Native / Flutter
-const analyzeUserFeedback = async (feedback) => {
-  try {
-    const response = await fetch(
-      'https://samo-unified-api-frrnetyhfa-uc.a.run.app/analyze/journal',
-      {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ text: feedback, generate_summary: false })
-      }
-    );
-    const analysis = await response.json();
-    return analysis;
-  } catch (error) {
-    console.error('Error:', error);
-  }
-};
-
-
-
-
-
-
@@ -914,8 +605,8 @@

🟢 Live API Endpoints

-

- All endpoints are live and ready for production integration +

+ Production endpoints are live at: https://samo-unified-api-optimized-frrnetyhfa-uc.a.run.app

@@ -1003,61 +694,6 @@
🔧 System
- -
-
-
-
-

Documentation & Resources

-

- Complete guides and resources for successful integration -

-
-
-
- -
-
-
- -
Deployment Guide
-

Step-by-step deployment instructions

- Deploy Now -
-
-
-
-
-
- -
Team Guides
-

Integration guides for all teams

- Learn More -
-
-
-
-
-
- -
Source Code
-

Open source project on GitHub

- View Code -
-
-
-
-
-