diff --git a/.dockerignore b/.dockerignore index 3fd378dc4..b81c37bbc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,4 +12,4 @@ LICENSE dist build artifacts -notebooks \ No newline at end of file +notebooks diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c77d00796..5990d9c64 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,4 +9,3 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" - diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index ee0c49c31..c65324f08 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,16 @@ 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 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pr-scope-check.yml b/.github/workflows/pr-scope-check.yml new file mode 100644 index 000000000..3b90941c9 --- /dev/null +++ b/.github/workflows/pr-scope-check.yml @@ -0,0 +1,91 @@ +name: PR Scope Check + +permissions: + contents: read + pull-requests: read + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + scope-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Get full history for proper diff + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.8' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + + - name: Run PR Scope Check + run: | + python scripts/check_pr_scope.py --strict + continue-on-error: false + + - name: Check branch naming (secure) + env: + BRANCH_NAME: ${{ github.head_ref }} + run: | + # Validate branch name using environment variable (safer than direct interpolation) + if [[ ! "$BRANCH_NAME" =~ ^(feat|fix|chore|refactor|docs|test)/[a-z]+(-[a-z]+)*$ ]]; then + echo "❌ Branch name must follow pattern: type/short-description" + echo " Current: $BRANCH_NAME" + echo " Examples: feat/add-user-auth, fix/validate-input, chore/update-deps" + exit 1 + fi + echo "✅ Branch name follows convention: $BRANCH_NAME" + + - name: Check PR size limits (secure) + env: + BASE_BRANCH: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + run: | + # Validate input parameters + if [[ -z "$BASE_BRANCH" || -z "$HEAD_REF" ]]; then + echo "❌ Missing required branch information" + exit 1 + fi + + # Ensure base branch exists in origin + if ! git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then + echo "❌ Base branch origin/$BASE_BRANCH not found" + exit 1 + fi + + # Count files changed using safe git commands + FILES_CHANGED=$(git diff --name-only "origin/$BASE_BRANCH" | wc -l) + echo "Files changed: $FILES_CHANGED" + + # Count lines changed using shortstat (more reliable) + SHORTSTAT=$(git diff --shortstat "origin/$BASE_BRANCH") + LINES_CHANGED=0 + + if [[ -n "$SHORTSTAT" ]]; then + # Extract insertions and deletions from shortstat + INSERTIONS=$(echo "$SHORTSTAT" | grep -o '[0-9]\+ insertion' | head -1 | grep -o '[0-9]\+' || echo "0") + DELETIONS=$(echo "$SHORTSTAT" | grep -o '[0-9]\+ deletion' | head -1 | grep -o '[0-9]\+' || echo "0") + LINES_CHANGED=$((INSERTIONS + DELETIONS)) + fi + + echo "Lines changed: $LINES_CHANGED" + + # Check limits with proper error handling + if [[ "$FILES_CHANGED" -gt 50 ]]; then + echo "❌ Too many files changed: $FILES_CHANGED (max 50)" + exit 1 + fi + + if [[ "$LINES_CHANGED" -gt 1500 ]]; then + echo "❌ Too many lines changed: $LINES_CHANGED (max 1500)" + exit 1 + fi + + echo "✅ PR size within limits: $FILES_CHANGED files, $LINES_CHANGED lines" diff --git a/.gitignore b/.gitignore index 5083e787f..7868d6769 100644 --- a/.gitignore +++ b/.gitignore @@ -384,5 +384,4 @@ coverage.xml # Large generated reports bandit-report.json ci_pipeline.log - - +notebooks/ diff --git a/.gitmessage.txt b/.gitmessage.txt new file mode 100644 index 000000000..8f431523e --- /dev/null +++ b/.gitmessage.txt @@ -0,0 +1,25 @@ +# SAMO-DL Commit Message Template +# +# Format: (): +# +# Types: +# feat: A new feature +# fix: A bug fix +# chore: Changes to the build process or auxiliary tools/libraries +# refactor: A code change that neither fixes a bug nor adds a feature +# docs: Documentation only changes +# test: Adding missing tests or correcting existing tests +# +# Rules: +# - ONE purpose per commit (no "and", "also", "plus") +# - Subject line < 50 characters +# - Use imperative mood ("Add" not "Added") +# - No period at end of subject line +# +# Examples: +# feat: add user authentication system +# fix: resolve memory leak in model loading +# chore: update dependency versions +# refactor: simplify rate limiter logic +# docs: update API documentation +# test: add unit tests for validation functions diff --git a/.logs/code_quality_report.md b/.logs/code_quality_report.md index 6e5f342c6..ce5b888fd 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 @@ -22,4 +22,3 @@ Generated: 2025-07-22 20:21:52 UTC 📊 **Comprehensive file validation** The pre-commit hooks are working perfectly! - diff --git a/.logs/repo_inventory.json b/.logs/repo_inventory.json index 4c440d77e..95ca6c159 100644 --- a/.logs/repo_inventory.json +++ b/.logs/repo_inventory.json @@ -4995,4 +4995,4 @@ "bandit-report.json": [], "ci_pipeline.log": [] } -} \ No newline at end of file +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e211822be..8644270e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,36 +1,7 @@ -# Comprehensive Code Quality Prevention System for SAMO-DL -# This configuration prevents ALL recurring DeepSource issues from ever happening again -# -# TODO: Re-enable all disabled hooks once configuration issues are resolved -# Issue: https://github.com/uelkerd/SAMO--DL/issues/106 -# -# Disabled hooks and their status: -# ✅ Bandit: RE-ENABLED - Split into targeted hooks: changed files + tests (B101 skipped) -# ✅ Safety: RE-ENABLED - Local hook with safety package, runs on push, scans all deps -# ✅ Docformatter: RE-ENABLED - Local hook with docformatter package for reliability -# ✅ Flynt: RE-ENABLED - Fully configured with always_run and pass_filenames for consistency -# - Local hooks: Configuration format issues (exclude field format) - FIXED: Updated to single-line format -# -# IMPROVEMENTS IMPLEMENTED: -# ✅ Global exclude pattern implemented - all hooks now inherit from top-level exclude -# ✅ Individual exclude patterns removed from active hooks (black, isort, ruff, mypy) -# ✅ Bandit split into targeted hooks: changed files (all rules) + tests (B101 skipped) -# ✅ Safety implemented as local hook with safety package, optimized for push-only execution -# ✅ Docformatter implemented as local hook with docformatter package for reliability -# ✅ Flynt configuration made consistent with Bandit (always_run, pass_filenames) -# ✅ Global exclude pattern enhanced to cover ALL test artifacts and build directories -# ✅ Configuration is now much more maintainable and follows best practices -# ✅ All code review comments addressed and resolved -# -# Next steps: -# 1. Test Safety hook with local implementation -# 2. Test Docformatter hook with system language configuration -# 3. Test and re-enable local hooks -# 4. Update this TODO section as hooks are re-enabled -# +# Comprehensive Code Quality System for SAMO-DL +# Prevents ALL recurring security and quality issues with automated enforcement + # Global exclude pattern - applies to all hooks unless overridden -# Uses anchored regex with extended mode for readability and accuracy -# Comprehensive coverage: git, venvs, caches, builds, artifacts, docs, samples, test artifacts exclude: | (?x)^( \.git| @@ -61,18 +32,19 @@ exclude: | deprecated| test_reports| test_report\.txt| - \.pytest_cache| - \.mypy_cache| - \.ruff_cache| - \.coverage| - coverage\.xml| - \.coveragerc| \.gitignore-pages| \.nojekyll| \.deepsource\.toml| - \.pre-commit-exclude-patterns\.yaml| trivy-results-.*\.json| - vulnerabilities-.*\.json + vulnerabilities-.*\.json| + scripts/legacy| + scripts/maintenance| + scripts/testing| + scripts/ci| + deployment/.*| + tests| + temp_.*\.py| + scripts/test-quality-tools\.py )$ repos: @@ -94,125 +66,106 @@ repos: - id: fix-byte-order-marker - id: mixed-line-ending - id: check-ast + - id: check-added-large-files - # Python formatting and linting (in order) - - repo: https://github.com/psf/black - rev: 24.2.0 - hooks: - - id: black - language_version: python3 - args: [--line-length=88, --target-version=py38] - types: [python] - - # Import sorting and organization - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - args: [--profile=black, --line-length=88, --py=38] - types: [python] - - # Python linting with Ruff (super fast) + # Python linting and formatting with Ruff (replaces black, isort, flake8) - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.0 + rev: v0.6.8 hooks: - id: ruff + name: ruff-linting args: [--fix, --exit-non-zero-on-fix] types: [python] + - id: ruff-format + name: ruff-formatting + types: [python] # Type checking with MyPy - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.8.0 hooks: - id: mypy - args: [--ignore-missing-imports, --python-version=3.8] + args: [--ignore-missing-imports, --python-version=3.9] + additional_dependencies: [types-requests, types-PyYAML] + # Strategic exclusions to focus on core business logic (matching pyproject.toml) + exclude: | + (?x)( + # Legacy scripts - defer until core is stable + scripts/legacy/.* + # Maintenance utilities - non-critical path + | scripts/maintenance/.* + # Testing scripts - can be addressed separately + | scripts/testing/.* + # Deployment automation - secondary priority + | scripts/deployment/vertex_ai_phase4_automation\.py + # CI scripts - already have type annotations + | scripts/ci/.* + # Jupyter notebooks and experimental training files (invalid syntax) + | scripts/training/.*bulletproof.* + # Additional tooling directories + | deployment/.* + | test_.*\.py$ + ) + # Focus on core business logic paths + files: ^(src/|scripts/training/|scripts/database/).*\.py$ + + # Code quality with Pylint + - repo: https://github.com/pycqa/pylint + rev: v3.0.3 + hooks: + - id: pylint + args: [--rcfile=pyproject.toml] types: [python] - # Security scanning with Bandit (optimized for performance) - # Split into targeted hooks: changed files (all rules) + tests (B101 skipped) + # Security scanning with Bandit (comprehensive rules) - repo: https://github.com/PyCQA/bandit - rev: 1.7.5 + rev: 1.7.6 hooks: - id: bandit - name: bandit (changed files) - # Scan only changed Python files, enforce all rules + name: bandit (production code) + # Scan production code with all security rules types: [python] exclude: '^(tests/|.*_test\.py$)' + args: [-c, pyproject.toml] - id: bandit name: bandit (tests, B101 skipped) # Scan test files with B101 (assert_used) disabled - args: [-s, B101] + args: [-c, pyproject.toml, -s, B101] types: [python] files: '^(tests/|.*_test\.py$)' - # Security vulnerability scanning with Safety (local hook) - # Local hook with safety package for reliability and control - # Runs on push to avoid blocking commits, scans all dependency files - - repo: local - hooks: - - id: safety-scan - name: Safety (dependency vulnerability scan) - entry: safety - language: python - additional_dependencies: [safety==3.6.0] - args: [scan, --full-report, --target, .] - pass_filenames: false - stages: [push] - # Optional: Add policy file for custom rules - # args: [scan, --full-report, --policy-file, .safety-policy.yml] - # env: - # - SAFETY_API_KEY # if using the commercial DB + # Security vulnerability scanning with Safety (temporarily disabled - interactive auth issue) + # - repo: local + # hooks: + # - id: safety-scan + # name: Safety (dependency vulnerability scan) + # entry: safety + # language: python + # additional_dependencies: [safety>=3.0.0] + # args: [scan, --disable-optional-telemetry] + # pass_filenames: false + # files: ^(requirements.*\.txt|pyproject\.toml)$ - # Documentation formatting with Docformatter (local hook) - # Local hook with docformatter package to avoid external repository compatibility issues + # Documentation formatting with Docformatter - repo: local hooks: - id: docformatter name: Docformatter (docstring formatting) entry: docformatter language: python - additional_dependencies: [docformatter==1.7.3] + additional_dependencies: [docformatter>=1.7.0] args: [--in-place, --wrap-summaries=88, --wrap-descriptions=88] types: [python] # String formatting with flynt - # Configuration consistent with Bandit hooks for maintainability - repo: https://github.com/ikamensh/flynt rev: "0.78" hooks: - id: flynt - args: [--line-length=88, .] + args: [--line-length=88] types: [python] - pass_filenames: false - always_run: true - - # Custom SAMO-DL code quality enforcer - # TODO: Re-enable once configuration issues are resolved - # Issue: https://github.com/uelkerd/SAMO--DL/issues/106 - # - repo: local - # hooks: - # - id: samo-code-quality-enforcer - # name: SAMO-DL Code Quality Enforcer - # entry: python scripts/maintenance/code_quality_enforcer.py - # language: python - # types: [python] - # pass_filenames: false - # always_run: true - - # Auto-fix common code quality issues - # TODO: Re-enable once configuration issues are resolved - # Issue: https://github.com/uelkerd/SAMO--DL/issues/106 - # - repo: local - # hooks: - # - id: samo-auto-fix-code-quality - # name: SAMO-DL Auto-Fix Code Quality - # entry: python scripts/maintenance/auto_fix_code_quality.py - # language: python - # types: [python] - # pass_filenames: false - # always_run: true # Global configuration default_language_version: - python: python3.8 + python: python3.9 diff --git a/.safety-project.ini b/.safety-project.ini index 28ade64c6..72012c899 100644 --- a/.safety-project.ini +++ b/.safety-project.ini @@ -1,29 +1,12 @@ -# Safety Project Configuration -# This file pre-configures Safety CLI to prevent interactive prompts in CI environments - [project] -# Project name - prevents the "Enter a name for this codebase" prompt -name = samo-dl - -# Project ID - optional but helps with consistency +name = samo-dl-project id = samo-dl-project - -# Organization - optional organization = SAMO +url = /codebases/samo-dl-project/findings [scan] -# Skip interactive prompts interactive = false -# Note: targets and continue_on_error are now specified via CLI flags -# This allows more flexibility in different environments - -# [policy] -# Policy file disabled - using command-line options instead -# policy_file = .safety-policy.yml - [output] -# Output format for CI format = screen -# Detailed output for debugging -detailed = true \ No newline at end of file +detailed = true diff --git a/.vscode/sessions.json b/.vscode/sessions.json index 06f736525..670709c2c 100644 --- a/.vscode/sessions.json +++ b/.vscode/sessions.json @@ -53,4 +53,4 @@ } ] } -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 72fd0cf7a..e573d4432 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/).* diff --git a/CODE_QUALITY_README.md b/CODE_QUALITY_README.md new file mode 100644 index 000000000..57e9950c6 --- /dev/null +++ b/CODE_QUALITY_README.md @@ -0,0 +1,207 @@ +# SAMO-DL Code Quality Standards + +## 🎯 Mission: Prevent Monster PRs Forever + +This document outlines the **strict rules** and **automated enforcement** designed to prevent the creation of massive, unfocused pull requests that slow down development and make code reviews impossible. + +## 🚫 THE PROBLEM WE SOLVE + +**Monster PRs** are pull requests that: +- Change 100+ files +- Add 1000+ lines of code +- Mix multiple concerns (API + tests + docs + infrastructure) +- Take weeks to review +- Cause merge conflicts +- Block progress + +## ✅ THE SOLUTION: Micro-PRs Only + +### **Hard Limits (Automated Enforcement)** +```yaml +# GitHub Actions - PR Size Guard +max_files_changed: 50 # HARD STOP at 50 files +max_lines_changed: 1500 # HARD STOP at 1500 lines +max_commits_per_pr: 5 # HARD STOP at 5 commits +branch_lifetime: 48h # FORCE merge or close after 48h +``` + +### **Single Purpose Rule** +**EVERY PR MUST HAVE EXACTLY ONE SENTENCE DESCRIBING ITS PURPOSE** +- ✅ "Add user authentication system" +- ✅ "Fix memory leak in model loading" +- ❌ "Improve model architecture and fix bugs" *(TWO THINGS!)* +- ❌ "Refactor training pipeline" *(TOO VAGUE!)* + +## 🛠️ AUTOMATED ENFORCEMENT + +### **1. Pre-Commit Hook** +The `.git/hooks/pre-commit` script automatically: +- Validates branch naming (`feat/add-auth`, `fix/memory-leak`) +- Checks commit message format (`feat: add user auth`) +- Prevents commits with mixed concerns +- Runs before every commit + +### **2. PR Scope Checker** +```bash +python scripts/check_pr_scope.py --strict +``` +Validates: +- File count ≤ 50 +- Line changes ≤ 1500 +- Single purpose (no mixing concerns) +- Branch naming compliance + +### **3. CI Pipeline Checks** +GitHub Actions automatically: +- Runs scope validation on PR creation +- Fails builds that exceed limits +- Prevents merging of out-of-scope PRs + +## 📋 DEVELOPMENT WORKFLOW + +### **Before Creating a Branch** +```bash +# Answer these questions: +1. Can I describe this in ONE sentence? +2. Will this affect < 50 files? +3. Can I complete this in < 4 hours? +4. Is this EXACTLY ONE concern? +5. Am I mixing API + tests + docs? + +# If ANY answer is NO: Split into separate PRs +``` + +### **Branch Naming Convention** +``` +feat/short-description # New features +fix/short-description # Bug fixes +chore/short-description # Build/tooling changes +refactor/short-description # Code restructuring +docs/short-description # Documentation +test/short-description # Test additions +``` + +**Examples:** +- ✅ `feat/add-user-auth` +- ✅ `fix/validate-input` +- ✅ `chore/update-deps` +- ✅ `refactor/simplify-logic` +- ❌ `feature/add-auth-and-fix-bugs` *(multiple concerns)* +- ❌ `fix-stuff` *(too vague)* + +### **Commit Message Format** +``` +(): + + +``` +**Examples:** +``` +feat: add JWT token authentication +fix: resolve memory leak in model loading +chore: update Python dependencies +refactor: simplify rate limiter logic +``` + +## 🏗️ CODE QUALITY TOOLS + +### **Automated Tools** +- **Black**: Code formatting (88 char lines) +- **isort**: Import sorting +- **flake8**: Linting and style +- **pylint**: Advanced code analysis +- **mypy**: Type checking +- **bandit**: Security scanning +- **safety**: Dependency vulnerability checks + +### **Pre-commit Hooks** +Run automatically on commit: +```bash +pre-commit install # Install hooks +pre-commit run --all-files # Run on all files +``` + +### **Development Commands** +```bash +make format # Format code +make lint # Run linters +make test # Run tests +make quality-check # Run all quality checks +``` + +## 🚨 EMERGENCY OVERRIDES + +**Only for critical production issues:** +```yaml +override_label: "EMERGENCY-OVERRIDE" +required_approvers: 2 +max_override_per_week: 1 +auto_close_after: 8h +``` + +## 📊 SUCCESS METRICS + +**Weekly Tracking:** +- Average PR size: < 15 files +- PR lifetime: < 24 hours +- Number of scope violations: 0 +- Merge conflicts: < 1 per week + +**Red Flags (Auto-alert):** +- Any PR > 50 files +- Any branch > 48 hours old +- Any PR title with "and", "also", "plus" +- Any description > 2 sentences + +## 🎯 WHY THIS WORKS + +### **Psychological Benefits** +- **Small wins**: Frequent merges build momentum +- **Fast feedback**: Quick reviews = faster iteration +- **Reduced risk**: Smaller changes = easier rollback +- **Team satisfaction**: Actually shipping features + +### **Technical Benefits** +- **Fewer merge conflicts**: Smaller, focused changes +- **Easier reviews**: 50 files vs 100+ files +- **Better testing**: Isolated changes = targeted tests +- **Faster CI/CD**: Smaller PRs = faster pipelines + +## 🚀 IMPLEMENTATION + +### **Immediate Actions** +1. **Install pre-commit hooks**: `pre-commit install` +2. **Set commit template**: `git config commit.template .gitmessage.txt` +3. **Run scope checker**: `python scripts/check_pr_scope.py` +4. **Review existing PRs**: Close any that violate rules + +### **Team Adoption** +1. **Training session**: Walk through the rules +2. **Documentation**: Share this guide +3. **Examples**: Show good vs bad PR examples +4. **Celebrate**: Recognize teams following the rules + +## 📞 SUPPORT + +**Questions?** Ask in the development channel. + +**Found a violation?** Use the PR comment template: +``` +🚨 **SCOPE VIOLATION DETECTED** +This PR exceeds our size limits: +- Files: [count]/50 max +- Lines: [count]/1500 max +- Multiple concerns: [list them] + +Please split into focused micro-PRs. +``` + +--- + +## 🎉 CONCLUSION + +**Small PRs = Fast reviews = Quick merges = Happy developers = Successful project** + +**NO EXCEPTIONS. NO EXCUSES. NO "JUST THIS ONCE".** + +Welcome to the era of **productive, focused development!** 🚀 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 035d359dc..e4600697d 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. diff --git a/ENVIRONMENT_SETUP.md b/ENVIRONMENT_SETUP.md index 7d03adab2..59ae1a96d 100644 --- a/ENVIRONMENT_SETUP.md +++ b/ENVIRONMENT_SETUP.md @@ -93,4 +93,4 @@ If you encounter issues: 1. **Environment conflicts**: Remove and recreate the environment 2. **Missing dependencies**: Check if you're using the right environment file for your use case -3. **Version mismatches**: Ensure constraints.txt is being applied during pip installs \ No newline at end of file +3. **Version mismatches**: Ensure constraints.txt is being applied during pip installs diff --git a/Home.md b/Home.md index 544b5867d..8eb22be3b 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* 🚀 diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..66f808afd --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# SAMO-DL Makefile - Minimal Code Quality Edition +.PHONY: help format lint test quality-check clean + +help: ## Show this help message + @echo "SAMO-DL Code Quality Commands" + @echo "=============================" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +format: ## Format code with ruff + ruff format src/ tests/ scripts/ + +lint: ## Run comprehensive linting with ruff and pylint + ruff check --fix src/ tests/ scripts/ + pylint --rcfile=pyproject.toml src/ tests/ scripts/ + +test: ## Run tests with coverage + pytest tests/ --cov=src --cov-report=term-missing + +quality-check: ## Run all quality checks (matches pre-commit) + @echo "Running comprehensive code quality checks..." + ruff check --diff src/ tests/ scripts/ + ruff format --check src/ tests/ scripts/ + pylint --rcfile=pyproject.toml src/ tests/ scripts/ + mypy src/ scripts/training/ scripts/database/ + bandit -r src/ -c pyproject.toml + bandit -r tests/ -c pyproject.toml -s B101 + safety scan --json --output safety-report.json + docformatter --check --wrap-summaries=88 --wrap-descriptions=88 src/ tests/ scripts/ + +check-scope: ## Check PR scope compliance + python scripts/check_pr_scope.py --strict + +pre-commit-install: ## Install pre-commit hooks + pre-commit install + git config commit.template .gitmessage.txt + +clean: ## Clean up generated files + rm -rf build/ dist/ *.egg-info/ + rm -rf .pytest_cache/ .coverage htmlcov/ + rm -rf bandit-report.json safety-report.json + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete 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..4105dd363 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!** 🚀 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/SAMO_Voice_First_Development.ipynb b/SAMO_Voice_First_Development.ipynb index d30849b5a..73e5dfd76 100644 --- a/SAMO_Voice_First_Development.ipynb +++ b/SAMO_Voice_First_Development.ipynb @@ -9122,4 +9122,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/artifacts/results/results/focal_training_results.json b/artifacts/results/results/focal_training_results.json index 39d78ea94..8cfbba8d1 100644 --- a/artifacts/results/results/focal_training_results.json +++ b/artifacts/results/results/focal_training_results.json @@ -3,4 +3,4 @@ "precision": 0.41398809523809527, "recall": 0.8333333333333334, "best_threshold": 0.3 -} \ No newline at end of file +} diff --git a/artifacts/results/results/simple_training_results.json b/artifacts/results/results/simple_training_results.json index b12e42ce7..5626b601f 100644 --- a/artifacts/results/results/simple_training_results.json +++ b/artifacts/results/results/simple_training_results.json @@ -48,4 +48,4 @@ "sad", "tired" ] -} \ No newline at end of file +} diff --git a/artifacts/test-reports/cloud_run_api_test_results.json b/artifacts/test-reports/cloud_run_api_test_results.json index 409b564f2..3939c2946 100644 --- a/artifacts/test-reports/cloud_run_api_test_results.json +++ b/artifacts/test-reports/cloud_run_api_test_results.json @@ -152,4 +152,4 @@ "failed_tests": 0, "critical_issues": [] } -} \ No newline at end of file +} 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..bb4cef3df 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,4 @@ "summary": { "model_status": "EXCELLENT", "confidence_status": "HIGH", - "deployment_ready": \ No newline at end of file + "deployment_ready": diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 8d73ecad6..c7762c074 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' @@ -81,4 +81,4 @@ substitutions: availableSecrets: secretManager: - versionName: projects/$PROJECT_ID/secrets/admin-api-key/versions/latest - env: 'ADMIN_API_KEY' \ No newline at end of file + env: 'ADMIN_API_KEY' diff --git a/configs/repo_inventory.json b/configs/repo_inventory.json index f1d54765c..9ea2416e4 100644 --- a/configs/repo_inventory.json +++ b/configs/repo_inventory.json @@ -12,4 +12,4 @@ "deprecated/ci_pipeline.log" ], "file_inventory_cap": 2000 -} \ No newline at end of file +} diff --git a/configs/samo_whisper_config.yaml b/configs/samo_whisper_config.yaml index 293a08562..94678ff4c 100644 --- a/configs/samo_whisper_config.yaml +++ b/configs/samo_whisper_config.yaml @@ -13,27 +13,27 @@ 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 + fp16: false # Use half precision for speed (only when CUDA available) # Audio Processing Configuration audio: @@ -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..e13783695 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,36 @@ model: validate_model_files: true check_model_signatures: true max_model_size_mb: 1000 - + + # Trusted signers configuration + trusted_signers: + # Key repository configuration + key_repository_url: "https://keys.samo-dl.com/trusted-keys/" + local_key_path: "/app/security/trusted-keys/" + + # Allowed signer IDs and fingerprints + allowed_signers: + - signer_id: "samo-dl-maintainer" + key_fingerprint: "SHA256:1234567890abcdef..." + description: "Main SAMO-DL maintainer key" + - signer_id: "samo-dl-ci" + key_fingerprint: "SHA256:abcdef1234567890..." + description: "CI/CD signing key" + + # Key rotation and refresh settings + key_rotation: + refresh_interval_hours: 24 + check_revocation: true + revocation_list_url: "https://keys.samo-dl.com/revoked-keys.txt" + max_key_age_days: 365 + # 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 +195,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 +219,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 diff --git a/dependencies/README.md b/dependencies/README.md index 276f34013..f858991aa 100644 --- a/dependencies/README.md +++ b/dependencies/README.md @@ -20,4 +20,3 @@ pip install -c dependencies/constraints.txt -r dependencies/requirements-ml.txt ``` Rationale: fewer, clearer files reduce drift and simplify security scanning and upgrades. - diff --git a/dependencies/constraints.txt b/dependencies/constraints.txt index ac8d7888a..ab4743815 100644 --- a/dependencies/constraints.txt +++ b/dependencies/constraints.txt @@ -4,46 +4,46 @@ # working versions to speed up resolution # ############################################ -# Core problematic packages - pin to exact versions -psycopg2-binary==2.9.10 -pgvector==0.3.6 -prometheus-client==0.20.0 # NOTE: pinned lower than 0.21.0 due to compatibility issues with exporter libraries +anyio==4.6.2.post1 + +# Common transitive dependencies that cause backtracking +# NOTE: certifi pinned to exact version for CI determinism; security updates handled via base image updates +certifi==2024.12.14 +charset-normalizer==3.4.0 +click==8.1.8 +coverage==7.6.9 +google-api-core==2.21.0 # Google Cloud packages - often cause conflicts google-auth==2.35.0 google-cloud-core==2.4.1 google-cloud-storage==2.17.0 -google-api-core==2.21.0 googleapis-common-protos==1.65.0 - -# Common transitive dependencies that cause backtracking -# NOTE: certifi pinned to exact version for CI determinism; security updates handled via base image updates -certifi==2024.12.14 -urllib3==2.2.3 -requests==2.32.4 +greenlet==3.1.1 httpx>=0.25.0,<0.29.0 # Ensures compatibility with AnyIO 4.x and prevents breaking changes -charset-normalizer==3.4.0 idna==3.10 +pgvector==0.3.6 +prometheus-client==0.20.0 # NOTE: pinned lower than 0.21.0 due to compatibility issues with exporter libraries +# Core problematic packages - pin to exact versions +psycopg2-binary==2.9.10 -# FastAPI ecosystem -starlette==0.41.3 -anyio==4.6.2.post1 -sniffio==1.3.1 -typing-extensions==4.12.2 - -# Database related -sqlalchemy==2.0.36 -greenlet==3.1.1 +# Other common packages +pydantic==2.11.7 +pydantic-core==2.33.2 # Testing/dev tools pytest==8.4.1 -coverage==7.6.9 +python-dotenv==1.0.1 +pyyaml==6.0.2 +requests==2.32.4 +rich==13.9.4 ruff==0.8.4 +sniffio==1.3.1 -# Other common packages -pydantic==2.11.7 -pydantic-core==2.33.2 -click==8.1.8 -rich==13.9.4 -pyyaml==6.0.2 -python-dotenv==1.0.1 \ No newline at end of file +# Database related +sqlalchemy==2.0.36 + +# FastAPI ecosystem +starlette==0.41.3 +typing-extensions==4.12.2 +urllib3==2.2.3 diff --git a/dependencies/requirements-api.txt b/dependencies/requirements-api.txt index f72d2f149..858a0c503 100644 --- a/dependencies/requirements-api.txt +++ b/dependencies/requirements-api.txt @@ -3,42 +3,41 @@ # Exact mirror of pyproject.toml base+prod # ############################################ +certifi==2024.12.14 +click==8.1.8 # Base Dependencies (from dependencies) fastapi==0.116.1 -uvicorn[standard]==0.35.0 -python-multipart==0.0.18 -pydantic==2.11.7 -PyJWT==2.8.0 -# Database & Storage -sqlalchemy==2.0.36 -psycopg2-binary==2.9.10 +# API runtime dependencies +Flask==3.0.3 +flask-restx==1.3.0 + +# Production Dependencies (from prod extra) +gunicorn>=23.0.0,<24.0.0 + +# HF model utilities +huggingface_hub>=0.34.0,<1.0 +loguru==0.7.2 pgvector==0.3.6 -redis==5.0.8 +prometheus-client==0.20.0 +psycopg2-binary==2.9.10 +pydantic==2.11.7 +PyJWT==2.8.0 # Utilities python-dotenv==1.0.1 +python-multipart==0.0.18 pyyaml==6.0.2 +redis==5.0.8 requests==2.32.4 -certifi==2024.12.14 -click==8.1.8 rich==13.9.4 -loguru==0.7.2 - -# Production Dependencies (from prod extra) -gunicorn>=23.0.0,<24.0.0 -prometheus-client==0.20.0 sentry-sdk[fastapi]==2.12.0 -# API runtime dependencies -Flask==3.0.3 -flask-restx==1.3.0 - -# HF model utilities -huggingface_hub>=0.34.0,<1.0 - -# NLP model runtime -transformers==4.55.0 +# Database & Storage +sqlalchemy==2.0.36 # Torch runtime (CPU by default; align with repo constraints) torch==2.8.0 +# NLP model runtime +transformers==4.55.0 +uvicorn[standard]==0.35.0 diff --git a/dependencies/requirements-audio.txt b/dependencies/requirements-audio.txt index 8aaad377b..ce235b71c 100644 --- a/dependencies/requirements-audio.txt +++ b/dependencies/requirements-audio.txt @@ -3,12 +3,12 @@ # Exact mirror of pyproject.toml audio # ############################################ +jiwer>=3.0.0,<4.0.0 # Audio Processing (requires system libraries) librosa>=0.10.0,<1.0.0 -soundfile>=0.12.0,<1.0.0 -pydub>=0.25.1,<1.0.0 openai-whisper>=20231117 -jiwer>=3.0.0,<4.0.0 +pydub>=0.25.1,<1.0.0 +soundfile>=0.12.0,<1.0.0 # Note: pyaudio requires system libraries (portaudio) # Install manually if needed: pip install pyaudio @@ -16,4 +16,4 @@ jiwer>=3.0.0,<4.0.0 # On Ubuntu: apt-get install portaudio19-dev && pip install pyaudio # HTTP client dependencies for consistency -# Note: requests and httpx versions are constrained in constraints.txt \ No newline at end of file +# Note: requests and httpx versions are constrained in constraints.txt diff --git a/dependencies/requirements-dev.txt b/dependencies/requirements-dev.txt index 8dc2def4d..554f7c362 100644 --- a/dependencies/requirements-dev.txt +++ b/dependencies/requirements-dev.txt @@ -3,30 +3,30 @@ # Exact mirror of pyproject.toml dev+test # ############################################ +bandit[toml]>=1.7.5 +black>=23.7.0 +coverage[toml]>=7.2.0 +factory-boy>=3.3.0 + +# Legacy Test Support (for Flask-based security tests) +flask>=3.0.3,<4.0.0 +httpx>=0.25.0,<0.29.0 +ipykernel>=6.25.0 +jupyterlab>=4.0.0 +mypy>=1.5.0 +pre-commit>=3.3.0 # Test Dependencies (from test extra) pytest>=8.4.1,<9.0.0 +pytest-asyncio>=0.21.0 +pytest-benchmark>=4.0.0 pytest-cov>=6.2.1,<7.0.0 -pytest-xdist>=3.3.0 pytest-mock>=3.11.0 -pytest-asyncio>=0.21.0 pytest-timeout>=2.1.0 -pytest-benchmark>=4.0.0 -httpx>=0.25.0,<0.29.0 +pytest-xdist>=3.3.0 requests==2.32.4 -coverage[toml]>=7.2.0 -factory-boy>=3.3.0 - -# Legacy Test Support (for Flask-based security tests) -flask>=3.0.3,<4.0.0 # Development Dependencies (from dev extra) ruff>=0.0.280 -black>=23.7.0 -mypy>=1.5.0 -bandit[toml]>=1.7.5 -pre-commit>=3.3.0 -jupyterlab>=4.0.0 -ipykernel>=6.25.0 # Keep safety optional in CI to avoid resolver backtracking # safety>=2.3.0 diff --git a/dependencies/requirements-ml.txt b/dependencies/requirements-ml.txt index 01ae37445..1b64a2cc4 100644 --- a/dependencies/requirements-ml.txt +++ b/dependencies/requirements-ml.txt @@ -3,32 +3,31 @@ # Exact mirror of pyproject.toml ml extra # ############################################ -# Core ML/AI Dependencies -torch>=2.0.0,<3.0.0 -transformers>=4.55.0,<5.0.0 -datasets>=2.14.0,<5.0.0 accelerate>=0.20.0,<1.0.0 -onnx>=1.14.0,<2.0.0 -onnxruntime>=1.22.1,<2.0.0 -sentencepiece>=0.1.99,<1.0.0 -tokenizers>=0.21.4,<1.0.0 - -# Deep Learning Frameworks -scikit-learn>=1.3.0,<2.0.0 -pandas>=2.0.0,<3.0.0 -numpy>=1.24.0,<3.0.0 -scipy>=1.11.0,<2.0.0 +datasets>=2.14.0,<5.0.0 +gensim>=4.3.0,<5.0.0 +httpx>=0.25.0,<0.29.0 # Text Processing nltk>=3.8,<4.0.0 -spacy>=3.6.0,<4.0.0 -gensim>=4.3.0,<5.0.0 -textblob>=0.17.0,<1.0.0 +numpy>=1.24.0,<3.0.0 +onnx>=1.14.0,<2.0.0 +onnxruntime>=1.22.1,<2.0.0 +pandas>=2.0.0,<3.0.0 -# Note: For GPU support, use: pip install .[ml-gpu] +# Note: For GPU support, use: pip install .[ml-gpu] # For audio processing, use: pip install .[audio] # HTTP client dependencies for consistency requests==2.32.4 -httpx>=0.25.0,<0.29.0 +# Deep Learning Frameworks +scikit-learn>=1.3.0,<2.0.0 +scipy>=1.11.0,<2.0.0 +sentencepiece>=0.1.99,<1.0.0 +spacy>=3.6.0,<4.0.0 +textblob>=0.17.0,<1.0.0 +tokenizers>=0.21.4,<1.0.0 +# Core ML/AI Dependencies +torch>=2.0.0,<3.0.0 +transformers>=4.55.0,<5.0.0 diff --git a/dependencies/requirements.txt b/dependencies/requirements.txt index 1da9912e9..4b6aa349b 100644 --- a/dependencies/requirements.txt +++ b/dependencies/requirements.txt @@ -11,4 +11,4 @@ # Note: For ML functionality, install separately: # pip install -r requirements-ml.txt -# Or use extras: pip install .[ml] \ No newline at end of file +# Or use extras: pip install .[ml] diff --git a/dependencies/requirements_deployment.txt b/dependencies/requirements_deployment.txt index e09c79065..956bee5b5 100644 --- a/dependencies/requirements_deployment.txt +++ b/dependencies/requirements_deployment.txt @@ -1,9 +1,9 @@ -# DEPRECATED: Use requirements-api.txt / requirements-ml.txt + constraints.txt -transformers>=4.55.0,<5.0.0 -torch>=2.7.1,<2.9.0 -scikit-learn>=1.5.0,<2.0.0 +flask>=3.1.1,<4.0.0 +httpx>=0.25.0,<0.29.0 numpy>=2.3.2,<3.0.0 pandas>=2.0.0,<3.0.0 -flask>=3.1.1,<4.0.0 requests==2.32.4 -httpx>=0.25.0,<0.29.0 +scikit-learn>=1.5.0,<2.0.0 +torch>=2.7.1,<2.9.0 +# DEPRECATED: Use requirements-api.txt / requirements-ml.txt + constraints.txt +transformers>=4.55.0,<5.0.0 diff --git a/dependencies/requirements_gcp.txt b/dependencies/requirements_gcp.txt index 75168b348..1b9ef5143 100644 --- a/dependencies/requirements_gcp.txt +++ b/dependencies/requirements_gcp.txt @@ -1,7 +1,7 @@ +flask>=2.0.0 +httpx>=0.25.0,<0.29.0 +numpy>=1.21.0 +requests==2.32.4 # DEPRECATED: Use requirements-api.txt / requirements-ml.txt + constraints.txt torch>=2.0.0 transformers>=4.55.0 -numpy>=1.21.0 -flask>=2.0.0 -requests==2.32.4 -httpx>=0.25.0,<0.29.0 diff --git a/dependencies/requirements_minimal.txt b/dependencies/requirements_minimal.txt index 99b2f493a..11482e90e 100644 --- a/dependencies/requirements_minimal.txt +++ b/dependencies/requirements_minimal.txt @@ -5,19 +5,19 @@ # Web framework flask>=3.1.1,<4.0.0 -# HTTP client -requests==2.32.4 - -# Database -sqlalchemy==2.0.36 -psycopg2-binary==2.9.10 +# Production server +gunicorn>=23.0.0,<24.0.0 # Monitoring prometheus-client==0.20.0 +psycopg2-binary==2.9.10 # Utilities python-dotenv==1.0.1 pyyaml==6.0.2 -# Production server -gunicorn>=23.0.0,<24.0.0 +# HTTP client +requests==2.32.4 + +# Database +sqlalchemy==2.0.36 diff --git a/dependencies/requirements_onnx.txt b/dependencies/requirements_onnx.txt index 37c0aec28..510cced8b 100644 --- a/dependencies/requirements_onnx.txt +++ b/dependencies/requirements_onnx.txt @@ -1,18 +1,18 @@ # ONNX Runtime Requirements # Optimized for inference performance -# Core ML -onnx>=1.14.0 -onnxruntime>=1.22.1 # Web framework flask>=3.1.1,<4.0.0 -# HTTP client -requests==2.32.4 +# Production +gunicorn>=23.0.0,<24.0.0 +# Core ML +onnx>=1.14.0 +onnxruntime>=1.22.1 # Monitoring prometheus-client==0.20.0 -# Production -gunicorn>=23.0.0,<24.0.0 \ No newline at end of file +# HTTP client +requests==2.32.4 diff --git a/dependencies/requirements_production.txt b/dependencies/requirements_production.txt index 2199a877d..3c73a88db 100644 --- a/dependencies/requirements_production.txt +++ b/dependencies/requirements_production.txt @@ -9,9 +9,6 @@ numpy>=2.3.2,<3.0.0 # ONNX Runtime (CPU optimized) onnxruntime>=1.20.0,<2.0.0 -# Tokenization -tokenizers>=0.20.0,<1.0.0 - # Monitoring and metrics prometheus-client==0.20.0 psutil>=6.0.0,<7.0.0 @@ -20,4 +17,7 @@ psutil>=6.0.0,<7.0.0 python-dotenv>=1.0.0,<2.0.0 # HTTP client -requests==2.32.4 \ No newline at end of file +requests==2.32.4 + +# Tokenization +tokenizers>=0.20.0,<1.0.0 diff --git a/dependencies/requirements_secure.txt b/dependencies/requirements_secure.txt index fe65545e9..fae1badea 100644 --- a/dependencies/requirements_secure.txt +++ b/dependencies/requirements_secure.txt @@ -1,30 +1,30 @@ # SECURE API Requirements - Minimal attack surface # Core dependencies only - no ML libraries +bcrypt>=4.0.0 +click==8.1.8 +cryptography>=41.0.0 # FastAPI ecosystem fastapi==0.116.1 -uvicorn[standard]==0.35.0 -python-multipart==0.0.18 +loguru==0.7.2 + +# Monitoring & Logging +prometheus-client==0.20.0 +psycopg2-binary==2.9.10 pydantic==2.11.7 # Security & Auth PyJWT==2.8.0 -cryptography>=41.0.0 -bcrypt>=4.0.0 +python-multipart==0.0.18 + +# Utilities +pyyaml==6.0.2 # HTTP & Networking requests==2.32.4 +rich==13.9.4 +sentry-sdk[fastapi]==2.12.0 # Database sqlalchemy==2.0.36 -psycopg2-binary==2.9.10 - -# Monitoring & Logging -prometheus-client==0.20.0 -sentry-sdk[fastapi]==2.12.0 - -# Utilities -pyyaml==6.0.2 -click==8.1.8 -rich==13.9.4 -loguru==0.7.2 +uvicorn[standard]==0.35.0 diff --git a/dependencies/requirements_unified.txt b/dependencies/requirements_unified.txt index 74b429702..b5c54fa35 100644 --- a/dependencies/requirements_unified.txt +++ b/dependencies/requirements_unified.txt @@ -1,26 +1,26 @@ # DEPRECATED: Use Dockerfile.app with BUILD_TYPE=unified and requirements-api.txt + requirements-ml.txt fastapi==0.116.1 -uvicorn==0.35.0 -pydantic==2.11.7 -PyJWT==2.8.0 -requests==2.32.4 -psutil==5.9.8 -python-multipart==0.0.18 +jiwer==3.0.3 # Ensure compatibility for build-time model pre-bundling numpy==1.26.4 -# NLP/ML for summarizer and emotion model -transformers==4.55.0 +# Whisper transcription +openai-whisper==20250625 +prometheus-client==0.20.0 +psutil==5.9.8 +pydantic==2.11.7 +pydub==0.25.1 +PyJWT==2.8.0 +python-multipart==0.0.18 +requests==2.32.4 sentencepiece==0.1.99 # Torch runtime torch==2.8.0 -# Whisper transcription -openai-whisper==20250625 -pydub==0.25.1 -jiwer==3.0.3 +# NLP/ML for summarizer and emotion model +transformers==4.55.0 +uvicorn==0.35.0 # Optional: websockets if needed by client tests websockets==12.0 -prometheus-client==0.20.0 diff --git a/deployment/.env.flask.example b/deployment/.env.flask.example index a7aa75252..795bdb5f9 100644 --- a/deployment/.env.flask.example +++ b/deployment/.env.flask.example @@ -6,7 +6,7 @@ # ============================================================================= # Flask Host Binding (SECURITY CRITICAL!) -# +# # DEVELOPMENT (RECOMMENDED): FLASK_HOST=127.0.0.1 # ✅ SECURE: Only accepts connections from localhost @@ -41,7 +41,7 @@ FLASK_DEBUG=False # LOCAL DEVELOPMENT (Most Common): # FLASK_HOST=127.0.0.1 -# FLASK_PORT=5000 +# FLASK_PORT=5000 # FLASK_DEBUG=False # DOCKER CONTAINER (For external access): @@ -94,7 +94,7 @@ FLASK_DEBUG=False # ============================================================================= # □ Reviewed host binding setting # □ Confirmed debug mode is disabled for production -# □ Implemented proper authentication if exposing externally +# □ Implemented proper authentication if exposing externally # □ Set up reverse proxy/load balancer for external access # □ Configured firewall rules # □ Enabled HTTPS/TLS encryption @@ -114,6 +114,6 @@ DEPLOYMENT_TYPE=local MODEL_NAME=your-username/samo-dl-emotion-model HF_TOKEN=your_hf_token_here -# Model Processing Settings +# Model Processing Settings MAX_LENGTH=128 BATCH_SIZE=32 diff --git a/deployment/CUSTOM_MODEL_DEPLOYMENT_GUIDE.md b/deployment/CUSTOM_MODEL_DEPLOYMENT_GUIDE.md index 3e1d80601..31b6c21c3 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,9 +421,9 @@ 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 -Your custom model will provide much better accuracy for your specific use case! 🎉 \ No newline at end of file +Your custom model will provide much better accuracy for your specific use case! 🎉 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/__init__.py b/deployment/__init__.py new file mode 100644 index 000000000..31738ee73 --- /dev/null +++ b/deployment/__init__.py @@ -0,0 +1,5 @@ +"""Deployment package for SAMO-DL models. + +This package contains various deployment configurations and API servers for different +environments and use cases. +""" diff --git a/deployment/api_server.py b/deployment/api_server.py deleted file mode 100644 index d1f4f4b4c..000000000 --- a/deployment/api_server.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -""" -🚀 EMOTION DETECTION API SERVER -=============================== -REST API server for emotion detection with comprehensive security headers. -""" - -# Import all modules first -import logging -from flask import Flask, request, jsonify -from inference import EmotionDetector - -# Import security setup using relative import -from ..src.security_setup import setup_security_middleware - -# Configure logging after all imports -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -app = Flask(__name__) - -# Initialize security headers middleware -security_middleware = setup_security_middleware(app, "development") - -# Initialize emotion detector -try: - detector = EmotionDetector() - logger.info("✅ Emotion detector initialized successfully!") -except Exception as e: - logger.error(f"❌ Failed to initialize emotion detector: {e}") - detector = None - -@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 [] - }) - -@app.route('/predict', methods=['POST']) -def predict_emotion(): - """Predict emotion for given text""" - if detector is None: - return jsonify({'error': 'Model not loaded'}), 500 - - try: - data = request.get_json() - text = data.get('text', '') - - if not text: - 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 - -@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 - - try: - data = request.get_json() - texts = data.get('texts', []) - - if not texts: - return jsonify({'error': 'No texts provided'}), 400 - - results = detector.predict_batch(texts) - return jsonify({'results': results}) - - except Exception as e: - logger.error(f"Batch prediction error: {e}") - return jsonify({'error': str(e)}), 500 - -@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__': - 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("🌐 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) diff --git a/deployment/cloud-run/config.py b/deployment/cloud-run/config.py deleted file mode 100644 index d44221d89..000000000 --- a/deployment/cloud-run/config.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Environment Configuration Management - Phase 3 Cloud Run Optimization -Provides environment-specific settings for development, staging, and production -""" - -import os -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 - max_instances: int = 10 - min_instances: int = 1 - concurrency: int = 80 - timeout_seconds: int = 300 - - # Auto-scaling - target_cpu_utilization: float = 0.7 - target_memory_utilization: float = 0.8 - scale_up_cooldown_seconds: int = 60 - scale_down_cooldown_seconds: int = 300 - - # Health checks - health_check_interval_seconds: int = 30 - health_check_timeout_seconds: int = 10 - health_check_retries: int = 3 - - # Graceful shutdown - graceful_shutdown_timeout_seconds: int = 30 - - # Monitoring - enable_monitoring: bool = True - enable_metrics: bool = True - log_level: str = "info" - - # Rate limiting - max_requests_per_minute: int = 1000 - rate_limit_window_seconds: int = 60 - - # Security - enable_cors: bool = True - cors_origins: Optional[List[str]] = None - 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.config = self._load_environment_config() - - def _load_environment_config(self) -> CloudRunConfig: - """Load configuration based on environment""" - 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'), - enable_cors=True, - cors_origins=os.getenv('CORS_ORIGINS', '*').split(','), - enable_rate_limiting=True, - enable_input_sanitization=True - ) - - if self.environment == 'staging': - return CloudRunConfig( - memory_limit_mb=1024, - cpu_limit=1, - max_instances=5, - min_instances=0, - concurrency=40, - timeout_seconds=180, - target_cpu_utilization=0.6, - target_memory_utilization=0.7, - health_check_interval_seconds=60, - graceful_shutdown_timeout_seconds=15, - enable_monitoring=True, - enable_metrics=True, - log_level='debug', - max_requests_per_minute=500, - enable_cors=True, - cors_origins=['*'], - enable_rate_limiting=True, - enable_input_sanitization=True - ) - return CloudRunConfig( - memory_limit_mb=512, - cpu_limit=1, - max_instances=2, - min_instances=0, - concurrency=20, - timeout_seconds=120, - target_cpu_utilization=0.5, - target_memory_utilization=0.6, - health_check_interval_seconds=120, - graceful_shutdown_timeout_seconds=10, - enable_monitoring=False, - enable_metrics=False, - log_level='debug', - max_requests_per_minute=100, - enable_cors=True, - cors_origins=['*'], - enable_rate_limiting=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 - } - - 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 - } - - 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 - } - - 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 - } - - def validate_config(self) -> None: - """Validate configuration settings""" - # Validate resource limits - if not 512 <= self.config.memory_limit_mb <= 8192: - raise AssertionError("Memory limit must be between 512MB and 8GB") - if not 1 <= self.config.cpu_limit <= 8: - raise AssertionError("CPU limit must be between 1 and 8") - if not 1 <= self.config.max_instances <= 100: - raise AssertionError("Max instances must be between 1 and 100") - if not 0 <= self.config.min_instances <= self.config.max_instances: - raise AssertionError("Min instances cannot exceed max instances") - - # Validate timeouts - if not 10 <= self.config.timeout_seconds <= 900: - raise AssertionError("Timeout must be between 10 and 900 seconds") - if not 5 <= self.config.health_check_interval_seconds <= 300: - raise AssertionError("Health check interval must be between 5 and 300 seconds") - - # Validate utilization targets - if not 0.1 <= self.config.target_cpu_utilization <= 0.9: - raise AssertionError("CPU utilization target must be between 0.1 and 0.9") - if not 0.1 <= self.config.target_memory_utilization <= 0.9: - raise AssertionError("Memory utilization target must be between 0.1 and 0.9") - - 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 - }, - '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 diff --git a/deployment/cloud-run/health_monitor.py b/deployment/cloud-run/health_monitor.py deleted file mode 100644 index 8f681a028..000000000 --- a/deployment/cloud-run/health_monitor.py +++ /dev/null @@ -1,242 +0,0 @@ -""" -Cloud Run Health Monitor - Phase 3 Optimization -Provides comprehensive health checks, graceful shutdown, and monitoring -""" - -import os -import sys -import time -import signal -import logging -from typing import Dict, Any, Optional -from dataclasses import dataclass -from datetime import datetime -import psutil - -# Configure logging -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 - cpu_usage_percent: float - active_requests: int - timestamp: datetime - error_message: Optional[str] = None - -class HealthMonitor: - """Comprehensive health monitoring for Cloud Run""" - - def __init__(self): - self.start_time = datetime.now() - 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') - - # Register graceful shutdown handlers - signal.signal(signal.SIGTERM, self._graceful_shutdown) - signal.signal(signal.SIGINT, self._graceful_shutdown) - - logger.info(f"Health monitor initialized with {self.shutdown_timeout}s shutdown timeout") - - def _graceful_shutdown(self, signum, frame): - """Handle graceful shutdown""" - logger.info(f"Received shutdown signal {signum}, starting graceful shutdown...") - self.is_shutting_down = True - - # Wait for active requests to complete - start_wait = time.time() - while self.active_requests > 0 and (time.time() - start_wait) < self.shutdown_timeout: - logger.info(f"Waiting for {self.active_requests} active requests to complete...") - 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") - else: - logger.info("Graceful shutdown completed successfully") - - sys.exit(0) - - def get_system_metrics(self) -> Dict[str, float]: - """Get current system resource usage""" - try: - process = psutil.Process() - 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() - } - 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 - } - - @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' - ] - - for module_name in modules_to_check: - try: - 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 - } - - return { - '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 - } - - @staticmethod - def check_api_health() -> Dict[str, Any]: - """Check API endpoint health""" - try: - start_time = time.time() - - # Test internal health endpoint - from secure_api_server import app - - # Use Flask test client instead of FastAPI TestClient - with app.test_client() as client: - response = client.get("/health") - - response_time = (time.time() - start_time) * 1000 - - if response.status_code == 200: - return { - '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 - } - except Exception as e: - return { - '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() - } - - # Get system metrics - system_metrics = self.get_system_metrics() - - # Check model health - model_health = self.check_model_health() - - # Check API health - 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' - - # Check resource thresholds - 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' - - 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) - }, - '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'], - active_requests=self.active_requests, - timestamp=datetime.now(), - error_message=model_health.get('error') or api_health.get('error') - ) - - # Keep only last 100 metrics - if len(self.health_metrics) > 100: - oldest_key = min(self.health_metrics.keys()) - del self.health_metrics[oldest_key] - - return health_data - - def request_started(self): - """Track request start""" - with self.lock: - self.active_requests += 1 - - def request_completed(self): - """Track request completion""" - 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 diff --git a/deployment/cloud-run/minimal_api_server.py b/deployment/cloud-run/minimal_api_server.py deleted file mode 100644 index 5f90bc504..000000000 --- a/deployment/cloud-run/minimal_api_server.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -""" -Minimal Emotion Detection API Server -Uses known working PyTorch/transformers combination -Matches the actual model architecture: RoBERTa with 12 emotion classes -""" - -import logging -import os -import time -import os - -from flask import Flask, request, jsonify -import psutil -from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST - -# Import shared model utilities -from model_utils import ( - ensure_model_loaded, predict_emotions, get_model_status, - MAX_TEXT_LENGTH -) - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -# Initialize Flask app -app = Flask(__name__) - -# 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') - - -def initialize_model(): - """Initialize model using shared utilities.""" - logger.info("🔄 Initializing model...") - success = ensure_model_loaded() - if success: - logger.info("✅ Model initialized successfully") - else: - logger.error("❌ Model initialization failed") - - -@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" - - # 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 - } - } - - 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 - - -@app.route('/predict', methods=['POST']) -def predict(): - """Predict emotions from text.""" - start_time = time.time() - - 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 - - data = request.get_json() - text = data.get('text', '').strip() - - if not text: - 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 - - # Ensure model is loaded - initialize_model() - - # Make prediction using shared utilities - 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() - - 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 - - -@app.route('/metrics', methods=['GET']) -def metrics(): - """Prometheus metrics endpoint.""" - return generate_latest(), 200, {'Content-Type': CONTENT_TYPE_LATEST} - - -@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__': - # 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) diff --git a/deployment/cloud-run/secure_api_server.py b/deployment/cloud-run/secure_api_server.py deleted file mode 100644 index beca133e2..000000000 --- a/deployment/cloud-run/secure_api_server.py +++ /dev/null @@ -1,513 +0,0 @@ -#!/usr/bin/env python3 -""" -🚀 SECURE EMOTION DETECTION API FOR CLOUD RUN -============================================ -Production-ready Flask API with comprehensive security features and Swagger documentation. -""" - -import os -import time -import logging -import uuid -import threading -import hmac -from flask import Flask, request, jsonify, g -from flask_restx import Api, Resource, fields, Namespace -from functools import wraps - -# Import security modules -from security_headers import add_security_headers -from rate_limiter import rate_limit - -# Import shared model utilities -from model_utils import ( - ensure_model_loaded, predict_emotions, get_model_status, - validate_text_input, -) - -# Configure logging for Cloud Run -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -app = Flask(__name__) - -# Add security headers -add_security_headers(app) - -# Register root endpoint BEFORE Flask-RESTX initialization to avoid conflicts -@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() - }) - except Exception as e: - logger.error(f"Root endpoint error for {request.remote_addr}: {str(e)}") - 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', - # Temporarily disable Swagger docs to avoid 500 errors - # doc='/docs', - 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' - } -}) - -# 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') -}) - -# Security configuration from environment variables -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")) -RATE_LIMIT_PER_MINUTE = int(os.environ.get("RATE_LIMIT_PER_MINUTE", "100")) -MODEL_PATH = os.environ.get("MODEL_PATH", "/app/model") -PORT = int(os.environ.get("PORT", "8080")) - -# Global variables for model state (thread-safe with locks) -model = None -tokenizer = None -emotion_mapping = None -model_loading = False -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'] - -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') - 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 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 = ['<', '>', '"', "'", '&', ';', '|', '`', '$', '(', ')', '{{', '}}'] - for char in dangerous_chars: - text = text.replace(char, '') - - # Limit length - if len(text) > MAX_INPUT_LENGTH: - text = text[:MAX_INPUT_LENGTH] - - return text.strip() - -def load_model(): - """Load the emotion detection model using shared utilities""" - # Use the shared model loading function - success = ensure_model_loaded() - if not success: - 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()) - - 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() - } - 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) - -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})") - - # 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']} - logger.debug(f"📋 Request headers: {headers_to_log}") - -@app.after_request -def after_request(response): - """Add request tracking headers""" - 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 - - # 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 - - - -@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) - 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() - } - else: - logger.warning("Health check failed - model not ready") - 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) - -@main_ns.route('/predict') -class Predict(Resource): - @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) - @rate_limit(RATE_LIMIT_PER_MINUTE) - @require_api_key - def post(self): - """Predict emotion for a single text input""" - 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: - logger.warning(f"Missing text field in request from {request.remote_addr}") - return create_error_response('Missing text field', 400) - - 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) - - # Sanitize input - try: - text = sanitize_input(text) - except ValueError as e: - logger.warning(f"Input sanitization failed for {request.remote_addr}: {str(e)}") - 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) - - # Predict emotion - logger.info(f"Processing prediction request for {request.remote_addr}") - result = predict_emotion(text) - 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) - -@main_ns.route('/predict_batch') -class PredictBatch(Resource): - @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) - @rate_limit(RATE_LIMIT_PER_MINUTE) - @require_api_key - def post(self): - """Predict emotions for multiple text inputs""" - 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: - logger.warning(f"Missing texts field in batch request from {request.remote_addr}") - return create_error_response('Missing texts field', 400) - - 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) - - 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) - - # 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) - - # Process each text - 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)}") - continue - - 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) - -@main_ns.route('/emotions') -class Emotions(Resource): - @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() - } - except Exception as e: - logger.error(f"Emotions endpoint error for {request.remote_addr}: {str(e)}") - return create_error_response('Internal server error', 500) - -# Admin endpoints -@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) - @require_api_key - def get(self): - """Get detailed model status (admin only)""" - try: - # Get model status from shared utilities - logger.info(f"Admin model status request from {request.remote_addr}") - 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) - -@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) - @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() - } - except Exception as e: - logger.error(f"Security status error for {request.remote_addr}: {str(e)}") - 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) - -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) - -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) - -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) - -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) - -# Register error handlers directly -api.error_handlers[429] = rate_limit_exceeded -api.error_handlers[500] = internal_error -api.error_handlers[404] = not_found -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"🌐 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)}") - raise - -# Initialize model when the application starts -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) -else: - # For production deployment - don't initialize during import - # Model will be initialized when the app actually starts - logger.info("🚀 Production deployment detected - model will be initialized on first request") - -# Root endpoint is now registered BEFORE Flask-RESTX initialization to avoid conflicts - -# Make Flask app available to Gunicorn diff --git a/deployment/cloud-run/test_direct_errorhandler.py b/deployment/cloud-run/test_direct_errorhandler.py deleted file mode 100644 index 00f16200a..000000000 --- a/deployment/cloud-run/test_direct_errorhandler.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -""" -Test direct error handler registration -""" - -import os -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}") - exit(1) - -try: - app = Flask(__name__) - 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 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 diff --git a/deployment/cloud-run/test_minimal_swagger.py b/deployment/cloud-run/test_minimal_swagger.py deleted file mode 100644 index a372cc6c7..000000000 --- a/deployment/cloud-run/test_minimal_swagger.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -""" -Minimal test to isolate Swagger docs issue -""" - -import os -from flask import Flask, jsonify -from flask_restx import Api, Resource, Namespace - -# Create Flask app -app = Flask(__name__) - -# Register root endpoint first -@app.route('/') -def root(): - 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' -) - -# Create namespace -main_ns = Namespace('api', description='Main operations') -api.add_namespace(main_ns) - -# Test endpoint -@main_ns.route('/health') -class Health(Resource): - def get(self): - return {'status': 'healthy'} - -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 diff --git a/deployment/cloud-run/test_routing_minimal.py b/deployment/cloud-run/test_routing_minimal.py deleted file mode 100644 index 73f2ea03e..000000000 --- a/deployment/cloud-run/test_routing_minimal.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -""" -Minimal test script to isolate Flask-RESTX routing issues -""" - -import os -from flask import Flask, jsonify -from flask_restx import Api, Resource, Namespace - -# Create Flask app -app = Flask(__name__) - -# Initialize Flask-RESTX API -api = Api( - app, - 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' -api.add_namespace(main_ns) - -# Test endpoint in namespace -@main_ns.route('/health') -class Health(Resource): - def get(self): - return {'status': 'healthy'} - -# Test direct Flask route BEFORE API setup -@app.route('/test_before') -def test_before(): - return jsonify({'message': 'This route was added before API setup'}) - -# Test direct Flask route AFTER API setup -@app.route('/test_after') -def test_after(): - return jsonify({'message': 'This route was added after API setup'}) - -# Test root endpoint - this should work now -@app.route('/') -def root(): - return jsonify({'message': 'Root endpoint'}) - -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 diff --git a/deployment/cloud-run/test_swagger_no_model.py b/deployment/cloud-run/test_swagger_no_model.py deleted file mode 100644 index 09b350a00..000000000 --- a/deployment/cloud-run/test_swagger_no_model.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -""" -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' - -# Create Flask app -app = Flask(__name__) - -# Register root endpoint first -@app.route('/') -def home(): - 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' -) - -# Create namespace -main_ns = Namespace('api', description='Main operations') -api.add_namespace(main_ns) - -# Test endpoint -@main_ns.route('/health') -class Health(Resource): - def get(self): - return {'status': 'healthy'} - -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 diff --git a/deployment/cloud-run/README-consolidated-dockerfile.md b/deployment/cloud_run/README-consolidated-dockerfile.md similarity index 100% rename from deployment/cloud-run/README-consolidated-dockerfile.md rename to deployment/cloud_run/README-consolidated-dockerfile.md diff --git a/deployment/cloud_run/__init__.py b/deployment/cloud_run/__init__.py new file mode 100644 index 000000000..0d1cd5c60 --- /dev/null +++ b/deployment/cloud_run/__init__.py @@ -0,0 +1,4 @@ +"""Cloud Run deployment package. + +Contains configuration and deployment scripts for Google Cloud Run. +""" diff --git a/deployment/cloud-run/cloudbuild.yaml b/deployment/cloud_run/cloudbuild.yaml similarity index 100% rename from deployment/cloud-run/cloudbuild.yaml rename to deployment/cloud_run/cloudbuild.yaml diff --git a/deployment/cloud_run/config.py b/deployment/cloud_run/config.py new file mode 100644 index 000000000..d0b74fee5 --- /dev/null +++ b/deployment/cloud_run/config.py @@ -0,0 +1,239 @@ +"""Environment Configuration Management - Phase 3 Cloud Run Optimization +Provides environment-specific settings for development, staging, and production +""" + +import os +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + + +@dataclass +class CloudRunConfig: + """Cloud Run specific configuration.""" + + # Resource allocation + memory_limit_mb: int = 2048 + cpu_limit: int = 2 + max_instances: int = 10 + min_instances: int = 1 + concurrency: int = 80 + timeout_seconds: int = 300 + + # Auto-scaling + target_cpu_utilization: float = 0.7 + target_memory_utilization: float = 0.8 + scale_up_cooldown_seconds: int = 60 + scale_down_cooldown_seconds: int = 300 + + # Health checks + health_check_interval_seconds: int = 30 + health_check_timeout_seconds: int = 10 + health_check_retries: int = 3 + + # Graceful shutdown + graceful_shutdown_timeout_seconds: int = 30 + + # Monitoring + enable_monitoring: bool = True + enable_metrics: bool = True + log_level: str = "info" + + # Rate limiting + max_requests_per_minute: int = 1000 + rate_limit_window_seconds: int = 60 + + # Security + enable_cors: bool = True + cors_origins: Optional[List[str]] = None + enable_rate_limiting: bool = True + enable_input_sanitization: bool = True + + +class EnvironmentConfig: + """Environment-specific configuration management.""" + + def __init__(self, environment: Optional[str] = None): + 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": + 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", + ), + enable_cors=True, + cors_origins=os.getenv("CORS_ORIGINS", "*").split(","), + enable_rate_limiting=True, + enable_input_sanitization=True, + ) + + if self.environment == "staging": + return CloudRunConfig( + memory_limit_mb=1024, + cpu_limit=1, + max_instances=5, + min_instances=0, + concurrency=40, + timeout_seconds=180, + target_cpu_utilization=0.6, + target_memory_utilization=0.7, + health_check_interval_seconds=60, + graceful_shutdown_timeout_seconds=15, + enable_monitoring=True, + enable_metrics=True, + log_level="debug", + max_requests_per_minute=500, + enable_cors=True, + cors_origins=["*"], + enable_rate_limiting=True, + enable_input_sanitization=True, + ) + return CloudRunConfig( + memory_limit_mb=512, + cpu_limit=1, + max_instances=2, + min_instances=0, + concurrency=20, + timeout_seconds=120, + target_cpu_utilization=0.5, + target_memory_utilization=0.6, + health_check_interval_seconds=120, + graceful_shutdown_timeout_seconds=10, + enable_monitoring=False, + enable_metrics=False, + log_level="debug", + max_requests_per_minute=100, + enable_cors=True, + cors_origins=["*"], + enable_rate_limiting=False, + enable_input_sanitization=False, + ) + + def get_gunicorn_config(self) -> Dict[str, Any]: + """Get Gunicorn configuration for Cloud Run.""" + port = os.getenv("PORT", "8080") + return { + "bind": f"0.0.0.0:{port}", + "workers": 1, # Cloud Run best practice + "threads": 8, + "timeout": self.config.timeout_seconds, # Use finite timeout + "keepalive": 5, + "max_requests": 1000, + "max_requests_jitter": 100, + "access_logfile": "-", + "error_logfile": "-", + "loglevel": self.config.log_level, + "preload_app": True, + "worker_class": "sync", + # Removed worker_connections as it's not used with sync worker + } + + 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, + } + + 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, + } + + 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, + } + + def validate_config(self) -> None: + """Validate configuration settings.""" + # Validate resource limits + if not 512 <= self.config.memory_limit_mb <= 8192: + raise AssertionError("Memory limit must be between 512MB and 8GB") + if not 1 <= self.config.cpu_limit <= 8: + raise AssertionError("CPU limit must be between 1 and 8") + if not 1 <= self.config.max_instances <= 100: + raise AssertionError("Max instances must be between 1 and 100") + if not 0 <= self.config.min_instances <= self.config.max_instances: + raise AssertionError("Min instances cannot exceed max instances") + + # Validate timeouts + if not 10 <= self.config.timeout_seconds <= 900: + raise AssertionError("Timeout must be between 10 and 900 seconds") + if not 5 <= self.config.health_check_interval_seconds <= 300: + raise AssertionError( + "Health check interval must be between 5 and 300 seconds", + ) + + # Validate utilization targets + if not 0.1 <= self.config.target_cpu_utilization <= 0.9: + raise AssertionError("CPU utilization target must be between 0.1 and 0.9") + if not 0.1 <= self.config.target_memory_utilization <= 0.9: + raise AssertionError( + "Memory utilization target must be between 0.1 and 0.9", + ) + + 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, + }, + "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 diff --git a/deployment/cloud-run/debug_api_import.py b/deployment/cloud_run/debug_api_import.py similarity index 82% rename from deployment/cloud-run/debug_api_import.py rename to deployment/cloud_run/debug_api_import.py index 9ceee410d..01cb4c86e 100644 --- a/deployment/cloud-run/debug_api_import.py +++ b/deployment/cloud_run/debug_api_import.py @@ -1,10 +1,8 @@ #!/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 +import sys # Add current directory to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -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}") @@ -39,9 +39,9 @@ print("4. Creating API object...") api = Api( app, - version='1.0.0', - title='Test API', - description='Test API for debugging' + 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}") @@ -51,9 +51,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 +65,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 +77,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 similarity index 79% rename from deployment/cloud-run/debug_errorhandler.py rename to deployment/cloud_run/debug_errorhandler.py index 1e78cfe2f..b24459c0b 100644 --- a/deployment/cloud-run/debug_errorhandler.py +++ b/deployment/cloud_run/debug_errorhandler.py @@ -1,10 +1,8 @@ #!/usr/bin/env python3 -""" -Debug script to investigate the errorhandler issue -""" +"""Debug script to investigate the errorhandler issue.""" -import sys import os +import sys # Add current directory to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -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}") @@ -23,9 +22,9 @@ app = Flask(__name__) api = Api( app, - version='1.0.0', - title='Test API', - description='Test API for debugging' + version="1.0.0", + title="Test API", + description="Test API for debugging", ) print("✅ API object created successfully") except Exception as e: @@ -33,26 +32,26 @@ 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 +62,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 similarity index 75% rename from deployment/cloud-run/debug_errorhandler_detailed.py rename to deployment/cloud_run/debug_errorhandler_detailed.py index 2aecdcb8d..0330b94e9 100644 --- a/deployment/cloud-run/debug_errorhandler_detailed.py +++ b/deployment/cloud_run/debug_errorhandler_detailed.py @@ -1,79 +1,84 @@ #!/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' +import sys + +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}") - exit(1) + sys.exit(1) 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) + 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)}") - 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 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/deploy_production.sh b/deployment/cloud_run/deploy_production.sh similarity index 99% rename from deployment/cloud-run/deploy_production.sh rename to deployment/cloud_run/deploy_production.sh index 5e76a9a4b..e6bb716ad 100755 --- a/deployment/cloud-run/deploy_production.sh +++ b/deployment/cloud_run/deploy_production.sh @@ -154,4 +154,4 @@ echo " - Image: ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NA echo " - Server: Gunicorn WSGI (production-ready)" echo " - Workers: 2 (configurable via GUNICORN_WORKERS)" echo " - Max Length: 512 characters (configurable via MAX_LENGTH)" -echo " - Status: ✅ PRODUCTION READY" \ No newline at end of file +echo " - Status: ✅ PRODUCTION READY" diff --git a/deployment/cloud-run/deploy_secure.sh b/deployment/cloud_run/deploy_secure.sh similarity index 99% rename from deployment/cloud-run/deploy_secure.sh rename to deployment/cloud_run/deploy_secure.sh index b0bb1285f..3dcb95ebf 100755 --- a/deployment/cloud-run/deploy_secure.sh +++ b/deployment/cloud_run/deploy_secure.sh @@ -167,4 +167,4 @@ echo " - Service: ${SERVICE_NAME}" echo " - Region: ${REGION}" echo " - Image: ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPOSITORY}/${IMAGE_NAME}:latest" echo " - Security: Enhanced with input sanitization, rate limiting, and security headers" -echo " - Status: ✅ SECURE & PRODUCTION READY" \ No newline at end of file +echo " - Status: ✅ SECURE & PRODUCTION READY" diff --git a/deployment/cloud-run/docs_blueprint.py b/deployment/cloud_run/docs_blueprint.py similarity index 52% rename from deployment/cloud-run/docs_blueprint.py rename to deployment/cloud_run/docs_blueprint.py index 169a6a289..12d27ff49 100644 --- a/deployment/cloud-run/docs_blueprint.py +++ b/deployment/cloud_run/docs_blueprint.py @@ -1,41 +1,42 @@ from __future__ import annotations import os -from flask import Blueprint, Response, jsonify, render_template, g +from flask import Blueprint, Response, g, jsonify, render_template -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 new file mode 100644 index 000000000..1fe6d7f98 --- /dev/null +++ b/deployment/cloud_run/health_monitor.py @@ -0,0 +1,259 @@ +"""Cloud Run Health Monitor - Phase 3 Optimization +Provides comprehensive health checks, graceful shutdown, and monitoring +""" + +import logging +import os +import signal +import sys +import time +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, Optional + +import psutil + +# Configure logging +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 + cpu_usage_percent: float + active_requests: int + timestamp: datetime + error_message: Optional[str] = None + + +class HealthMonitor: + """Comprehensive health monitoring for Cloud Run.""" + + def __init__(self): + self.start_time = datetime.now() + 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", + ) + + # Register graceful shutdown handlers + signal.signal(signal.SIGTERM, self._graceful_shutdown) + signal.signal(signal.SIGINT, self._graceful_shutdown) + + logger.info( + f"Health monitor initialized with {self.shutdown_timeout}s shutdown timeout", + ) + + def _graceful_shutdown(self, signum, frame): + """Handle graceful shutdown.""" + logger.info(f"Received shutdown signal {signum}, starting graceful shutdown...") + self.is_shutting_down = True + + # Wait for active requests to complete + start_wait = time.time() + while ( + self.active_requests > 0 + and (time.time() - start_wait) < self.shutdown_timeout + ): + logger.info( + f"Waiting for {self.active_requests} active requests to complete...", + ) + 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", + ) + else: + logger.info("Graceful shutdown completed successfully") + + sys.exit(0) + + def get_system_metrics(self) -> Dict[str, float]: + """Get current system resource usage.""" + try: + process = psutil.Process() + 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(), + } + 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, + } + + @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) + + # 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", + ] + + for module_name in modules_to_check: + try: + 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, + } + + return { + "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, + } + + @staticmethod + def check_api_health() -> Dict[str, Any]: + """Check API endpoint health.""" + try: + start_time = time.time() + + # Test internal health endpoint + from secure_api_server import app + + # Use Flask test client instead of FastAPI TestClient + with app.test_client() as client: + response = client.get("/health") + + response_time = (time.time() - start_time) * 1000 + + if response.status_code == 200: + return { + "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, + } + except Exception as e: + return { + "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(), + } + + # Get system metrics + system_metrics = self.get_system_metrics() + + # Check model health + model_health = self.check_model_health() + + # Check API health + 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" + + # Check resource thresholds (only if not already unhealthy) + if overall_status == "healthy": + 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" + + 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), + }, + "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"], + active_requests=self.active_requests, + timestamp=datetime.now(), + error_message=model_health.get("error") or api_health.get("error"), + ) + + # Keep only last 100 metrics + if len(self.health_metrics) > 100: + oldest_key = min(self.health_metrics.keys()) + del self.health_metrics[oldest_key] + + return health_data + + def request_started(self): + """Track request start.""" + with self.lock: + self.active_requests += 1 + + def request_completed(self): + """Track request completion.""" + 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 diff --git a/deployment/cloud_run/minimal_api_server.py b/deployment/cloud_run/minimal_api_server.py new file mode 100644 index 000000000..4763e2fe4 --- /dev/null +++ b/deployment/cloud_run/minimal_api_server.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Minimal Emotion Detection API Server +Uses known working PyTorch/transformers combination +Matches the actual model architecture: RoBERTa with 12 emotion classes +""" + +import logging +import os +import time + +import psutil +from flask import Flask, jsonify, request + +# Import shared model utilities +from model_utils import ( + MAX_TEXT_LENGTH, + ensure_model_loaded, + get_model_status, + predict_emotions, +) +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize Flask app +app = Flask(__name__) + +# 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") + + +def initialize_model(): + """Initialize model using shared utilities.""" + logger.info("🔄 Initializing model...") + success = ensure_model_loaded() + if success: + logger.info("✅ Model initialized successfully") + else: + logger.error("❌ Model initialization failed") + + +@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" + ) + + # 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, + }, + } + + REQUEST_COUNT.labels(endpoint="/health", status="success").inc() + return jsonify(health_data), 200 + + except Exception: + logger.exception("❌ Health check failed") + REQUEST_COUNT.labels(endpoint="/health", status="error").inc() + return jsonify({"status": "unhealthy"}), 500 + + +@app.route("/predict", methods=["POST"]) +def predict(): + """Predict emotions from text.""" + start_time = time.time() + + 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 + + data = request.get_json() + text = data.get("text", "").strip() + + if not text: + 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 + + # Ensure model is loaded + initialize_model() + + # Make prediction using shared utilities + 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() + + return jsonify(result), 200 + + except Exception: + logger.exception("❌ Prediction endpoint error") + 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 + + +@app.route("/metrics", methods=["GET"]) +def metrics(): + """Prometheus metrics endpoint.""" + return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST} + + +@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__": + # 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) diff --git a/deployment/cloud-run/minimal_test.py b/deployment/cloud_run/minimal_test.py similarity index 69% rename from deployment/cloud-run/minimal_test.py rename to deployment/cloud_run/minimal_test.py index dffdddac6..f3af56b1d 100644 --- a/deployment/cloud-run/minimal_test.py +++ b/deployment/cloud_run/minimal_test.py @@ -1,21 +1,22 @@ #!/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' +import sys + +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, Namespace, fields + print("✅ Imports successful") except Exception as e: print(f"❌ Imports failed: {e}") - exit(1) + sys.exit(1) try: print("2. Creating Flask app...") @@ -23,50 +24,55 @@ print("✅ Flask app created") except Exception as e: print(f"❌ Flask app creation failed: {e}") - exit(1) + sys.exit(1) try: print("3. Creating API object...") api = Api( app, - version='1.0.0', - title='Test API', - description='Test API' + 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}") - exit(1) + sys.exit(1) 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: print(f"❌ Namespace creation failed: {e}") - exit(1) + sys.exit(1) 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}") - exit(1) + sys.exit(1) 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}") print(f"API type at this point: {type(api)}") print(f"API errorhandler type: {type(api.errorhandler)}") - exit(1) + sys.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 similarity index 62% rename from deployment/cloud-run/model_utils.py rename to deployment/cloud_run/model_utils.py index 0156e08d8..2f7337427 100644 --- a/deployment/cloud-run/model_utils.py +++ b/deployment/cloud_run/model_utils.py @@ -1,18 +1,17 @@ -""" -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. +This module provides common functionality for model loading, inference, and error +handling to eliminate code duplication between API servers. """ import logging import os import threading import time -from typing import Dict, List, Optional, Tuple, Any +from typing import Any, Dict, List, Optional, Tuple import torch -from transformers import pipeline, AutoTokenizer, AutoModelForSequenceClassification +from transformers import AutoModelForSequenceClassification, AutoTokenizer, pipeline from transformers.pipelines import TextClassificationPipeline # Import centralized constants with fallback for non-package environments @@ -20,8 +19,8 @@ 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,15 +33,23 @@ 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' + "anger", + "disgust", + "fear", + "joy", + "neutral", + "sadness", + "surprise", ] # Runtime emotion labels @@ -53,10 +60,12 @@ def _create_emotion_pipeline(tokenizer, model) -> TextClassificationPipeline: """Create an emotion text-classification pipeline from tokenizer and model. Args: + ---- tokenizer: The tokenizer instance model: The model instance Returns: + ------- A configured Hugging Face text-classification pipeline. """ return pipeline( @@ -64,19 +73,21 @@ 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. Args: + ---- texts: List of input texts to validate Returns: + ------- Tuple of (results_list, valid_texts, valid_indices) """ results = [None] * len(texts) @@ -84,23 +95,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) @@ -112,7 +117,8 @@ def _validate_and_prepare_texts( def ensure_model_loaded() -> bool: """Thread-safe emotion model loading with proper error handling. - Returns: + Returns + ------- bool: True if model is loaded successfully, False otherwise """ global emotion_pipeline, model_loaded, model_loading, emotion_labels_runtime @@ -137,13 +143,14 @@ 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) + logger.info("📁 Loading from local model directory: %s", EMOTION_MODEL_DIR) tokenizer = AutoTokenizer.from_pretrained( - EMOTION_MODEL_DIR, local_files_only=True + EMOTION_MODEL_DIR, + local_files_only=True, ) model = AutoModelForSequenceClassification.from_pretrained( - EMOTION_MODEL_DIR, local_files_only=True + EMOTION_MODEL_DIR, + local_files_only=True, ) emotion_pipeline = _create_emotion_pipeline(tokenizer, model) logger.info("✅ Emotion model loaded from local directory") @@ -155,27 +162,32 @@ 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 + EMOTION_MODEL_DIR, + local_files_only=True, ) model = AutoModelForSequenceClassification.from_pretrained( - EMOTION_MODEL_DIR, local_files_only=True + EMOTION_MODEL_DIR, + local_files_only=True, ) emotion_pipeline = _create_emotion_pipeline(tokenizer, model) logger.info("✅ Emotion model loaded from downloaded files") @@ -183,11 +195,12 @@ 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) + logger.debug( + "Unable to derive runtime labels from model config: %s", + label_err, + ) with model_lock: model_loaded = True model_loading = False @@ -205,100 +218,125 @@ 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 Returns: + ------- Dict[str, Any]: Prediction results with emotions and confidence scores """ # 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 + "error": "Emotion prediction failed", + "emotions": [], + "confidence": 0.0, } def get_model_status() -> Dict[str, Any]: """Get current emotion model status. - Returns: + Returns + ------- 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(), } +def get_emotion_labels() -> List[str]: + """Get the current emotion labels from the loaded model. + + Returns + ------- + List[str]: List of emotion labels in the correct order + """ + global emotion_labels_runtime + + if not model_loaded: + logger.warning("Model not loaded, returning fallback labels") + return EMOTION_LABELS.copy() + + return emotion_labels_runtime.copy() + + def predict_emotions_batch(texts: List[str]) -> List[Dict[str, Any]]: """Predict emotions for multiple texts using the emotion model. Args: + ---- texts (List[str]): List of input texts to analyze Returns: + ------- 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,48 +351,52 @@ 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 Returns: + ------- 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 similarity index 51% rename from deployment/cloud-run/onnx_api_server.py rename to deployment/cloud_run/onnx_api_server.py index 7354c35fc..b6dc06690 100644 --- a/deployment/cloud-run/onnx_api_server.py +++ b/deployment/cloud_run/onnx_api_server.py @@ -1,20 +1,20 @@ #!/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 import threading +import time +from typing import Dict, List, Tuple import numpy as np import onnxruntime as ort -from flask import Flask, request, jsonify import psutil -from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST +from flask import Flask, jsonify, request +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest # Configure logging logging.basicConfig(level=logging.INFO) @@ -30,43 +30,156 @@ 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 +188,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 +208,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 +232,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] @@ -138,7 +251,9 @@ def load_onnx_model() -> ort.InferenceSession: # Optimized session options session_options = ort.SessionOptions() - session_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + session_options.graph_optimization_level = ( + ort.GraphOptimizationLevel.ORT_ENABLE_ALL + ) session_options.intra_op_num_threads = 1 session_options.inter_op_num_threads = 1 @@ -171,13 +286,15 @@ 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 +307,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 +322,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 +357,7 @@ def initialize_model(): initialize_model() -@app.route('/health', methods=['GET']) +@app.route("/health", methods=["GET"]) def health_check(): """Health check endpoint.""" try: @@ -252,80 +369,86 @@ 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 + except Exception: + logger.exception("❌ Health check failed") + REQUEST_COUNT.labels(endpoint="/health", status="error").inc() + return jsonify({"error": "Internal server error"}), 500 -@app.route('/predict', methods=['POST']) +@app.route("/predict", methods=["POST"]) def predict(): """Predict emotions from text.""" start_time = time.time() try: # Get request data - data = request.get_json() - if not data or 'text' not in data: - return jsonify({'error': 'Missing text field'}), 400 + data = request.get_json(silent=True) + if not isinstance(data, dict) or "text" not in data: + return jsonify({"error": "Missing or invalid JSON with 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 + + # Check if model and vocab are ready + if model_session is None or vocab is None: + return jsonify({"error": "Model not ready"}), 503 # 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}") + except Exception: + logger.exception("❌ Prediction failed") 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": "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.""" - 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__': + 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__": # Production WSGI server try: import gunicorn.app.base @@ -333,7 +456,8 @@ def root(): class StandaloneApplication(gunicorn.app.base.BaseApplication): def init(self, parser, opts, args): """Initialize the application (abstract method override).""" - raise NotImplementedError() + # No-op implementation to avoid NotImplementedError during Gunicorn startup + def __init__(self, flask_app, gunicorn_options=None): self.options = gunicorn_options or {} self.application = flask_app @@ -347,19 +471,25 @@ def load(self): return self.application # Production configuration + host = "0.0.0.0" + port = os.environ.get("PORT", "8080") + bind_address = f"{host}:{port}" + 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": bind_address, + "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) + host = "0.0.0.0" + port = int(os.environ.get("PORT", "8080")) + app.run(host=host, port=port, debug=False) diff --git a/deployment/cloud-run/openapi.yaml b/deployment/cloud_run/openapi.yaml similarity index 99% rename from deployment/cloud-run/openapi.yaml rename to deployment/cloud_run/openapi.yaml index 6106dfb85..f976622b8 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 @@ -370,4 +370,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 diff --git a/deployment/cloud-run/rate_limiter.py b/deployment/cloud_run/rate_limiter.py similarity index 64% rename from deployment/cloud-run/rate_limiter.py rename to deployment/cloud_run/rate_limiter.py index 96f040232..b6e33f5f2 100644 --- a/deployment/cloud-run/rate_limiter.py +++ b/deployment/cloud_run/rate_limiter.py @@ -1,26 +1,33 @@ #!/usr/bin/env python3 -"""Rate Limiter for Flask API""" +"""Rate Limiter for Flask API.""" -import time import threading +import time from collections import defaultdict, deque -from flask import request, jsonify from functools import wraps +from typing import Dict + +from flask import jsonify, request + class RateLimiter: def __init__(self, requests_per_minute: int = 100): self.requests_per_minute = requests_per_minute - self.requests = defaultdict(lambda: deque(maxlen=requests_per_minute)) + self.requests: Dict[str, deque] = defaultdict( + lambda: deque(maxlen=requests_per_minute), + ) self.lock = threading.Lock() def is_allowed(self, client_id: str) -> bool: - """Check if request is allowed""" + """Check if request is allowed.""" current_time = time.time() 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 @@ -32,17 +39,18 @@ def is_allowed(self, client_id: str) -> bool: @staticmethod def get_client_id(request) -> str: - """Get client identifier""" + """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""" + """Rate limiting decorator.""" limiter = RateLimiter(requests_per_minute) def decorator(f): @@ -51,11 +59,15 @@ 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/requirements.txt b/deployment/cloud_run/requirements.txt similarity index 100% rename from deployment/cloud-run/requirements.txt rename to deployment/cloud_run/requirements.txt index a98531c17..03ab487f3 100644 --- a/deployment/cloud-run/requirements.txt +++ b/deployment/cloud_run/requirements.txt @@ -1,8 +1,8 @@ flask>=3.1.1,<4.0.0 flask-restx>=1.3.0,<2.0.0 -torch>=2.7.1,<2.9.0 -transformers>=4.55.0,<5.0.0 gunicorn>=23.0.0,<24.0.0 numpy>=1.24.0,<2.0.0 -scikit-learn>=1.5.0,<2.0.0 requests==2.32.4 +scikit-learn>=1.5.0,<2.0.0 +torch>=2.7.1,<2.9.0 +transformers>=4.55.0,<5.0.0 diff --git a/deployment/cloud-run/robust_predict.py b/deployment/cloud_run/robust_predict.py similarity index 55% rename from deployment/cloud-run/robust_predict.py rename to deployment/cloud_run/robust_predict.py index 713de8542..9b4c6a5a4 100644 --- a/deployment/cloud-run/robust_predict.py +++ b/deployment/cloud_run/robust_predict.py @@ -1,24 +1,24 @@ #!/usr/bin/env python3 -""" -🚀 EMOTION DETECTION API FOR CLOUD RUN +"""🚀 EMOTION DETECTION API FOR CLOUD RUN ====================================== Robust Flask API optimized for Cloud Run deployment. """ +import logging import os +import threading import time -import logging import uuid -import threading -from flask import Flask, request, jsonify -import torch -from transformers import AutoTokenizer, AutoModelForSequenceClassification from pathlib import Path +import torch +from flask import Flask, jsonify, request +from transformers import AutoModelForSequenceClassification, AutoTokenizer + # Configure logging for Cloud Run logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) @@ -33,61 +33,76 @@ 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", +] # Constants MAX_INPUT_LENGTH = 512 + def load_model(): - """Load the emotion detection model""" - global model, tokenizer, emotion_mapping, model_loading, model_loaded, model_lock - + """Load the emotion detection model.""" 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 + + # Load tokenizer and model from the same local path 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 - + + with model_lock: + model_loaded = True + model_loading = False + logger.info(f"✅ Model loaded successfully on {device}") logger.info(f"🎯 Supported emotions: {emotion_mapping}") - + except Exception: - model_loading = False + with model_lock: + model_loading = False logger.exception("❌ Failed to load model") # Do not re-raise to maintain secure error handling finally: - model_loading = False + with model_lock: + model_loading = False + def predict_emotion(text): - """Predict emotion for given text""" - global model, tokenizer, emotion_mapping - + """Predict emotion for given text.""" if not model_loaded: raise RuntimeError("Model not loaded") @@ -98,156 +113,186 @@ 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 + "text": text, } + def ensure_model_loaded(): - """Ensure model is loaded before processing requests""" + """Ensure model is loaded before processing requests.""" if not model_loaded and not model_loading: load_model() - + 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""" + """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() - }) - -@app.route('/health', methods=['GET']) + """Root endpoint.""" + return jsonify( + { + "message": "Hello from SAMO Emotion Detection API!", + "status": "running", + "timestamp": time.time(), + }, + ) + + +@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']) + """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"]) def predict(): - """Predict emotion for given text""" + """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""" + """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) - }) + """Get list of supported emotions.""" + return jsonify( + { + "emotions": EMOTION_MAPPING, + "count": len(EMOTION_MAPPING), + }, + ) + -@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() - }) + """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(), + }, + ) + # Load model on startup def initialize_model(): - """Initialize model before first request""" + """Initialize model before first request.""" try: load_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 +305,48 @@ 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 {} 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 - + 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": 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", } - - 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 new file mode 100644 index 000000000..a9b133f6b --- /dev/null +++ b/deployment/cloud_run/secure_api_server.py @@ -0,0 +1,673 @@ +#!/usr/bin/env python3 +"""🚀 SECURE EMOTION DETECTION API FOR CLOUD RUN +============================================ +Production-ready Flask API with comprehensive security features and Swagger documentation. +""" + +import hmac +import logging +import os +import threading +import time +import uuid +from functools import wraps + +from flask import Flask, g, jsonify, request +from flask_restx import Api, Namespace, Resource, fields + +# Import shared model utilities +from model_utils import ( + ensure_model_loaded, + get_emotion_labels, + get_model_status, + predict_emotions, +) +from rate_limiter import rate_limit + +# Import security modules +from security_headers import add_security_headers + +# Configure logging for Cloud Run +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +# Add security headers +add_security_headers(app) + + +# Register root endpoint BEFORE Flask-RESTX initialization to avoid conflicts +@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(), + }, + ) + except Exception as e: + 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", + # Temporarily disable Swagger docs to avoid 500 errors + # doc='/docs', + 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", + path="/api", +) +admin_ns = Namespace( + "admin", + description="Admin operations", + path="/admin", + 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"), + }, +) + +# Security configuration from environment variables +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")) +RATE_LIMIT_PER_MINUTE = int(os.environ.get("RATE_LIMIT_PER_MINUTE", "100")) +MODEL_PATH = os.environ.get("MODEL_PATH", "/app/model") +PORT = int(os.environ.get("PORT", "8080")) + +# Global variables for model state (thread-safe with locks) +model = None +tokenizer = None +emotion_mapping = None +model_loading = False +model_loaded = False +model_lock = threading.Lock() + +# Emotion mapping - will be loaded from model at runtime +EMOTION_MAPPING = None # Will be initialized from model labels + + +def initialize_emotion_mapping(): + """Initialize emotion mapping from the loaded model.""" + global EMOTION_MAPPING + + if EMOTION_MAPPING is None: + try: + EMOTION_MAPPING = get_emotion_labels() + logger.info(f"✅ Initialized emotion mapping from model: {EMOTION_MAPPING}") + except Exception as e: + logger.warning(f"Failed to get emotion labels from model: {e}") + # Fallback to static mapping + EMOTION_MAPPING = [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", + ] + logger.info(f"Using fallback emotion mapping: {EMOTION_MAPPING}") + + +def setup_request_id(): + """Set up request ID from X-Request-ID header or generate new one.""" + if not hasattr(g, "request_id"): + g.request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + + +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") + 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 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 = [ + "<", + ">", + '"', + "'", + "&", + ";", + "|", + "`", + "$", + "(", + ")", + "{{", + "}}", + ] + for char in dangerous_chars: + text = text.replace(char, "") + + # Limit length + if len(text) > MAX_INPUT_LENGTH: + text = text[:MAX_INPUT_LENGTH] + + return text.strip() + + +def load_model(): + """Load the emotion detection model using shared utilities.""" + # Use the shared model loading function + success = ensure_model_loaded() + if not success: + logger.error("❌ Model loading failed") + raise RuntimeError("Model loading failed - check logs for details") + + # Initialize emotion mapping from the loaded model + initialize_emotion_mapping() + + +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"] = g.request_id + + 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": g.request_id, + "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) + + +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() + setup_request_id() + + # 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})", + ) + + # 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"] + } + logger.debug(f"📋 Request headers: {headers_to_log}") + + +@app.after_request +def after_request(response): + """Add request tracking headers.""" + 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 + + # 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 + + +@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) + 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(), + } + logger.warning("Health check failed - model not ready") + return create_error_response( + "Service unavailable - model not ready", + 503, + ) + + except Exception as e: + logger.error(f"Health check error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) + + +@main_ns.route("/predict") +class Predict(Resource): + @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) + @rate_limit(RATE_LIMIT_PER_MINUTE) + @require_api_key + def post(self): + """Predict emotion for a single text input.""" + 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: + logger.warning( + f"Missing text field in request from {request.remote_addr}", + ) + return create_error_response("Missing text field", 400) + + 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) + + # Sanitize input + try: + text = sanitize_input(text) + except ValueError as 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) + + # Predict emotion + logger.info(f"Processing prediction request for {request.remote_addr}") + result = predict_emotion(text) + return result + + except Exception as e: + logger.error(f"Prediction error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) + + +@main_ns.route("/predict_batch") +class PredictBatch(Resource): + @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) + @rate_limit(RATE_LIMIT_PER_MINUTE) + @require_api_key + def post(self): + """Predict emotions for multiple text inputs.""" + 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: + logger.warning( + f"Missing texts field in batch request from {request.remote_addr}", + ) + return create_error_response("Missing texts field", 400) + + 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) + + 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) + + # 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) + + # Process each text + 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}: {e!s}", + ) + continue + + return {"results": results} + + except Exception as e: + logger.error(f"Batch prediction error for {request.remote_addr}: {e!s}") + return create_error_response("Internal server error", 500) + + +@main_ns.route("/emotions") +class Emotions(Resource): + @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}") + + # Ensure emotion mapping is initialized + if EMOTION_MAPPING is None: + initialize_emotion_mapping() + + return { + "emotions": EMOTION_MAPPING, + "count": len(EMOTION_MAPPING), + "timestamp": time.time(), + } + except Exception as e: + 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") +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) + @require_api_key + def get(self): + """Get detailed model status (admin only)""" + try: + # Get model status from shared utilities + logger.info(f"Admin model status request from {request.remote_addr}") + status = get_model_status() + return status + except Exception as e: + 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") +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) + @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(), + } + except Exception as e: + 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) + + +def internal_error(error): + """Handle internal server errors.""" + 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) + + +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) + + +def handle_unexpected_error(error): + """Handle any unexpected errors.""" + 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 +api.error_handlers[500] = internal_error +api.error_handlers[404] = not_found +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("🔐 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: {e!s}") + raise + + +# Initialize model when the application starts +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) +else: + # For production deployment - don't initialize during import + # Model will be initialized when the app actually starts + logger.info( + "🚀 Production deployment detected - model will be initialized on first request", + ) + +# Root endpoint is now registered BEFORE Flask-RESTX initialization to avoid conflicts + +# Make Flask app available to Gunicorn diff --git a/deployment/cloud-run/security_headers.py b/deployment/cloud_run/security_headers.py similarity index 50% rename from deployment/cloud-run/security_headers.py rename to deployment/cloud_run/security_headers.py index 0aebcc545..006869e0f 100644 --- a/deployment/cloud-run/security_headers.py +++ b/deployment/cloud_run/security_headers.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""Security Headers Module for Cloud Run API""" +"""Security Headers Module for Cloud Run API.""" + +from flask import Flask, g, request -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""" + """Add comprehensive security headers to Flask app.""" @app.after_request def add_headers(response): @@ -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,27 @@ 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/templates/docs.html b/deployment/cloud_run/templates/docs.html similarity index 99% rename from deployment/cloud-run/templates/docs.html rename to deployment/cloud_run/templates/docs.html index e109f7970..2ddb41c21 100644 --- a/deployment/cloud-run/templates/docs.html +++ b/deployment/cloud_run/templates/docs.html @@ -22,4 +22,4 @@ }); - \ No newline at end of file + diff --git a/deployment/cloud_run/test_direct_errorhandler.py b/deployment/cloud_run/test_direct_errorhandler.py new file mode 100644 index 000000000..500f81fc0 --- /dev/null +++ b/deployment/cloud_run/test_direct_errorhandler.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Test direct error handler registration""" + +import os +import sys + +from werkzeug.exceptions import TooManyRequests, InternalServerError + + +def main(): + """Main test function with proper environment handling.""" + # Save original ADMIN_API_KEY value + original_admin_key = os.environ.get("ADMIN_API_KEY") + + try: + # Set test environment + 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}") + raise SystemExit(1) + + try: + app = Flask(__name__) + api = Api(app, version="1.0.0", title="Test") + print("✅ API object created") + except Exception as e: + print(f"❌ API creation failed: {e}") + raise SystemExit(1) + + # Let's try to register error handlers using decorators + try: + print("1. Testing decorator-based error handler registration...") + + @api.errorhandler(429) + def rate_limit_handler(error): + return {"error": "Rate limit exceeded"}, 429 + + @api.errorhandler(500) + def internal_error_handler(error): + return {"error": "Internal server error"}, 500 + + @api.errorhandler(TooManyRequests) + def werkzeug_rate_limit_handler(error): + return {"error": "Rate limit exceeded (Werkzeug)"}, 429 + + @api.errorhandler(InternalServerError) + def werkzeug_internal_error_handler(error): + return {"error": "Internal server error (Werkzeug)"}, 500 + + print("✅ Decorator registration successful") + print(f"Error handlers: {api.error_handlers}") + + except Exception as e: + print(f"❌ Decorator 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.") + + finally: + # Restore original ADMIN_API_KEY value + if original_admin_key is not None: + os.environ["ADMIN_API_KEY"] = original_admin_key + elif "ADMIN_API_KEY" in os.environ: + del os.environ["ADMIN_API_KEY"] + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/deployment/cloud-run/test_docs_error.py b/deployment/cloud_run/test_docs_error.py similarity index 75% rename from deployment/cloud-run/test_docs_error.py rename to deployment/cloud_run/test_docs_error.py index ab387bab1..6905fca02 100644 --- a/deployment/cloud-run/test_docs_error.py +++ b/deployment/cloud_run/test_docs_error.py @@ -1,41 +1,42 @@ #!/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" # nosec B105 - test API key +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) - + app.run(host="0.0.0.0", 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 +44,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 similarity index 82% rename from deployment/cloud-run/test_minimal_import.py rename to deployment/cloud_run/test_minimal_import.py index 1bd62f110..d76c39d32 100644 --- a/deployment/cloud-run/test_minimal_import.py +++ b/deployment/cloud_run/test_minimal_import.py @@ -1,10 +1,10 @@ #!/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' +import sys + +os.environ["ADMIN_API_KEY"] = "test123" print("🔍 Starting minimal import test...") @@ -12,10 +12,11 @@ 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}") - exit(1) + sys.exit(1) try: print("2. Creating Flask app...") @@ -23,15 +24,15 @@ print("✅ Flask app created") except Exception as e: print(f"❌ Flask app creation failed: {e}") - exit(1) + sys.exit(1) 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}") - exit(1) + sys.exit(1) try: print("4. Testing API methods...") @@ -41,7 +42,7 @@ print("✅ API methods check successful") except Exception as e: print(f"❌ API methods check failed: {e}") - exit(1) + sys.exit(1) try: print("5. Testing errorhandler call...") @@ -50,6 +51,6 @@ except Exception as e: print(f"❌ errorhandler(429) call failed: {e}") print(f"Error type: {type(e)}") - exit(1) + sys.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 new file mode 100644 index 000000000..0cc72f70e --- /dev/null +++ b/deployment/cloud_run/test_minimal_swagger.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Minimal test to isolate Swagger docs issue""" + +import os + +from flask import Flask, jsonify +from flask_restx import Api, Namespace, Resource + +# Create Flask app +app = Flask(__name__) + + +# Register root endpoint first +@app.route("/") +def root(): + 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", +) + +# Create namespace +main_ns = Namespace("api", description="Main operations") +api.add_namespace(main_ns) + + +# Test endpoint +@main_ns.route("/health") +class Health(Resource): + @staticmethod + def get(): + return {"status": "healthy"} + + +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)") + + # Default to loopback interface for security, allow opt-in to bind all interfaces + bind_all = os.environ.get("BIND_ALL", "").lower() in ("1", "true") + host = "0.0.0.0" if bind_all else "127.0.0.1" + + app.run( + host=host, port=int(os.environ.get("PORT", 5003)), debug=False + ) # Debug mode disabled for security diff --git a/deployment/cloud-run/test_routing_debug.py b/deployment/cloud_run/test_routing_debug.py similarity index 73% rename from deployment/cloud-run/test_routing_debug.py rename to deployment/cloud_run/test_routing_debug.py index a7a53a252..c400f45c7 100644 --- a/deployment/cloud-run/test_routing_debug.py +++ b/deployment/cloud_run/test_routing_debug.py @@ -1,10 +1,8 @@ #!/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 +from flask_restx import Api, Namespace, Resource # Create Flask app app = Flask(__name__) @@ -15,35 +13,40 @@ # 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') -api.add_namespace(main_ns) +main_ns = Namespace("api", description="Main operations") +api.add_namespace(main_ns, path="/api") 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'} + @staticmethod + def get(): + 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 +54,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}") @@ -62,7 +67,7 @@ def root(): print("App routes:", [rule.rule for rule in app.url_map.iter_rules()]) # Check for endpoint name conflicts -endpoints = {} +endpoints: dict[str, str] = {} for rule in app.url_map.iter_rules(): if rule.endpoint in endpoints: print(f"⚠️ CONFLICT: Endpoint '{rule.endpoint}' appears multiple times:") @@ -78,7 +83,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 similarity index 74% rename from deployment/cloud-run/test_routing_fixed.py rename to deployment/cloud_run/test_routing_fixed.py index dc3e579f5..58ab745a6 100644 --- a/deployment/cloud-run/test_routing_fixed.py +++ b/deployment/cloud_run/test_routing_fixed.py @@ -1,57 +1,59 @@ #!/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" # nosec B105 - test API key +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 new file mode 100644 index 000000000..f8d74ebec --- /dev/null +++ b/deployment/cloud_run/test_routing_minimal.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Minimal test script to isolate Flask-RESTX routing issues""" + +import os + +from flask import Flask, jsonify +from flask_restx import Api, Namespace, Resource + +# Create Flask app +app = Flask(__name__) + +# Initialize Flask-RESTX API +api = Api( + app, + 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("Main operations", description="Main operations", path="/api") +api.add_namespace(main_ns) + + +# Test endpoint in namespace +@main_ns.route("/health") +class Health(Resource): + @staticmethod + def get(): + return {"status": "healthy"} + + +# Test direct Flask route BEFORE API setup +@app.route("/test_before") +def test_before(): + return jsonify({"message": "This route was added before API setup"}) + + +# Test direct Flask route AFTER API setup +@app.route("/test_after") +def test_after(): + return jsonify({"message": "This route was added after API setup"}) + + +# Test root endpoint - this should work now +@app.route("/") +def root(): + return jsonify({"message": "Root endpoint"}) + + +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 diff --git a/deployment/cloud-run/test_server_start.py b/deployment/cloud_run/test_server_start.py similarity index 71% rename from deployment/cloud-run/test_server_start.py rename to deployment/cloud_run/test_server_start.py index 19eb6edd1..942f40078 100644 --- a/deployment/cloud-run/test_server_start.py +++ b/deployment/cloud_run/test_server_start.py @@ -1,65 +1,68 @@ #!/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" # nosec B105 - test API key +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) - + app.run(host="0.0.0.0", 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)}") + 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 similarity index 51% rename from deployment/cloud-run/test_swagger_debug.py rename to deployment/cloud_run/test_swagger_debug.py index fdb5b3f40..c4a9f3f99 100644 --- a/deployment/cloud-run/test_swagger_debug.py +++ b/deployment/cloud_run/test_swagger_debug.py @@ -1,11 +1,10 @@ #!/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 -from flask_restx import Api, Resource, Namespace +from flask_restx import Api, Namespace, Resource # Create Flask app app = Flask(__name__) @@ -13,36 +12,43 @@ # 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') -api.add_namespace(main_ns) +main_ns = Namespace("api", description="Main operations") +api.add_namespace(main_ns, path="/api") + # Test endpoint in namespace -@main_ns.route('/health') +@main_ns.route("/health") class Health(Resource): - def get(self): - return {'status': 'healthy'} + @staticmethod + def get(): + 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 + + host = os.environ.get("HOST", "127.0.0.1") + app.run( + host=host, port=int(os.environ.get("PORT", 5001)), debug=False + ) # Debug mode disabled for security diff --git a/deployment/cloud-run/test_swagger_debug_detailed.py b/deployment/cloud_run/test_swagger_debug_detailed.py similarity index 82% rename from deployment/cloud-run/test_swagger_debug_detailed.py rename to deployment/cloud_run/test_swagger_debug_detailed.py index 0cb467f87..69b855683 100644 --- a/deployment/cloud-run/test_swagger_debug_detailed.py +++ b/deployment/cloud_run/test_swagger_debug_detailed.py @@ -1,84 +1,83 @@ #!/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 +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'] = '8084' +os.environ["ADMIN_API_KEY"] = "test-key-123" # nosec B105 - test API key +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) + app.run(host="0.0.0.0", 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 new file mode 100644 index 000000000..a86a2d565 --- /dev/null +++ b/deployment/cloud_run/test_swagger_no_model.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Test Swagger docs without model dependencies""" + +import os + +from flask import Flask, jsonify +from flask_restx import Api, Namespace, Resource + +# Environment variables will be set in main block to avoid import-time side effects + +# Create Flask app +app = Flask(__name__) + + +# Register root endpoint first +@app.route("/") +def home(): + 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", +) + +# Create namespace +main_ns = Namespace("api", description="Main operations") +api.add_namespace(main_ns) + + +# Test endpoint +@main_ns.route("/health") +class Health(Resource): + @staticmethod + def get(): + return {"status": "healthy"} + + +if __name__ == "__main__": + # Set required environment variables with defaults (only when running as main) + os.environ.setdefault("ADMIN_API_KEY", "test-key-123") + os.environ.setdefault("MAX_INPUT_LENGTH", "512") + os.environ.setdefault("RATE_LIMIT_PER_MINUTE", "100") + os.environ.setdefault("MODEL_PATH", "/app/model") + os.environ.setdefault("PORT", "8083") + + 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 diff --git a/deployment/docker/Dockerfile.emotion_arch_fixed b/deployment/docker/Dockerfile.emotion_arch_fixed index 949bab9cc..0014992e9 100644 --- a/deployment/docker/Dockerfile.emotion_arch_fixed +++ b/deployment/docker/Dockerfile.emotion_arch_fixed @@ -56,4 +56,4 @@ CMD exec gunicorn \ --access-logfile - \ --error-logfile - \ --log-level info \ - robust_predict:app \ No newline at end of file + robust_predict:app diff --git a/deployment/docker/Dockerfile.optimized b/deployment/docker/Dockerfile.optimized index efa505091..7282293be 100644 --- a/deployment/docker/Dockerfile.optimized +++ b/deployment/docker/Dockerfile.optimized @@ -60,4 +60,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ # 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 +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"] diff --git a/deployment/docker/Dockerfile.production b/deployment/docker/Dockerfile.production index a8a7bea8c..283d8bd85 100644 --- a/deployment/docker/Dockerfile.production +++ b/deployment/docker/Dockerfile.production @@ -54,4 +54,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 # Start the application with Gunicorn -CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "secure_api_server:app"] \ No newline at end of file +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "secure_api_server:app"] diff --git a/deployment/docker/Dockerfile.train b/deployment/docker/Dockerfile.train index 6ce096c02..ac751bc81 100644 --- a/deployment/docker/Dockerfile.train +++ b/deployment/docker/Dockerfile.train @@ -70,4 +70,4 @@ COPY scripts/training/full_focal_training.py /app/train.py RUN chmod +x /app/train.py # Set default command -CMD ["python", "/app/train.py"] \ No newline at end of file +CMD ["python", "/app/train.py"] diff --git a/deployment/docker/README.md b/deployment/docker/README.md index 7223fb29f..2a2eee7ec 100644 --- a/deployment/docker/README.md +++ b/deployment/docker/README.md @@ -31,4 +31,4 @@ docker build \ --build-arg PIP_CONSTRAINT=dependencies/constraints.txt \ -f deployment/docker/Dockerfile.train \ -t samo-dl-train . -``` \ No newline at end of file +``` diff --git a/deployment/docker/requirements-api-optimized.txt b/deployment/docker/requirements-api-optimized.txt index de64baf3e..203b797e0 100644 --- a/deployment/docker/requirements-api-optimized.txt +++ b/deployment/docker/requirements-api-optimized.txt @@ -5,26 +5,30 @@ # Use PyTorch CPU-only index for torch packages --extra-index-url https://download.pytorch.org/whl/cpu +certifi==2024.12.14 +click==8.1.8 # Core API dependencies Flask>=3.1.1,<4.0.0 flask-restx==1.3.0 -PyJWT==2.8.0 - -# Utilities -python-dotenv==1.0.1 -pyyaml==6.0.2 -requests==2.32.4 -certifi==2024.12.14 -click==8.1.8 -loguru==0.7.2 # Production Dependencies gunicorn>=23.0.0,<24.0.0 -prometheus-client==0.20.0 # HF model utilities huggingface_hub>=0.34.0,<1.0 +loguru==0.7.2 + +# OPTIMIZED: Only essential scientific computing +numpy>=1.24.0,<2.0.0 +prometheus-client==0.20.0 +PyJWT==2.8.0 + +# Utilities +python-dotenv==1.0.1 +pyyaml==6.0.2 +requests==2.32.4 +scipy==1.13.1 # OPTIMIZED: CPU-only PyTorch for inference torch==2.8.0+cpu @@ -32,9 +36,5 @@ torch==2.8.0+cpu # OPTIMIZED: Minimal transformers for inference only transformers==4.55.0 -# OPTIMIZED: Only essential scientific computing -numpy>=1.24.0,<2.0.0 -scipy==1.13.1 - # OPTIMIZED: Remove unnecessary ML training dependencies -# (No datasets, accelerate, onnx, etc. - only inference needed) \ No newline at end of file +# (No datasets, accelerate, onnx, etc. - only inference needed) diff --git a/deployment/gcp/predict.py b/deployment/gcp/predict.py index 73fc60bff..6dff9bc8b 100644 --- a/deployment/gcp/predict.py +++ b/deployment/gcp/predict.py @@ -1,61 +1,100 @@ #!/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. """ import os + import torch -from transformers import AutoTokenizer, AutoModelForSequenceClassification -from flask import Flask, request, jsonify +from flask import Flask, jsonify, request +from transformers import AutoModelForSequenceClassification, AutoTokenizer 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) - + 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") 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'] + + # Get emotion labels from model config + try: + if ( + hasattr(self.model.config, "id2label") + and self.model.config.id2label + ): + self.emotions = [ + self.model.config.id2label[i] + for i in range(len(self.model.config.id2label)) + ] + print(f"✅ Loaded emotion labels from model: {self.emotions}") + else: + raise AttributeError("Model config missing id2label") + except Exception as label_error: + print(f"⚠️ Failed to load labels from model config: {label_error}") + # Fallback to static mapping + self.emotions = [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", + ] + print(f"✅ Using fallback emotion labels: {self.emotions}") 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 predict(self, text): """Make a prediction.""" 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] @@ -63,87 +102,97 @@ def predict(self, text): predicted_emotion = self.model.config.id2label[str(predicted_label)] else: predicted_emotion = f"unknown_{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": { + 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%' - } } - + 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 + print(f"Prediction endpoint error: {e!s}") + return jsonify({"error": "Internal server error"}), 500 -@app.route('/', methods=['GET']) + +@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"})' + 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%", + }, + }, }, - '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") @@ -152,6 +201,6 @@ def home(): print("") print("🚀 Server starting on http://0.0.0.0:8080") print("") - + # Run the Flask app - app.run(host='0.0.0.0', port=8080, debug=False) + app.run(host="0.0.0.0", port=8080, debug=False) diff --git a/deployment/inference.py b/deployment/inference.py index 430f45042..a909d37fb 100644 --- a/deployment/inference.py +++ b/deployment/inference.py @@ -1,88 +1,110 @@ #!/usr/bin/env python3 -""" -EMOTION DETECTION INFERENCE SCRIPT +"""EMOTION DETECTION INFERENCE SCRIPT ===================================== Standalone script to run emotion detection on text. """ -import torch -from transformers import AutoTokenizer, AutoModelForSequenceClassification from pathlib import Path +import torch +from transformers import AutoModelForSequenceClassification, AutoTokenizer + + class EmotionDetector: def __init__(self, model_path=None): - """Initialize the emotion detector""" + """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""" + """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 + "text": text, } - + def predict_batch(self, texts): - """Predict emotions for multiple texts""" + """Predict emotions for multiple texts.""" results = [] for text in texts: result = self.predict(text) results.append(result) return results + def main(): - """Main function for command line usage""" + """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/__init__.py b/deployment/local/__init__.py new file mode 100644 index 000000000..511c4f7bd --- /dev/null +++ b/deployment/local/__init__.py @@ -0,0 +1,5 @@ +"""Local deployment package. + +Contains the comprehensive API server with monitoring, rate limiting, and production- +ready features for local development and testing. +""" diff --git a/deployment/local/api_server.py b/deployment/local/api_server.py index 56224e566..e8c63c6ca 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, @@ -8,18 +7,18 @@ """ # Import all modules first -import os import logging -import time +import os import threading +import time from collections import defaultdict, deque from datetime import datetime from functools import wraps import torch import werkzeug -from flask import Flask, request, jsonify -from transformers import AutoTokenizer, AutoModelForSequenceClassification +from flask import Flask, jsonify, request +from transformers import AutoModelForSequenceClassification, AutoTokenizer # Import security setup using relative import from ...src.security_setup import setup_security_middleware @@ -27,11 +26,11 @@ # Configure logging after all imports logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[ - logging.FileHandler('api_server.log'), - logging.StreamHandler() - ] + logging.FileHandler("api_server.log"), + logging.StreamHandler(), + ], ) logger = logging.getLogger(__name__) @@ -48,108 +47,141 @@ # 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 {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) - + 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'] + + self.emotions = [ + "anxious", + "calm", + "content", + "excited", + "frustrated", + "grateful", + "happy", + "hopeful", + "overwhelmed", + "proud", + "sad", + "tired", + ] 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 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] @@ -157,240 +189,277 @@ def predict(self, text): predicted_emotion = self.model.config.id2label[str(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})") - + logger.info( + f"Prediction completed in {prediction_time:.3f}s: '{text[:50]}...' → {predicted_emotion} (conf: {confidence:.3f})", + ) + # 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": { + 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") # Logs stack trace + return jsonify({"error": "Internal server error"}), 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'] + update_metrics(response_time, success=False, error_type="missing_text") + return jsonify({"error": "No text provided"}), 400 + + text = data["text"] 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") # Logs stack trace + return jsonify({"error": "An internal error has occurred."}), 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) 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 + 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 as e: 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.error(f"Batch prediction endpoint error: {e!s}") + return jsonify({"error": "An internal error has occurred."}), 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) + 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, + }, }, - '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: 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}") + return jsonify( + {"error": "An internal server error occurred. Please try again later."} + ), 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 +472,12 @@ 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) + + app.run(host="0.0.0.0", port=8000, debug=False) diff --git a/deployment/local/requirements.txt b/deployment/local/requirements.txt index bade617cb..b1748b2c8 100644 --- a/deployment/local/requirements.txt +++ b/deployment/local/requirements.txt @@ -1,6 +1,6 @@ flask>=2.0.0 -torch>=2.0.0 -transformers>=4.55.0 +httpx>=0.25.0,<0.29.0 numpy>=1.21.0 requests==2.32.4 -httpx>=0.25.0,<0.29.0 +torch>=2.0.0 +transformers>=4.55.0 diff --git a/deployment/local/test_api.py b/deployment/local/test_api.py index fb3c415c6..fcef5d780 100644 --- a/deployment/local/test_api.py +++ b/deployment/local/test_api.py @@ -1,19 +1,21 @@ #!/usr/bin/env python3 -""" -Enhanced API Testing Script +"""Enhanced API Testing Script =========================== Comprehensive testing for the enhanced emotion detection API with monitoring, logging, and rate limiting features. """ -import requests +import os +import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed -import sys + +import requests # Configuration BASE_URL = "http://localhost:8000" +DEFAULT_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "5.0")) TEST_TEXTS = [ "I am feeling happy today!", "I feel sad about the news", @@ -26,96 +28,129 @@ "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...") try: - response = requests.get(f"{BASE_URL}/health") + response = requests.get(f"{BASE_URL}/health", timeout=DEFAULT_TIMEOUT) if response.status_code == 200: data = response.json() - print(f"✅ 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" Avg Response Time: {data['metrics']['average_response_time_ms']}ms") + print("✅ Health check passed") + status = data.get("status", data.get("state", "unknown")) + print(f" Status: {status}") + if "timestamp" in data: + print(f" Timestamp: {data['timestamp']}") + if "model_version" in data: + print(f" Model Version: {data['model_version']}") + if "uptime_seconds" in data: + print(f" Uptime: {data['uptime_seconds']:.1f} seconds") + metrics = data.get("metrics") + if isinstance(metrics, dict): + total = metrics.get("total_requests") + success = metrics.get("successful_requests") + if total is not None and success is not None: + print(f" Success Rate: {success}/{total}") + avg_ms = metrics.get("average_response_time_ms") + if avg_ms is not None: + print(f" Avg Response Time: {avg_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 failed: {response.status_code}") + return False + except requests.exceptions.RequestException as e: + print(f"❌ Health check HTTP error: {e!s}") + return False + except ValueError as e: + print(f"❌ Health check JSON parse error: {e!s}") return False + def test_metrics_endpoint(): """Test the new metrics endpoint.""" print("\n2. Testing metrics endpoint...") try: - response = requests.get(f"{BASE_URL}/metrics") + response = requests.get(f"{BASE_URL}/metrics", timeout=DEFAULT_TIMEOUT) if response.status_code == 200: - data = response.json() - print(f"✅ 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") + ctype = response.headers.get("Content-Type", "") + if ctype.startswith("text/"): + print("✅ Metrics endpoint served Prometheus exposition format") + lines = response.text.splitlines() + if lines: + print(f" Sample: {lines[0][:120]}") + else: + # Fallback: JSON metrics (if implemented) + data = response.json() + print("✅ Metrics endpoint working (JSON)") + print(f" Keys: {list(data)[:5]}") 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 failed: {response.status_code}") + return False + except requests.exceptions.RequestException as e: + print(f"❌ Metrics endpoint HTTP error: {e!s}") + return False + except ValueError as e: + print(f"❌ Metrics endpoint JSON parse 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"}, + timeout=DEFAULT_TIMEOUT, ) 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) + emotion = data["predicted_emotion"] + confidence = data["confidence"] + 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 - }) + + 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, + }, + ) else: print(f"❌ Test {i} failed: {response.status_code}") return False - - except Exception as e: - print(f"❌ Test {i} error: {str(e)}") + + except requests.exceptions.RequestException as e: + print(f"❌ Test {i} HTTP error: {e!s}") + return False + except ValueError as e: + print(f"❌ Test {i} JSON parse 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,190 +159,228 @@ 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"}, + timeout=DEFAULT_TIMEOUT, ) 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.get("predictions") or data.get("results") + batch_time = data.get("batch_processing_time_ms", 0) total_time = (end_time - start_time) * 1000 - + + if not isinstance(predictions, list): + print("❌ Unexpected batch response format") + return False + 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'] + 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})") - + 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 failed: {response.status_code}") return False + except requests.exceptions.RequestException as e: + print(f"❌ Batch prediction HTTP error: {e!s}") + return False + except ValueError as e: + print(f"❌ Batch prediction JSON parse 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"}, + timeout=DEFAULT_TIMEOUT, ) 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}") - + 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)") - return True + 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"} + headers={"Content-Type": "application/json"}, + timeout=DEFAULT_TIMEOUT, ) if response.status_code == 400: print("✅ Missing text error handled correctly") else: print(f"❌ Missing text error not handled: {response.status_code}") return False - except Exception as e: - print(f"❌ Missing text test error: {str(e)}") + except requests.exceptions.RequestException as e: + print(f"❌ Missing text test HTTP error: {e!s}") + return False + except ValueError as e: + print(f"❌ Missing text test JSON parse 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"}, + timeout=DEFAULT_TIMEOUT, ) if response.status_code == 400: print("✅ Empty text error handled correctly") else: print(f"❌ Empty text error not handled: {response.status_code}") return False - except Exception as e: - print(f"❌ Empty text test error: {str(e)}") + except requests.exceptions.RequestException as e: + print(f"❌ Empty text test HTTP error: {e!s}") + return False + except ValueError as e: + print(f"❌ Empty text test JSON parse 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"}, + timeout=DEFAULT_TIMEOUT, ) if response.status_code == 400: print("✅ Invalid JSON error handled correctly") else: print(f"❌ Invalid JSON error not handled: {response.status_code}") return False - except Exception as e: - print(f"❌ Invalid JSON test error: {str(e)}") + except requests.exceptions.RequestException as e: + print(f"❌ Invalid JSON test HTTP error: {e!s}") return False - + except ValueError as e: + print(f"❌ Invalid JSON test JSON parse 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"}, + timeout=DEFAULT_TIMEOUT, ) 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)} - + except requests.exceptions.RequestException as e: + return {"status_code": 0, "response_time": 0, "error": f"HTTP error: {e!s}"} + except ValueError as e: + return { + "status_code": 0, + "response_time": 0, + "error": f"JSON parse error: {e!s}", + } + # 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") - + 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 - else: - print(" ⚠️ Performance may need optimization") - return True - else: - print(" ❌ No successful requests in performance test") - return False + print(" ⚠️ Performance may need optimization") + return True + 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,25 +388,29 @@ 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(): passed += 1 else: print(f"❌ {test_name} failed") + except requests.exceptions.RequestException as e: + print(f"❌ {test_name} HTTP error: {e!s}") + except ValueError as e: + print(f"❌ {test_name} JSON parse error: {e!s}") except Exception as e: - print(f"❌ {test_name} error: {str(e)}") - + print(f"❌ {test_name} unexpected 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:") @@ -344,9 +421,9 @@ def main(): print(" ✅ Performance monitoring") print(" ✅ Batch processing") return 0 - else: - print(f"❌ {total - passed} tests failed. Please check the implementation.") - return 1 + print(f"❌ {total - passed} tests failed. Please check the implementation.") + return 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/deployment/models/README.md b/deployment/models/README.md index e1dfce425..007fc3a83 100644 --- a/deployment/models/README.md +++ b/deployment/models/README.md @@ -51,4 +51,4 @@ The script will automatically find your model here and upload it to HuggingFace --- -**Need help?** Check the complete guide: [`deployment/CUSTOM_MODEL_DEPLOYMENT_GUIDE.md`](../CUSTOM_MODEL_DEPLOYMENT_GUIDE.md) \ No newline at end of file +**Need help?** Check the complete guide: [`deployment/CUSTOM_MODEL_DEPLOYMENT_GUIDE.md`](../CUSTOM_MODEL_DEPLOYMENT_GUIDE.md) diff --git a/deployment/secure_api_server.py b/deployment/secure_api_server.py index 8c78347ad..31116b444 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. @@ -15,41 +14,42 @@ """ # Import all modules first -import os -from flask import Flask, request, jsonify, g -import werkzeug import logging -from pathlib import Path +import os +import threading import time -from datetime import datetime from collections import defaultdict, deque -import threading -from functools import wraps, lru_cache -from typing import List, Tuple, Any, Dict +from datetime import datetime +from functools import lru_cache, wraps +from pathlib import Path +from typing import Any, Dict, List, Tuple + +import werkzeug +from flask import Flask, g, jsonify, request # Import security components using relative imports -from ..src.api_rate_limiter import TokenBucketRateLimiter, RateLimitConfig -from ..src.input_sanitizer import InputSanitizer, SanitizationConfig -from ..src.security_setup import setup_security_middleware, get_environment +from ..src.api_rate_limiter import RateLimitConfig, TokenBucketRateLimiter from ..src.inference.text_emotion_service import HFEmotionService # type: ignore +from ..src.input_sanitizer import InputSanitizer, SanitizationConfig +from ..src.security_setup import get_environment, setup_security_middleware # Import centralized constants with fallback for non-package environments try: 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', + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[ - logging.FileHandler('secure_api_server.log'), - logging.StreamHandler() - ] + logging.FileHandler("secure_api_server.log"), + logging.StreamHandler(), + ], ) logger = logging.getLogger(__name__) @@ -66,7 +66,7 @@ enable_ip_whitelist=False, whitelisted_ips=set(), enable_ip_blacklist=True, - blacklisted_ips=set() + blacklisted_ips=set(), ) sanitization_config = SanitizationConfig( @@ -77,7 +77,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 +87,128 @@ # 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) + 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') - 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 - + update_metrics( + response_time, + success=False, + error_type="invalid_content_type", + ) + logger.warning( + f"Invalid content type: {content_type} from {client_ip}", + ) + # Release acquired slot before returning + rate_limiter.release_request(client_ip, user_agent) + 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: + + except Exception: # 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") + logger.exception("Endpoint error occurred") + # Return a generic error message to the user, do not expose exception details + return jsonify({"error": "Internal server error"}), 500 + return decorated_function @@ -193,21 +226,37 @@ 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' + 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 + 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 # In CI/TESTING, or when model directory is missing/invalid, run in stub mode if os.environ.get("TESTING") or os.environ.get("CI"): - logger.warning("TEST/CI environment detected. Running secure model in stub mode.") + logger.warning( + "TEST/CI environment detected. Running secure model in stub mode.", + ) self.tokenizer = None self.model = None self.loaded = False @@ -216,7 +265,7 @@ def __init__(self): # If the local model directory is missing, skip heavy loading to keep imports working if not self.model_path.exists() or not self.model_path.is_dir(): logger.warning( - "Secure model directory not found. Running in stub mode (no HF model will be loaded)." + "Secure model directory not found. Running in stub mode (no HF model will be loaded).", ) self.tokenizer = None self.model = None @@ -225,13 +274,13 @@ 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." + "Secure model directory lacks expected files. Running in stub mode.", ) self.tokenizer = None self.model = None @@ -240,16 +289,25 @@ def __init__(self): try: # Lazy import heavy deps only when not in stub mode and path checks passed - from transformers import AutoTokenizer, AutoModelForSequenceClassification # type: ignore import torch # type: ignore + from transformers import ( # type: ignore + AutoModelForSequenceClassification, + AutoTokenizer, + ) - 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 +319,22 @@ 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 +345,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 +375,84 @@ 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})") - + logger.info( + "Secure prediction completed in %.3fs → %s (conf=%.3f)", + prediction_time, + predicted_emotion, + confidence, + ) + # Create secure response return { - 'text': sanitized_text, - 'predicted_emotion': predicted_emotion, - 'confidence': float(confidence), - 'probabilities': { - emotion: float(prob) for emotion, prob in zip(self.emotions, all_probs) + "text": sanitized_text, + "predicted_emotion": predicted_emotion, + "confidence": float(confidence), + "probabilities": { + # Align labels to logits index via id2label; fall back to label_i + ( + self.model.config.id2label.get(str(i)) + or self.model.config.id2label.get(i) + or f"label_{i}" + ): float(p) + for i, p in enumerate(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. + 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() @@ -366,8 +462,7 @@ def get_secure_model(): """Return a cached secure model instance created via the factory. Using an LRU cache (size=1) avoids global mutable state and ensures a single - instance per process. Tests can clear the cache with - get_secure_model.cache_clear(). + instance per process. Tests can clear the cache with get_secure_model.cache_clear(). """ return create_secure_model() @@ -397,7 +492,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,37 +511,43 @@ 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. - This function intentionally does not cache the key to support dynamic - updates (e.g., during tests or runtime reconfiguration). Be aware this - per-request read may introduce race conditions if the environment variable - changes mid-request; callers should treat the value as ephemeral per call. + This function intentionally does not cache the key to support dynamic updates (e.g., + during tests or runtime reconfiguration). Be aware this 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") + def require_admin_api_key(f): """Decorator to require admin API key via X-Admin-API-Key header. 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: - logger.warning(f"Unauthorized admin access attempt from {request.remote_addr}") + 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 +555,41 @@ 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): + 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' + 'Field "texts" must be a list of strings', + 400, + "validation_error", ) - original_texts = data['texts'] + 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 + 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 @@ -491,8 +597,8 @@ def _validate_alignment_count_or_raise( def _validate_single_results_or_raise(results: Any) -> List[Dict[str, Any]]: """Validate single-input provider results shape and return the distribution. - Expects results to be List[List[Dict[str, Any]]], with len(results) == 1. - Raises _ClientError(502) on invalid shape. + Expects results to be List[List[Dict[str, Any]]], with len(results) == 1. Raises + _ClientError(502) on invalid shape. """ if ( (not isinstance(results, list)) @@ -500,13 +606,11 @@ def _validate_single_results_or_raise(results: Any) -> List[Dict[str, Any]]: or (not isinstance(results[0], list)) ): outer_type = type(results).__name__ - outer_len = ( - len(results) if isinstance(results, list) 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' + else "N/A" ) logger.error( "Provider returned invalid shape for single input: " @@ -516,32 +620,25 @@ def _validate_single_results_or_raise(results: Any) -> List[Dict[str, Any]]: inner_type, ) raise _ClientError( - 'Provider returned mismatched result count', + "Provider returned mismatched result count", 502, - 'provider_misalignment' + "provider_misalignment", ) dist = results[0] if dist and not ( - isinstance(dist[0], dict) - and 'label' in dist[0] - and 'score' in dist[0] + 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' - ) + 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', + "Provider returned mismatched result count", 502, - 'provider_misalignment' + "provider_misalignment", ) return dist @@ -549,206 +646,224 @@ def _validate_single_results_or_raise(results: Any) -> List[Dict[str, Any]]: def _build_single_response( sanitized_text: str, dist: List[Dict[str, Any]], - warnings: List[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: + + 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": "Internal server error"}), 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": "Invalid request data"}), 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 + threshold_raw = sanitized_data.get("confidence_threshold") + threshold = ( + float(threshold_raw) if isinstance(threshold_raw, str) else threshold_raw + ) result = model_instance.predict( - sanitized_data['text'], - confidence_threshold=sanitized_data.get('confidence_threshold') + sanitized_data["text"], + confidence_threshold=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}") + return jsonify({"error": "An internal server error occurred."}), 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": "Batch request validation failed"}), 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 + threshold_raw = sanitized_data.get("confidence_threshold") + threshold = ( + float(threshold_raw) if isinstance(threshold_raw, str) else threshold_raw + ) + for text in sanitized_data["texts"]: if text.strip(): result = model_instance.predict( text, - confidence_threshold=sanitized_data.get('confidence_threshold') + confidence_threshold=threshold, ) results.append(result) - + response_time = time.time() - start_time update_metrics( - response_time, + response_time, success=True, - sanitization_warnings=len(warnings) + 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) - } - }) - + + 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' + 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 +879,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 +895,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.""" @@ -812,7 +927,8 @@ def nlp_emotion_batch(): _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 + "%s invalid texts filtered out from input batch.", + num_filtered, ) sanitized, total_warnings = _sanitize_texts_batch(texts) @@ -822,17 +938,21 @@ def nlp_emotion_batch(): except (ImportError, ValueError): response_time = time.time() - start_time update_metrics( - response_time, success=False, error_type='provider_error' + 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' + 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 +961,239 @@ 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 + 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) - } - }) + 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' + 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) + 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(), + }, }, - '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}") + return jsonify({"error": "Failed to update blacklist."}), 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}") + return jsonify( + {"error": "An internal error occurred. Please contact an administrator."} + ), 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' + "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)' + "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}") + return jsonify({"error": "Internal server error"}), 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 +1218,12 @@ 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( + 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 + + app.run(host="0.0.0.0", port=8000, debug=False) diff --git a/deployment/test_examples.py b/deployment/test_examples.py index fa1cb949f..24ffa2ddb 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""" + """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,42 @@ 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) - + 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( + f" Predicted: {result['emotion']} (confidence: {result['confidence']:.3f})", + ) + + # Show prediction details + print(f" Input text: {result.get('text', text[:50])}...") + print(f" Confidence: {result['confidence']:.3f}") 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..851e631e8 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 ``` @@ -93,7 +93,7 @@ ls -la local_deployment/model/ #### Step 3: Configuration -The API server uses default configuration. For customization, modify `local_deployment/api_server.py`: +The API server uses default configuration. For customization, modify `deployment/local/api_server.py`: ```python # Rate limiting (requests per minute) @@ -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) @@ -531,10 +531,10 @@ tar -xzf model_backup_20231201.tar.gz -C local_deployment/ ```bash # Backup configuration -cp local_deployment/api_server.py local_deployment/api_server.py.backup +cp deployment/local/api_server.py deployment/local/api_server.py.backup # Restore configuration -cp local_deployment/api_server.py.backup local_deployment/api_server.py +cp deployment/local/api_server.py.backup deployment/local/api_server.py ``` ## Scaling @@ -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..b4e81021f 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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6d9b3d70d..eb6e87061 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 diff --git a/docs/ci/CI_PIPELINE_GUIDE.md b/docs/ci/CI_PIPELINE_GUIDE.md index 29fe3e1a9..e13753b02 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 ✅ 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..bebae020e 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] ``` @@ -173,4 +173,4 @@ Documentation Updates Needed: - Avoid scope creep into other tracks (Web Dev, UX) - Maintain separation of concerns - Document all fixes for future reference -- Update this template with new patterns discovered \ No newline at end of file +- Update this template with new patterns discovered diff --git a/docs/ci/circleci-fix-summary.md b/docs/ci/circleci-fix-summary.md index 78c5a74d3..22460e695 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 diff --git a/docs/colab-gpu-development-guide.md b/docs/colab-gpu-development-guide.md index fe359be76..d22cf5251 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 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..fa445568a 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 diff --git a/docs/diagrams/Diagram01.svg b/docs/diagrams/Diagram01.svg index c0e0583e3..08c5c8db0 100644 --- a/docs/diagrams/Diagram01.svg +++ b/docs/diagrams/Diagram01.svg @@ -1 +1 @@ -

No

Yes

Model Fine-tuning

Collect text/audio

Clean & organize

Train model

Test on tricky cases

Good enough?

Make smaller & faster

Add trustworthy confidence

Package for the app

Share & monitor

\ No newline at end of file +

No

Yes

Model Fine-tuning

Collect text/audio

Clean & organize

Train model

Test on tricky cases

Good enough?

Make smaller & faster

Add trustworthy confidence

Package for the app

Share & monitor

diff --git a/docs/diagrams/Diagram02.svg b/docs/diagrams/Diagram02.svg index 34ac1a2cc..3ff1c7590 100644 --- a/docs/diagrams/Diagram02.svg +++ b/docs/diagrams/Diagram02.svg @@ -1 +1 @@ -

User input - Backend

API /predict

Model container GCP Cloud Run

Results JSON

Logs & metrics GCP

\ No newline at end of file +

User input - Backend

API /predict

Model container GCP Cloud Run

Results JSON

Logs & metrics GCP

diff --git a/docs/diagrams/Diagram03.svg b/docs/diagrams/Diagram03.svg index e520f5565..c4396bf6e 100644 --- a/docs/diagrams/Diagram03.svg +++ b/docs/diagrams/Diagram03.svg @@ -1 +1 @@ -AugAugSepSepExpand tricky examples (W6)Refresh clean test set (W7)Light model tuning (W6-7)Confidence calibration (W8)Faster inference trials (W8)Stress tests + guardrails (W9)Model card & demo set (W10)Data & EvaluationModelingSpeed & ReliabilityHandoffDeep Learning Roadmap (Project Weeks 0–10) \ No newline at end of file +AugAugSepSepExpand tricky examples (W6)Refresh clean test set (W7)Light model tuning (W6-7)Confidence calibration (W8)Faster inference trials (W8)Stress tests + guardrails (W9)Model card & demo set (W10)Data & EvaluationModelingSpeed & ReliabilityHandoffDeep Learning Roadmap (Project Weeks 0–10) diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index 8b1378917..e69de29bb 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -1 +0,0 @@ - diff --git a/docs/expanded-training-next-steps.md b/docs/expanded-training-next-steps.md index cfaa4bef6..d773e1fa6 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) diff --git a/docs/guides/COLAB_TROUBLESHOOTING.md b/docs/guides/COLAB_TROUBLESHOOTING.md index d19325090..9f0a8bff8 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! diff --git a/docs/guides/GITHUB_PAGES_DEPLOYMENT.md b/docs/guides/GITHUB_PAGES_DEPLOYMENT.md index e0d8243de..f466645c4 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!** 🎉 diff --git a/docs/guides/INTEGRATION_GUIDE.md b/docs/guides/INTEGRATION_GUIDE.md index 3e446a9e7..b007ef1a6 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" /> -