diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1c79e556ac4cc2ac6c4a036cb5627974d16928ca Binary files /dev/null and b/.DS_Store differ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..e92cf9b594e8e4608b60733d93926bdec544b5c1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.pt filter=lfs diff=lfs merge=lfs -text diff --git a/.ipynb_checkpoints/config-checkpoint.py b/.ipynb_checkpoints/config-checkpoint.py new file mode 100644 index 0000000000000000000000000000000000000000..d0ac883623c95f479b8e3f72798a737448912ca5 --- /dev/null +++ b/.ipynb_checkpoints/config-checkpoint.py @@ -0,0 +1,452 @@ +""" +FarmEyes Configuration File +=========================== +Central configuration for the FarmEyes crop disease detection application. +Contains model paths, class mappings, device settings, and app configurations. + +Device: Apple Silicon M1 Pro with MPS (Metal Performance Shaders) acceleration +""" + +import os +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass, field + + +# ============================================================================= +# PATH CONFIGURATIONS +# ============================================================================= + +# Base project directory - update this to your local path +BASE_DIR = Path(__file__).parent.resolve() + +# Data directories +DATA_DIR = BASE_DIR / "data" +STATIC_DIR = BASE_DIR / "static" +MODELS_DIR = BASE_DIR / "models" +OUTPUTS_DIR = BASE_DIR / "outputs" + +# Create directories if they don't exist +for directory in [DATA_DIR, STATIC_DIR, MODELS_DIR, OUTPUTS_DIR]: + directory.mkdir(parents=True, exist_ok=True) + +# Knowledge base and UI translations paths +KNOWLEDGE_BASE_PATH = DATA_DIR / "knowledge_base.json" +UI_TRANSLATIONS_PATH = STATIC_DIR / "ui_translations.json" + + +# ============================================================================= +# MODEL CONFIGURATIONS +# ============================================================================= + +@dataclass +class YOLOConfig: + """Configuration for YOLOv11 disease detection model""" + + # Path to trained YOLOv11 model weights (.pt file) + # Update this path once your model training is complete + model_path: Path = MODELS_DIR / "farmeyes_yolov11.pt" + + # Confidence threshold for detections (0.0 - 1.0) + confidence_threshold: float = 0.5 + + # IoU threshold for non-maximum suppression + iou_threshold: float = 0.45 + + # Input image size (YOLOv11 default) + input_size: int = 640 + + # Maximum number of detections per image + max_detections: int = 10 + + # Device for inference ('mps' for Apple Silicon, 'cuda' for NVIDIA, 'cpu' for CPU) + device: str = "mps" + + +@dataclass +class NATLaSConfig: + """Configuration for N-ATLaS language model (GGUF format)""" + + # Hugging Face model repository + hf_repo: str = "tosinamuda/N-ATLaS-GGUF" + + # GGUF model filename (16-bit quantized version) + model_filename: str = "N-ATLaS-8B-Instruct-v2.2-F16.gguf" + + # Local path where model will be downloaded/cached + model_path: Path = MODELS_DIR / "natlas" + + # Full path to the GGUF file + @property + def gguf_path(self) -> Path: + return self.model_path / self.model_filename + + # Context window size (tokens) + context_length: int = 4096 + + # Maximum tokens to generate in response + max_tokens: int = 1024 + + # Temperature for text generation (0.0 = deterministic, 1.0 = creative) + temperature: float = 0.7 + + # Top-p (nucleus) sampling + top_p: float = 0.9 + + # Number of GPU layers to offload (for MPS acceleration) + # Set to -1 to offload all layers, 0 for CPU only + n_gpu_layers: int = -1 + + # Number of threads for CPU computation + n_threads: int = 8 + + # Batch size for prompt processing + n_batch: int = 512 + + # Device for inference + device: str = "mps" + + +# ============================================================================= +# DISEASE CLASS MAPPINGS +# ============================================================================= + +# YOLOv11 class index to disease key mapping +# These matches the class indices from our trained model +CLASS_INDEX_TO_KEY: Dict[int, str] = { + 0: "cassava_bacterial_blight", + 1: "cassava_healthy", + 2: "cassava_mosaic_disease", + 3: "cocoa_healthy", + 4: "cocoa_monilia_disease", + 5: "cocoa_phytophthora_disease", + 6: "tomato_gray_mold", + 7: "tomato_healthy", + 8: "tomato_viral_disease", + 9: "tomato_wilt_disease" +} + +# Reverse mapping: disease key to class index +KEY_TO_CLASS_INDEX: Dict[str, int] = {v: k for k, v in CLASS_INDEX_TO_KEY.items()} + +# Class names as they appear in YOLO training (macthes our data.yaml file) +CLASS_NAMES: List[str] = [ + "Cassava Bacteria Blight", + "Cassava Healthy Leaf", + "Cassava Mosaic Disease", + "Cocoa Healthy Leaf", + "Cocoa Monilia Disease", + "Cocoa Phytophthora Disease", + "Tomato Gray Mold Disease", + "Tomato Healthy Leaf", + "Tomato Viral Disease", + "Tomato Wilt Disease" +] + +# Healthy class indices (for quick identification) +HEALTHY_CLASS_INDICES: List[int] = [1, 3, 7] # cassava_healthy, cocoa_healthy, tomato_healthy + +# Disease class indices (excluding healthy) +DISEASE_CLASS_INDICES: List[int] = [0, 2, 4, 5, 6, 8, 9] + +# Crop type mapping +CROP_TYPES: Dict[str, List[int]] = { + "cassava": [0, 1, 2], + "cocoa": [3, 4, 5], + "tomato": [6, 7, 8, 9] +} + +# Reverse mapping: class index to crop type +CLASS_TO_CROP: Dict[int, str] = {} +for crop, indices in CROP_TYPES.items(): + for idx in indices: + CLASS_TO_CROP[idx] = crop + + +# ============================================================================= +# LANGUAGE CONFIGURATIONS +# ============================================================================= + +@dataclass +class LanguageConfig: + """Configuration for supported languages""" + + # Supported language codes + supported_languages: List[str] = field(default_factory=lambda: ["en", "ha", "yo", "ig"]) + + # Default language + default_language: str = "en" + + # Language display names + language_names: Dict[str, str] = field(default_factory=lambda: { + "en": "English", + "ha": "Hausa", + "yo": "Yorùbá", + "ig": "Igbo" + }) + + # Language codes for N-ATLaS prompts + language_full_names: Dict[str, str] = field(default_factory=lambda: { + "en": "English", + "ha": "Hausa", + "yo": "Yoruba", + "ig": "Igbo" + }) + + +# ============================================================================= +# APPLICATION CONFIGURATIONS +# ============================================================================= + +@dataclass +class AppConfig: + """General application configuration""" + + # App information + app_name: str = "FarmEyes" + app_version: str = "1.0.0" + app_tagline: str = "AI-Powered Crop Health Assistant" + + # Gradio server settings + server_host: str = "0.0.0.0" + server_port: int = 7860 + share: bool = False # Set to True for public Gradio link + + # Debug mode + debug: bool = True + + # Maximum image file size (in bytes) - 10MB + max_image_size: int = 10 * 1024 * 1024 + + # Supported image formats + supported_image_formats: List[str] = field(default_factory=lambda: [ + ".jpg", ".jpeg", ".png", ".webp", ".bmp" + ]) + + # Confidence thresholds for user feedback + high_confidence_threshold: float = 0.85 + medium_confidence_threshold: float = 0.60 + low_confidence_threshold: float = 0.40 + + # Enable/disable features + enable_voice_input: bool = False # Future feature + enable_offline_mode: bool = False # Future feature + enable_history: bool = True + + +# ============================================================================= +# DEVICE CONFIGURATION (Apple Silicon Specific) +# ============================================================================= + +@dataclass +class DeviceConfig: + """Device and hardware configuration for Apple Silicon M1 Pro""" + + # Primary compute device + # Options: 'mps' (Apple Silicon GPU), 'cuda' (NVIDIA GPU), 'cpu' + compute_device: str = "mps" + + # Fallback device if primary is unavailable + fallback_device: str = "cpu" + + # Enable MPS (Metal Performance Shaders) for PyTorch + use_mps: bool = True + + # Memory management + # Set to True to clear GPU cache after each inference + clear_cache_after_inference: bool = True + + @staticmethod + def get_device() -> str: + """ + Determine the best available device for computation. + Returns 'mps' for Apple Silicon, 'cuda' for NVIDIA, or 'cpu'. + """ + import torch + + # Check for Apple Silicon MPS + if torch.backends.mps.is_available(): + return "mps" + # Check for NVIDIA CUDA + elif torch.cuda.is_available(): + return "cuda" + # Fallback to CPU + else: + return "cpu" + + @staticmethod + def get_device_info() -> Dict[str, str]: + """Get information about the current compute device.""" + import torch + import platform + + info = { + "platform": platform.system(), + "processor": platform.processor(), + "python_version": platform.python_version(), + "pytorch_version": torch.__version__, + "device": DeviceConfig.get_device() + } + + if torch.backends.mps.is_available(): + info["mps_available"] = "Yes" + info["mps_built"] = str(torch.backends.mps.is_built()) + + return info + + +# ============================================================================= +# PROMPT TEMPLATES CONFIGURATION +# ============================================================================= + +@dataclass +class PromptConfig: + """Configuration for N-ATLaS prompt templates""" + + # System prompt for the N-ATLaS model + system_prompt: str = """You are FarmEyes, an AI agricultural assistant helping Nigerian farmers. +You provide advice about crop diseases and treatments in a clear, simple, and helpful manner. +Always be respectful and use language that farmers can easily understand. +When providing treatment costs, use Nigerian Naira (₦). +Focus on practical advice that farmers can implement.""" + + # Maximum length for translated text + max_translation_length: int = 500 + + # Temperature for different tasks + translation_temperature: float = 0.3 # Lower for more accurate translations + diagnosis_temperature: float = 0.7 # Higher for more natural explanations + + +# ============================================================================= +# LOGGING CONFIGURATION +# ============================================================================= + +@dataclass +class LogConfig: + """Logging configuration""" + + # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL + log_level: str = "INFO" + + # Log file path + log_file: Path = BASE_DIR / "logs" / "farmeyes.log" + + # Log format + log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Enable console logging + console_logging: bool = True + + # Enable file logging + file_logging: bool = True + + +# ============================================================================= +# INSTANTIATE DEFAULT CONFIGURATIONS +# ============================================================================= + +# Create default configuration instances +yolo_config = YOLOConfig() +natlas_config = NATLaSConfig() +language_config = LanguageConfig() +app_config = AppConfig() +device_config = DeviceConfig() +prompt_config = PromptConfig() +log_config = LogConfig() + + +# ============================================================================= +# UTILITY FUNCTIONS +# ============================================================================= + +def get_disease_key(class_index: int) -> Optional[str]: + """Get disease key from class index.""" + return CLASS_INDEX_TO_KEY.get(class_index) + + +def get_class_index(disease_key: str) -> Optional[int]: + """Get class index from disease key.""" + return KEY_TO_CLASS_INDEX.get(disease_key) + + +def get_crop_type(class_index: int) -> Optional[str]: + """Get crop type from class index.""" + return CLASS_TO_CROP.get(class_index) + + +def is_healthy(class_index: int) -> bool: + """Check if class index represents a healthy plant.""" + return class_index in HEALTHY_CLASS_INDICES + + +def validate_config() -> Dict[str, bool]: + """ + Validate that all required configuration files and paths exist. + Returns a dictionary with validation results. + """ + validations = { + "knowledge_base_exists": KNOWLEDGE_BASE_PATH.exists(), + "ui_translations_exists": UI_TRANSLATIONS_PATH.exists(), + "models_dir_exists": MODELS_DIR.exists(), + "yolo_model_exists": yolo_config.model_path.exists(), + "natlas_model_exists": natlas_config.gguf_path.exists(), + } + return validations + + +def print_config_summary(): + """Print a summary of the current configuration.""" + print("=" * 60) + print("FarmEyes Configuration Summary") + print("=" * 60) + print(f"\n📁 Paths:") + print(f" Base Directory: {BASE_DIR}") + print(f" Knowledge Base: {KNOWLEDGE_BASE_PATH}") + print(f" UI Translations: {UI_TRANSLATIONS_PATH}") + print(f" Models Directory: {MODELS_DIR}") + + print(f"\n🤖 YOLOv11 Model:") + print(f" Model Path: {yolo_config.model_path}") + print(f" Confidence Threshold: {yolo_config.confidence_threshold}") + print(f" Device: {yolo_config.device}") + + print(f"\n🗣️ N-ATLaS Model:") + print(f" HuggingFace Repo: {natlas_config.hf_repo}") + print(f" Model File: {natlas_config.model_filename}") + print(f" Context Length: {natlas_config.context_length}") + print(f" GPU Layers: {natlas_config.n_gpu_layers}") + + print(f"\n🌍 Languages:") + print(f" Supported: {', '.join(language_config.supported_languages)}") + print(f" Default: {language_config.default_language}") + + print(f"\n📱 Application:") + print(f" Name: {app_config.app_name} v{app_config.app_version}") + print(f" Server: {app_config.server_host}:{app_config.server_port}") + print(f" Debug Mode: {app_config.debug}") + + print(f"\n💻 Device:") + device_info = device_config.get_device_info() + print(f" Platform: {device_info.get('platform', 'Unknown')}") + print(f" Compute Device: {device_info.get('device', 'Unknown')}") + print(f" PyTorch Version: {device_info.get('pytorch_version', 'Unknown')}") + + print("\n" + "=" * 60) + + # Validation + print("\n🔍 Configuration Validation:") + validations = validate_config() + for key, value in validations.items(): + status = "✅" if value else "❌" + print(f" {status} {key.replace('_', ' ').title()}") + + print("\n" + "=" * 60) + + +# ============================================================================= +# MAIN - Run configuration check +# ============================================================================= + +if __name__ == "__main__": + print_config_summary() diff --git a/.ipynb_checkpoints/test-checkpoint.ipynb b/.ipynb_checkpoints/test-checkpoint.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..363fcab7ed6e9634e198cf5555ceb88932c9a245 --- /dev/null +++ b/.ipynb_checkpoints/test-checkpoint.ipynb @@ -0,0 +1,6 @@ +{ + "cells": [], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..08bed49d87e8deb249b8e086af50fc403c7b68e1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# ============================================================================= +# FarmEyes - HuggingFace Spaces Dockerfile +# ============================================================================= +# AI-Powered Crop Disease Detection for African Farmers +# +# This Dockerfile is optimized for HuggingFace Spaces free tier: +# - Uses Python 3.10 slim image +# - Installs llama-cpp-python for CPU inference +# - Downloads N-ATLaS GGUF model at runtime (~4.92GB) +# - Runs on port 7860 (HF Spaces default) +# ============================================================================= + +FROM python:3.10-slim + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV HOST=0.0.0.0 +ENV PORT=7860 + +# Install system dependencies +# - ffmpeg: for audio processing (Whisper) +# - libsm6, libxext6, libgl1: for OpenCV (image processing) +# - build-essential, cmake: for compiling llama-cpp-python +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libsm6 \ + libxext6 \ + libgl1-mesa-glx \ + build-essential \ + cmake \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first (for Docker cache optimization) +COPY requirements.txt . + +# Upgrade pip +RUN pip install --no-cache-dir --upgrade pip + +# Install Python dependencies +# Note: llama-cpp-python is compiled for CPU (no CUDA on free tier) +RUN pip install --no-cache-dir -r requirements.txt + +# Install llama-cpp-python for CPU +# This enables GGUF model inference +RUN pip install --no-cache-dir llama-cpp-python + +# Copy all application code +COPY . . + +# Create necessary directories +RUN mkdir -p /app/uploads /app/temp + +# Expose port 7860 (HuggingFace Spaces default) +EXPOSE 7860 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:7860/api/health || exit 1 + +# Run the application +# The app will: +# 1. Start FastAPI server +# 2. Download N-ATLaS GGUF model on first request (~5-15 min) +# 3. Serve the web interface +CMD ["python", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..763abc3592051a78e89081a9a34c1e1e7bf09f2d --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +--- +title: FarmEyes +emoji: 🌱 +colorFrom: green +colorTo: yellow +sdk: docker +app_port: 7860 +pinned: false +suggested_hardware: cpu-basic +--- + +# 🌱 FarmEyes + +**AI-Powered Crop Disease Detection for African Farmers** + +[![Built for Awarri Challenge](https://img.shields.io/badge/Built%20for-Awarri%20Challenge%202025-green)](https://awarri.com) +[![N-ATLaS Powered](https://img.shields.io/badge/Powered%20by-N--ATLaS-blue)](https://huggingface.co/NCAIR1/N-ATLaS) + +--- + +## 🎯 What is FarmEyes? + +FarmEyes is an AI application that helps African farmers identify crop diseases and get treatment recommendations in their native languages. Simply upload a photo of your crop, and FarmEyes will: + +1. **Detect** the disease using computer vision (YOLOv11) +2. **Diagnose** the condition with severity assessment +3. **Translate** all information to your preferred language +4. **Chat** with an AI assistant for follow-up questions + +--- + +## 🌍 Supported Languages + +| Language | Native Name | +|----------|-------------| +| 🇬🇧 English | English | +| 🇳🇬 Hausa | Yaren Hausa | +| 🇳🇬 Yoruba | Èdè Yorùbá | +| 🇳🇬 Igbo | Asụsụ Igbo | + +--- + +## 🦠 Detectable Diseases + +| Crop | Diseases | +|------|----------| +| 🌿 **Cassava** | Bacterial Blight, Mosaic Virus | +| 🍫 **Cocoa** | Monilia Disease, Phytophthora Disease | +| 🍅 **Tomato** | Gray Mold Disease, Wilt Disease | + +--- + +## 🚀 How to Use + +### Step 1: Select Language +Choose your preferred language from the welcome screen. + +### Step 2: Upload Image +Take a photo of the affected crop leaf and upload it. + +### Step 3: View Results +- Disease name and confidence score +- Severity level (Low/Moderate/High/Critical) +- Treatment recommendations +- Cost estimates in Nigerian Naira (₦) + +### Step 4: Ask Questions +Use the chat feature to ask follow-up questions about the diagnosis. + +--- + +## 🔧 Technology Stack + +| Component | Technology | +|-----------|------------| +| **Disease Detection** | YOLOv11 (trained on African crops) | +| **Language Model** | N-ATLaS (Nigerian multilingual AI) | +| **Speech-to-Text** | OpenAI Whisper | +| **Backend** | FastAPI | +| **Frontend** | Custom HTML/CSS/JS | + +--- + +## 📱 Features + +- ✅ **Image Upload** - Drag & drop or click to upload +- ✅ **Real-time Detection** - Results in seconds +- ✅ **Multilingual Support** - 4 Nigerian languages +- ✅ **Voice Input** - Speak your questions +- ✅ **Text-to-Speech** - Listen to responses +- ✅ **Treatment Advice** - Practical farming guidance +- ✅ **Cost Estimates** - In Nigerian Naira + +--- + +## ⚠️ First Startup Notice + +**Please be patient on first use!** + +The N-ATLaS language model (~4.92GB) is downloaded automatically on first startup. This may take **5-15 minutes** depending on connection speed. Subsequent uses will be much faster. + +--- + +## 🏆 About + +FarmEyes was built for the **Awarri Developer Challenge 2025** to address the critical need for accessible agricultural AI in Africa. + +**The Problem:** +- 20-80% crop losses annually due to diseases +- Only 1 extension worker per 10,000 farmers (FAO recommends 1:1,000) +- Agricultural knowledge locked in English + +**Our Solution:** +- AI-powered disease detection accessible via smartphone +- Native language support through N-ATLaS +- Practical, localized treatment recommendations + +--- + +## 👨‍💻 Developer + +**Fola-AI** + +- 🤗 HuggingFace: [@Fola-AI](https://huggingface.co/Fola-AI) + +--- + +## 📄 License + +Apache 2.0 + +--- + +## 🙏 Acknowledgments + +- [NCAIR](https://ncair.nitda.gov.ng/) for N-ATLaS model +- [Ultralytics](https://ultralytics.com/) for YOLOv11 +- [HuggingFace](https://huggingface.co/) for hosting +- [Awarri](https://awarri.com/) for the challenge opportunity + +--- + +*Built with ❤️ for African Farmers* diff --git a/api/.DS_Store b/api/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..39cb7c1e572a6f82613a427726097001c1277245 Binary files /dev/null and b/api/.DS_Store differ diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3ef2772a36a8c374b5fb7f8935a40921fd67b997 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,22 @@ +""" +FarmEyes API Routes Package +=========================== +REST API endpoint modules for the FarmEyes application. + +Endpoints: +- /api/detect - Disease detection from images +- /api/chat - Contextual chat with N-ATLaS +- /api/transcribe - Voice-to-text with Whisper +- /api/session - Session management +- /api/translate - Text translation +""" + +from api.routes.detection import router as detection_router +from api.routes.chat import router as chat_router +from api.routes.transcribe import router as transcribe_router + +__all__ = [ + "detection_router", + "chat_router", + "transcribe_router" +] diff --git a/api/__pycache__/__init__.cpython-310.pyc b/api/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47e1c065b66b729a9a9a9fd2a72421f88002a516 Binary files /dev/null and b/api/__pycache__/__init__.cpython-310.pyc differ diff --git a/api/__pycache__/__init__.cpython-312.pyc b/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ef71b9f1864b2b905a3fdbd64fa6289ff92ff06 Binary files /dev/null and b/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/api/routes/.DS_Store b/api/routes/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..49e3ebb2ee1d4ac6a14707db7d0a30e5023bf6ab Binary files /dev/null and b/api/routes/.DS_Store differ diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..db372824ac9c774a1ebc5f7fea6f681aa72bbfc8 --- /dev/null +++ b/api/routes/__init__.py @@ -0,0 +1,15 @@ +""" +FarmEyes API Routes +=================== +Individual route modules for REST API endpoints. +""" + +from api.routes.detection import router as detection_router +from api.routes.chat import router as chat_router +from api.routes.transcribe import router as transcribe_router + +__all__ = [ + "detection_router", + "chat_router", + "transcribe_router" +] diff --git a/api/routes/__pycache__/__init__.cpython-310.pyc b/api/routes/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..66b9216567de8a9da69a5ba3f815ce8f572e2517 Binary files /dev/null and b/api/routes/__pycache__/__init__.cpython-310.pyc differ diff --git a/api/routes/__pycache__/__init__.cpython-312.pyc b/api/routes/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c87ea874d3019e130039dbc9366aa37a33ee4938 Binary files /dev/null and b/api/routes/__pycache__/__init__.cpython-312.pyc differ diff --git a/api/routes/__pycache__/chat.cpython-310.pyc b/api/routes/__pycache__/chat.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ecf69f888c367ae9d064ef3975bee02bc4f21e0 Binary files /dev/null and b/api/routes/__pycache__/chat.cpython-310.pyc differ diff --git a/api/routes/__pycache__/chat.cpython-312.pyc b/api/routes/__pycache__/chat.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8f36a93ee2b2f2270abaccd9f6aabf47ded3c38 Binary files /dev/null and b/api/routes/__pycache__/chat.cpython-312.pyc differ diff --git a/api/routes/__pycache__/detection.cpython-310.pyc b/api/routes/__pycache__/detection.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af3320ab54701e2d6dd8025bcf250dfb826cb90a Binary files /dev/null and b/api/routes/__pycache__/detection.cpython-310.pyc differ diff --git a/api/routes/__pycache__/detection.cpython-312.pyc b/api/routes/__pycache__/detection.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..38cae35931a41a7d5ea5f25d693019df861c674a Binary files /dev/null and b/api/routes/__pycache__/detection.cpython-312.pyc differ diff --git a/api/routes/__pycache__/transcribe.cpython-310.pyc b/api/routes/__pycache__/transcribe.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06a1417ad1eebe98b1a2e4a66c7843327137f457 Binary files /dev/null and b/api/routes/__pycache__/transcribe.cpython-310.pyc differ diff --git a/api/routes/__pycache__/transcribe.cpython-312.pyc b/api/routes/__pycache__/transcribe.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23436c47ae632dba8071f6ee149ee8434ca3c689 Binary files /dev/null and b/api/routes/__pycache__/transcribe.cpython-312.pyc differ diff --git a/api/routes/__pycache__/tts.cpython-310.pyc b/api/routes/__pycache__/tts.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c4e7820ddd1f4be457df3ae5b0b903f5baaf2c16 Binary files /dev/null and b/api/routes/__pycache__/tts.cpython-310.pyc differ diff --git a/api/routes/chat.py b/api/routes/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..9c08e40a898f3b514ddf5f9a568d6d6cf37e19c4 --- /dev/null +++ b/api/routes/chat.py @@ -0,0 +1,340 @@ +""" +FarmEyes Chat API Routes +======================== +REST API endpoints for contextual agricultural chat. + +Endpoints: +- POST /api/chat - Send message and get response +- GET /api/chat/welcome - Get welcome message for chat page +- GET /api/chat/history - Get chat history for session +- DELETE /api/chat/history - Clear chat history +""" + +import sys +from pathlib import Path +from typing import Optional, List +from datetime import datetime +import logging + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create router +router = APIRouter(prefix="/api/chat", tags=["Chat"]) + + +# ============================================================================= +# REQUEST/RESPONSE MODELS +# ============================================================================= + +class ChatRequest(BaseModel): + """Request model for chat message.""" + session_id: str = Field(..., description="Session ID") + message: str = Field(..., min_length=1, max_length=2000, description="User message") + language: str = Field(default="en", description="Response language (en, ha, yo, ig)") + + +class ChatResponse(BaseModel): + """Response model for chat message.""" + success: bool + response: str + session_id: str + language: str + is_redirect: bool = False + context: Optional[dict] = None + timestamp: str + + +class WelcomeResponse(BaseModel): + """Response model for welcome message.""" + success: bool + response: str + session_id: str + language: str + context: Optional[dict] = None + is_welcome: bool = True + + +class HistoryResponse(BaseModel): + """Response model for chat history.""" + success: bool + session_id: str + messages: List[dict] + total_messages: int + + +# ============================================================================= +# ENDPOINTS +# ============================================================================= + +@router.post("/", response_model=ChatResponse) +async def send_chat_message(request: ChatRequest): + """ + Send a chat message and get AI response. + + The assistant will: + - Answer questions about the diagnosed disease + - Provide related agricultural advice + - Respond in the user's preferred language + - Redirect off-topic questions politely + + Requires an active session with a diagnosis. + """ + try: + # Validate language + valid_languages = ["en", "ha", "yo", "ig"] + language = request.language if request.language in valid_languages else "en" + + # Validate message + message = request.message.strip() + if not message: + raise HTTPException(status_code=400, detail="Message cannot be empty") + + if len(message) > 2000: + raise HTTPException(status_code=400, detail="Message too long (max 2000 characters)") + + # Import chat service + from services.chat_service import get_chat_service + + chat_service = get_chat_service() + + # Get response + logger.info(f"Chat request from session {request.session_id[:8]}...") + result = chat_service.chat( + session_id=request.session_id, + message=message, + language=language + ) + + if not result.get("success", False): + # Handle specific error cases + error_type = result.get("error", "unknown") + + if error_type == "no_diagnosis": + raise HTTPException( + status_code=400, + detail=result.get("response", "Please analyze an image first") + ) + else: + raise HTTPException( + status_code=500, + detail=result.get("response", "Failed to generate response") + ) + + # Build response + response_data = { + "success": True, + "response": result.get("response", ""), + "session_id": result.get("session_id", request.session_id), + "language": result.get("language", language), + "is_redirect": result.get("is_redirect", False), + "context": result.get("context"), + "timestamp": datetime.now().isoformat() + } + + return JSONResponse(content=response_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Chat failed: {e}") + raise HTTPException(status_code=500, detail=f"Chat failed: {str(e)}") + + +@router.get("/welcome", response_model=WelcomeResponse) +async def get_welcome_message( + session_id: str = Query(..., description="Session ID"), + language: str = Query(default="en", description="Language code") +): + """ + Get welcome message for chat page. + + Returns a personalized welcome message based on the + current diagnosis in the session. Should be called + when user navigates to the chat page. + """ + try: + # Validate language + valid_languages = ["en", "ha", "yo", "ig"] + language = language if language in valid_languages else "en" + + # Import chat service + from services.chat_service import get_chat_service + + chat_service = get_chat_service() + + # Get welcome message + result = chat_service.get_welcome_message(session_id, language) + + if not result.get("success", False): + error_type = result.get("error", "unknown") + + if error_type == "no_diagnosis": + raise HTTPException( + status_code=400, + detail=result.get("response", "Please analyze an image first") + ) + else: + raise HTTPException( + status_code=500, + detail="Failed to generate welcome message" + ) + + response_data = { + "success": True, + "response": result.get("response", ""), + "session_id": result.get("session_id", session_id), + "language": result.get("language", language), + "context": result.get("context"), + "is_welcome": True + } + + return JSONResponse(content=response_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get welcome failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/history", response_model=HistoryResponse) +async def get_chat_history( + session_id: str = Query(..., description="Session ID"), + limit: int = Query(default=50, ge=1, le=100, description="Maximum messages to return") +): + """ + Get chat history for a session. + + Returns all messages in the current chat session, + useful for restoring chat state when user returns + to the chat page. + """ + try: + from services.chat_service import get_chat_service + + chat_service = get_chat_service() + messages = chat_service.get_history(session_id) + + # Apply limit + if len(messages) > limit: + messages = messages[-limit:] + + return JSONResponse(content={ + "success": True, + "session_id": session_id, + "messages": messages, + "total_messages": len(messages) + }) + + except Exception as e: + logger.error(f"Get history failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/history") +async def clear_chat_history( + session_id: str = Query(..., description="Session ID") +): + """ + Clear chat history for a session. + + Removes all messages but keeps the diagnosis context, + allowing user to start a fresh conversation about + the same diagnosis. + """ + try: + from services.chat_service import get_chat_service + + chat_service = get_chat_service() + success = chat_service.clear_history(session_id) + + if success: + return JSONResponse(content={ + "success": True, + "message": "Chat history cleared", + "session_id": session_id + }) + else: + raise HTTPException(status_code=404, detail="Session not found") + + except HTTPException: + raise + except Exception as e: + logger.error(f"Clear history failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/context") +async def get_diagnosis_context( + session_id: str = Query(..., description="Session ID") +): + """ + Get current diagnosis context for chat. + + Returns the diagnosis information being used as + context for the chat assistant. Useful for displaying + context banner in chat UI. + """ + try: + from services.session_manager import get_session_manager + + session_manager = get_session_manager() + diagnosis = session_manager.get_diagnosis(session_id) + + if not diagnosis or not diagnosis.is_valid(): + raise HTTPException( + status_code=404, + detail="No diagnosis found for this session" + ) + + return JSONResponse(content={ + "success": True, + "session_id": session_id, + "context": diagnosis.to_dict(), + "context_string": diagnosis.get_context_string() + }) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Get context failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/voice") +async def chat_with_voice( + session_id: str = Query(..., description="Session ID"), + language: str = Query(default="en", description="Language code"), + text: str = Query(..., description="Transcribed text from voice") +): + """ + Send chat message from voice input. + + Convenience endpoint that accepts already-transcribed + text from the voice input system. The transcription + is done separately via /api/transcribe. + + This is the final step in the voice chat pipeline: + Voice → Whisper → Text → This endpoint → Response + """ + try: + # Create request and use main chat endpoint logic + request = ChatRequest( + session_id=session_id, + message=text, + language=language + ) + + return await send_chat_message(request) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Voice chat failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/routes/detection.py b/api/routes/detection.py new file mode 100644 index 0000000000000000000000000000000000000000..15c63fa42959f23cabb309defad423885bc521b2 --- /dev/null +++ b/api/routes/detection.py @@ -0,0 +1,381 @@ +""" +FarmEyes Detection API Routes +============================= +REST API endpoints for crop disease detection. + +Endpoints: +- POST /api/detect - Analyze crop image for diseases +- GET /api/detect/status - Check model status +- GET /api/detect/classes - Get supported disease classes +""" + +import sys +import io +import base64 +from pathlib import Path +from typing import Optional +from datetime import datetime +import logging + +from fastapi import APIRouter, File, UploadFile, Form, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create router +router = APIRouter(prefix="/api/detect", tags=["Detection"]) + + +# ============================================================================= +# REQUEST/RESPONSE MODELS +# ============================================================================= + +class DetectionRequest(BaseModel): + """Request model for detection with base64 image.""" + image_base64: str = Field(..., description="Base64 encoded image data") + language: str = Field(default="en", description="Language code (en, ha, yo, ig)") + session_id: Optional[str] = Field(default=None, description="Session ID for context") + + +class DetectionResponse(BaseModel): + """Response model for disease detection.""" + success: bool + session_id: str + detection: dict + diagnosis: dict + language: str + timestamp: str + + +class StatusResponse(BaseModel): + """Response model for service status.""" + status: str + yolo_loaded: bool + natlas_loaded: bool + knowledge_base_loaded: bool + supported_languages: list + supported_crops: list + + +class ClassesResponse(BaseModel): + """Response model for supported classes.""" + total_classes: int + classes: list + crops: dict + + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + +def decode_base64_image(base64_string: str) -> bytes: + """ + Decode base64 image string to bytes. + + Args: + base64_string: Base64 encoded image + + Returns: + Image bytes + """ + # Remove data URL prefix if present + if "," in base64_string: + base64_string = base64_string.split(",")[1] + + try: + return base64.b64decode(base64_string) + except Exception as e: + raise ValueError(f"Invalid base64 image: {e}") + + +def validate_image_format(filename: str) -> bool: + """ + Validate image file format. + + Args: + filename: Image filename + + Returns: + True if valid format + """ + valid_extensions = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} + ext = Path(filename).suffix.lower() + return ext in valid_extensions + + +# ============================================================================= +# ENDPOINTS +# ============================================================================= + +@router.post("/", response_model=DetectionResponse) +async def detect_disease( + file: UploadFile = File(..., description="Crop image file"), + language: str = Form(default="en", description="Language code"), + session_id: Optional[str] = Form(default=None, description="Session ID") +): + """ + Detect crop disease from uploaded image. + + Analyzes the image using YOLOv11 model and returns: + - Disease detection results + - Complete diagnosis with treatments + - All content translated to selected language + + Supported formats: JPG, JPEG, PNG, WEBP, BMP + Maximum file size: 10MB + """ + try: + # Validate file format + if not file.filename or not validate_image_format(file.filename): + raise HTTPException( + status_code=400, + detail="Invalid image format. Supported: JPG, JPEG, PNG, WEBP, BMP" + ) + + # Read file content + contents = await file.read() + + # Validate file size (10MB max) + max_size = 10 * 1024 * 1024 + if len(contents) > max_size: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size: {max_size // (1024*1024)}MB" + ) + + # Validate language + valid_languages = ["en", "ha", "yo", "ig"] + if language not in valid_languages: + language = "en" + + # Import services + from services.session_manager import get_session_manager, DiagnosisContext + from services.diagnosis_generator import generate_diagnosis_with_image + from PIL import Image + + # Get or create session + session_manager = get_session_manager() + session = session_manager.get_or_create_session(session_id, language) + + # Convert bytes to PIL Image + image = Image.open(io.BytesIO(contents)) + + # Generate diagnosis + logger.info(f"Processing detection for session {session.session_id[:8]}...") + report, annotated_image = generate_diagnosis_with_image(image, language) + + # Update session with diagnosis context + diagnosis_context = DiagnosisContext.from_diagnosis_report(report) + session_manager.update_diagnosis(session.session_id, diagnosis_context) + + # Build response + response_data = { + "success": True, + "session_id": session.session_id, + "detection": { + "disease_name": report.disease_name, + "crop_type": report.crop_type, + "confidence": report.confidence, + "confidence_percent": round(report.confidence * 100, 1), + "severity_level": report.severity_level, + "is_healthy": report.is_healthy + }, + "diagnosis": report.to_dict(), + "language": language, + "timestamp": datetime.now().isoformat() + } + + logger.info(f"Detection complete: {report.disease_name} ({report.confidence:.1%})") + + return JSONResponse(content=response_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Detection failed: {e}") + raise HTTPException(status_code=500, detail=f"Detection failed: {str(e)}") + + +@router.post("/base64", response_model=DetectionResponse) +async def detect_disease_base64(request: DetectionRequest): + """ + Detect crop disease from base64 encoded image. + + Alternative endpoint for clients that prefer sending + images as base64 strings rather than file uploads. + """ + try: + # Decode base64 image + try: + image_bytes = decode_base64_image(request.image_base64) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Validate size + max_size = 10 * 1024 * 1024 + if len(image_bytes) > max_size: + raise HTTPException( + status_code=400, + detail=f"Image too large. Maximum size: {max_size // (1024*1024)}MB" + ) + + # Validate language + valid_languages = ["en", "ha", "yo", "ig"] + language = request.language if request.language in valid_languages else "en" + + # Import services + from services.session_manager import get_session_manager, DiagnosisContext + from services.diagnosis_generator import generate_diagnosis_with_image + from PIL import Image + + # Get or create session + session_manager = get_session_manager() + session = session_manager.get_or_create_session(request.session_id, language) + + # Convert bytes to PIL Image + image = Image.open(io.BytesIO(image_bytes)) + + # Generate diagnosis + logger.info(f"Processing base64 detection for session {session.session_id[:8]}...") + report, annotated_image = generate_diagnosis_with_image(image, language) + + # Update session + diagnosis_context = DiagnosisContext.from_diagnosis_report(report) + session_manager.update_diagnosis(session.session_id, diagnosis_context) + + # Build response + response_data = { + "success": True, + "session_id": session.session_id, + "detection": { + "disease_name": report.disease_name, + "crop_type": report.crop_type, + "confidence": report.confidence, + "confidence_percent": round(report.confidence * 100, 1), + "severity_level": report.severity_level, + "is_healthy": report.is_healthy + }, + "diagnosis": report.to_dict(), + "language": language, + "timestamp": datetime.now().isoformat() + } + + return JSONResponse(content=response_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Base64 detection failed: {e}") + raise HTTPException(status_code=500, detail=f"Detection failed: {str(e)}") + + +@router.get("/status", response_model=StatusResponse) +async def get_detection_status(): + """ + Get status of detection service. + + Returns information about: + - Model loading status + - Supported languages + - Supported crops + """ + try: + # Try to get service status + status_info = { + "status": "operational", + "yolo_loaded": False, + "natlas_loaded": False, + "knowledge_base_loaded": False, + "supported_languages": ["en", "ha", "yo", "ig"], + "supported_crops": ["cassava", "cocoa", "tomato"] + } + + try: + from services.disease_detector import get_disease_detector + detector = get_disease_detector() + status_info["knowledge_base_loaded"] = detector._knowledge_base is not None + status_info["yolo_loaded"] = ( + detector._yolo_model is not None and + detector._yolo_model._is_loaded + ) + except Exception as e: + logger.warning(f"Could not get detector status: {e}") + + try: + from models.natlas_model import get_natlas_model + natlas = get_natlas_model() + status_info["natlas_loaded"] = natlas.is_loaded + except Exception as e: + logger.warning(f"Could not get N-ATLaS status: {e}") + + return JSONResponse(content=status_info) + + except Exception as e: + logger.error(f"Status check failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/classes", response_model=ClassesResponse) +async def get_supported_classes(): + """ + Get list of supported disease classes. + + Returns: + - Total number of classes + - Class names with indices + - Mapping of crops to class indices + """ + try: + from config import CLASS_NAMES, CROP_TYPES, CLASS_TO_CROP, CLASS_INDEX_TO_KEY + + classes_list = [] + for idx, name in enumerate(CLASS_NAMES): + classes_list.append({ + "index": idx, + "name": name, + "key": CLASS_INDEX_TO_KEY.get(idx, ""), + "crop": CLASS_TO_CROP.get(idx, "unknown") + }) + + return JSONResponse(content={ + "total_classes": len(CLASS_NAMES), + "classes": classes_list, + "crops": CROP_TYPES + }) + + except Exception as e: + logger.error(f"Get classes failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/session/{session_id}") +async def clear_session_diagnosis(session_id: str): + """ + Clear diagnosis data for a session. + + Clears the current diagnosis and chat history, + allowing user to start fresh with a new image. + """ + try: + from services.session_manager import get_session_manager + + session_manager = get_session_manager() + success = session_manager.clear_diagnosis(session_id) + + if success: + return JSONResponse(content={ + "success": True, + "message": "Diagnosis cleared", + "session_id": session_id + }) + else: + raise HTTPException(status_code=404, detail="Session not found") + + except HTTPException: + raise + except Exception as e: + logger.error(f"Clear session failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/routes/transcribe.py b/api/routes/transcribe.py new file mode 100644 index 0000000000000000000000000000000000000000..2f119b398e7a6d14fe49392da74bf0403fbd92af --- /dev/null +++ b/api/routes/transcribe.py @@ -0,0 +1,418 @@ +""" +FarmEyes Transcribe API Routes +============================== +REST API endpoints for speech-to-text transcription. + +Endpoints: +- POST /api/transcribe - Transcribe audio to text +- GET /api/transcribe/status - Check Whisper model status +- GET /api/transcribe/formats - Get supported audio formats +""" + +import sys +import io +import base64 +from pathlib import Path +from typing import Optional +from datetime import datetime +import logging + +from fastapi import APIRouter, File, UploadFile, Form, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create router +router = APIRouter(prefix="/api/transcribe", tags=["Transcription"]) + + +# ============================================================================= +# REQUEST/RESPONSE MODELS +# ============================================================================= + +class TranscribeRequest(BaseModel): + """Request model for base64 audio transcription.""" + audio_base64: str = Field(..., description="Base64 encoded audio data") + filename: str = Field(default="audio.wav", description="Original filename for format detection") + language_hint: Optional[str] = Field(default=None, description="Language hint (en, ha, yo, ig)") + + +class TranscribeResponse(BaseModel): + """Response model for transcription.""" + success: bool + text: str + language: Optional[str] = None + confidence: float = 0.0 + duration: float = 0.0 + processing_time: Optional[float] = None + + +class StatusResponse(BaseModel): + """Response model for service status.""" + status: str + model_loaded: bool + model_size: str + device: str + supported_formats: list + + +class FormatsResponse(BaseModel): + """Response model for supported formats.""" + formats: list + max_file_size_mb: int + max_duration_seconds: int + + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + +def decode_base64_audio(base64_string: str) -> bytes: + """ + Decode base64 audio string to bytes. + + Args: + base64_string: Base64 encoded audio + + Returns: + Audio bytes + """ + # Remove data URL prefix if present + if "," in base64_string: + base64_string = base64_string.split(",")[1] + + try: + return base64.b64decode(base64_string) + except Exception as e: + raise ValueError(f"Invalid base64 audio: {e}") + + +def validate_audio_format(filename: str) -> bool: + """ + Validate audio file format. + + Args: + filename: Audio filename + + Returns: + True if valid format + """ + valid_extensions = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm"} + ext = Path(filename).suffix.lower() + return ext in valid_extensions + + +# ============================================================================= +# ENDPOINTS +# ============================================================================= + +@router.post("/", response_model=TranscribeResponse) +async def transcribe_audio( + file: UploadFile = File(..., description="Audio file"), + language_hint: Optional[str] = Form(default=None, description="Language hint (en, ha, yo, ig)") +): + """ + Transcribe audio file to text. + + Uses OpenAI Whisper model for accurate speech-to-text, + with special optimization for Nigerian languages. + + Supported formats: WAV, MP3, M4A, OGG, FLAC, WEBM + Maximum file size: 5MB + Maximum duration: 30 seconds + + Language hints improve accuracy: + - en: English + - ha: Hausa + - yo: Yoruba + - ig: Igbo + """ + try: + # Validate file format + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + if not validate_audio_format(file.filename): + raise HTTPException( + status_code=400, + detail="Invalid audio format. Supported: WAV, MP3, M4A, OGG, FLAC, WEBM" + ) + + # Read file content + contents = await file.read() + + # Validate file size (5MB max) + max_size = 5 * 1024 * 1024 + if len(contents) > max_size: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size: {max_size // (1024*1024)}MB" + ) + + # Validate language hint + valid_languages = ["en", "ha", "yo", "ig"] + if language_hint and language_hint not in valid_languages: + language_hint = None + + # Import Whisper service + from services.whisper_service import get_whisper_service + + whisper_service = get_whisper_service() + + # Transcribe + logger.info(f"Transcribing audio: {file.filename}") + result = whisper_service.transcribe_bytes( + audio_bytes=contents, + filename=file.filename, + language_hint=language_hint + ) + + if not result.get("success", False): + error_msg = result.get("error", "Transcription failed") + raise HTTPException(status_code=500, detail=error_msg) + + # Build response + response_data = { + "success": True, + "text": result.get("text", ""), + "language": result.get("language"), + "confidence": result.get("confidence", 0.0), + "duration": result.get("duration", 0.0), + "processing_time": result.get("processing_time") + } + + logger.info(f"Transcription complete: {len(response_data['text'])} chars") + + return JSONResponse(content=response_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Transcription failed: {e}") + raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}") + + +@router.post("/base64", response_model=TranscribeResponse) +async def transcribe_audio_base64(request: TranscribeRequest): + """ + Transcribe base64 encoded audio to text. + + Alternative endpoint for clients that prefer sending + audio as base64 strings (e.g., from web recordings). + """ + try: + # Decode base64 audio + try: + audio_bytes = decode_base64_audio(request.audio_base64) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Validate size (5MB max) + max_size = 5 * 1024 * 1024 + if len(audio_bytes) > max_size: + raise HTTPException( + status_code=400, + detail=f"Audio too large. Maximum size: {max_size // (1024*1024)}MB" + ) + + # Validate format from filename + if not validate_audio_format(request.filename): + raise HTTPException( + status_code=400, + detail="Invalid audio format. Supported: WAV, MP3, M4A, OGG, FLAC, WEBM" + ) + + # Validate language hint + valid_languages = ["en", "ha", "yo", "ig"] + language_hint = request.language_hint + if language_hint and language_hint not in valid_languages: + language_hint = None + + # Import Whisper service + from services.whisper_service import get_whisper_service + + whisper_service = get_whisper_service() + + # Transcribe + logger.info(f"Transcribing base64 audio: {request.filename}") + result = whisper_service.transcribe_bytes( + audio_bytes=audio_bytes, + filename=request.filename, + language_hint=language_hint + ) + + if not result.get("success", False): + error_msg = result.get("error", "Transcription failed") + raise HTTPException(status_code=500, detail=error_msg) + + response_data = { + "success": True, + "text": result.get("text", ""), + "language": result.get("language"), + "confidence": result.get("confidence", 0.0), + "duration": result.get("duration", 0.0), + "processing_time": result.get("processing_time") + } + + return JSONResponse(content=response_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Base64 transcription failed: {e}") + raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}") + + +@router.get("/status", response_model=StatusResponse) +async def get_transcription_status(): + """ + Get status of transcription service. + + Returns information about: + - Whisper model loading status + - Model size and device + - Supported formats + """ + try: + from services.whisper_service import get_whisper_service + + whisper_service = get_whisper_service() + info = whisper_service.get_model_info() + + return JSONResponse(content={ + "status": "operational" if info.get("is_loaded") else "model_not_loaded", + "model_loaded": info.get("is_loaded", False), + "model_size": info.get("model_size", "base"), + "device": info.get("device", "cpu"), + "supported_formats": info.get("supported_formats", []) + }) + + except Exception as e: + logger.error(f"Status check failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/formats", response_model=FormatsResponse) +async def get_supported_formats(): + """ + Get supported audio formats and limits. + + Returns: + - List of supported audio formats + - Maximum file size + - Maximum audio duration + """ + try: + from services.whisper_service import AudioProcessor + + return JSONResponse(content={ + "formats": list(AudioProcessor.SUPPORTED_FORMATS), + "max_file_size_mb": 5, + "max_duration_seconds": AudioProcessor.MAX_DURATION + }) + + except Exception as e: + logger.error(f"Get formats failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/detect-language") +async def detect_audio_language( + file: UploadFile = File(..., description="Audio file") +): + """ + Detect language in audio file. + + Uses Whisper's language detection to identify + the spoken language without full transcription. + Faster than full transcription for language detection. + """ + try: + # Validate file + if not file.filename or not validate_audio_format(file.filename): + raise HTTPException( + status_code=400, + detail="Invalid audio format" + ) + + # Read content + contents = await file.read() + + # Validate size + max_size = 5 * 1024 * 1024 + if len(contents) > max_size: + raise HTTPException(status_code=400, detail="File too large") + + # Import service + from services.whisper_service import get_whisper_service + + whisper_service = get_whisper_service() + + # Detect language + result = whisper_service.detect_language(contents) + + if not result.get("success", False): + raise HTTPException( + status_code=500, + detail=result.get("error", "Language detection failed") + ) + + return JSONResponse(content={ + "success": True, + "language": result.get("language"), + "confidence": result.get("confidence", 0.0), + "top_languages": result.get("all_probabilities", {}) + }) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Language detection failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/load-model") +async def load_whisper_model(): + """ + Explicitly load the Whisper model. + + Useful for warming up the model before user + starts using voice input. Model loads automatically + on first use, but pre-loading improves UX. + """ + try: + from services.whisper_service import get_whisper_service + + whisper_service = get_whisper_service() + + if whisper_service.is_loaded: + return JSONResponse(content={ + "success": True, + "message": "Model already loaded", + "model_size": whisper_service.model_size + }) + + # Load model + logger.info("Pre-loading Whisper model...") + success = whisper_service.load_model() + + if success: + return JSONResponse(content={ + "success": True, + "message": "Model loaded successfully", + "model_size": whisper_service.model_size + }) + else: + raise HTTPException( + status_code=500, + detail="Failed to load Whisper model" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Load model failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/api/routes/tts.py b/api/routes/tts.py new file mode 100644 index 0000000000000000000000000000000000000000..73a7b42b72d6436627ae40254356b34f18aa95b7 --- /dev/null +++ b/api/routes/tts.py @@ -0,0 +1,182 @@ +""" +FarmEyes TTS API Routes +======================= +REST API endpoints for text-to-speech synthesis. + +Endpoints: +- POST /api/tts - Synthesize text to speech +- GET /api/tts/languages - Get supported languages +- GET /api/tts/status - Check TTS service status +""" + +import sys +from pathlib import Path +from typing import Optional +from datetime import datetime +import logging + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Create router +router = APIRouter(prefix="/api/tts", tags=["Text-to-Speech"]) + + +# ============================================================================= +# REQUEST/RESPONSE MODELS +# ============================================================================= + +class TTSRequest(BaseModel): + """Request model for TTS synthesis.""" + text: str = Field(..., description="Text to convert to speech", max_length=2000) + language: str = Field(default="en", description="Language code (en, ha, yo, ig)") + + +class TTSResponse(BaseModel): + """Response model for TTS synthesis.""" + success: bool + audio_base64: Optional[str] = None + content_type: str = "audio/flac" + duration: float = 0.0 + language: str = "en" + text_length: int = 0 + processing_time: Optional[float] = None + error: Optional[str] = None + + +class LanguagesResponse(BaseModel): + """Response model for supported languages.""" + success: bool + languages: dict + + +class StatusResponse(BaseModel): + """Response model for service status.""" + success: bool + status: str + has_token: bool + supported_languages: list + + +# ============================================================================= +# ENDPOINTS +# ============================================================================= + +@router.post("", response_model=TTSResponse) +@router.post("/", response_model=TTSResponse) +async def synthesize_speech(request: TTSRequest): + """ + Synthesize text to speech. + + Converts the provided text to audio using Meta MMS-TTS. + Returns base64 encoded audio data. + + Supported languages: + - en: English + - ha: Hausa + - yo: Yoruba + - ig: Igbo + """ + try: + from services.tts_service import get_tts_service + + logger.info(f"TTS request: lang={request.language}, text_len={len(request.text)}") + + # Get TTS service + tts_service = get_tts_service() + + # Check language support + if not tts_service.is_language_supported(request.language): + raise HTTPException( + status_code=400, + detail=f"Language '{request.language}' is not supported. Use: en, ha, yo, ig" + ) + + # Synthesize + result = tts_service.synthesize(request.text, request.language) + + if result["success"]: + return TTSResponse( + success=True, + audio_base64=result["audio_base64"], + content_type=result.get("content_type", "audio/flac"), + duration=result.get("duration", 0.0), + language=result["language"], + text_length=result.get("text_length", len(request.text)), + processing_time=result.get("processing_time") + ) + else: + # Return error but don't raise exception (for fallback handling) + return TTSResponse( + success=False, + language=request.language, + text_length=len(request.text), + error=result.get("error", "TTS synthesis failed") + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"TTS synthesis failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/languages", response_model=LanguagesResponse) +async def get_supported_languages(): + """ + Get list of supported TTS languages. + + Returns language codes and their display names. + """ + try: + from services.tts_service import get_tts_service + + tts_service = get_tts_service() + languages = tts_service.get_supported_languages() + + return LanguagesResponse( + success=True, + languages=languages + ) + + except Exception as e: + logger.error(f"Get languages failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/status", response_model=StatusResponse) +async def get_tts_status(): + """ + Get TTS service status. + + Returns whether the service is configured and ready. + """ + try: + from services.tts_service import get_tts_service + + tts_service = get_tts_service() + has_token = bool(tts_service.hf_token) + languages = list(tts_service.get_supported_languages().keys()) + + status = "ready" if has_token else "no_token" + + return StatusResponse( + success=True, + status=status, + has_token=has_token, + supported_languages=languages + ) + + except Exception as e: + logger.error(f"Get status failed: {e}") + return StatusResponse( + success=False, + status="error", + has_token=False, + supported_languages=[] + ) diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..249b1afb922d864d3c079bb5b67578c25bd07f98 --- /dev/null +++ b/config.py @@ -0,0 +1,635 @@ +""" +FarmEyes Configuration File +=========================== +Central configuration for the FarmEyes crop disease detection application. +Contains model paths, class mappings, device settings, API configurations, +session management, and Whisper speech-to-text settings. + +Device: Apple Silicon M1 Pro with MPS (Metal Performance Shaders) acceleration +Deployment: Local development + HuggingFace Spaces + +Model: Custom trained YOLOv11 for 6 disease classes +Crops: Cassava, Cocoa, Tomato +""" + +import os +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass, field + + +# ============================================================================= +# PATH CONFIGURATIONS +# ============================================================================= + +# Base project directory +BASE_DIR = Path(__file__).parent.resolve() + +# Data directories +DATA_DIR = BASE_DIR / "data" +STATIC_DIR = BASE_DIR / "static" +MODELS_DIR = BASE_DIR / "models" +OUTPUTS_DIR = BASE_DIR / "outputs" +FRONTEND_DIR = BASE_DIR / "frontend" +UPLOADS_DIR = BASE_DIR / "uploads" + +# Create directories if they don't exist +for directory in [DATA_DIR, STATIC_DIR, MODELS_DIR, OUTPUTS_DIR, UPLOADS_DIR]: + directory.mkdir(parents=True, exist_ok=True) + +# Knowledge base and UI translations paths +KNOWLEDGE_BASE_PATH = DATA_DIR / "knowledge_base.json" +UI_TRANSLATIONS_PATH = STATIC_DIR / "ui_translations.json" + + +# ============================================================================= +# API CONFIGURATION +# ============================================================================= + +@dataclass +class APIConfig: + """Configuration for FastAPI backend""" + + # Server settings + host: str = "0.0.0.0" + port: int = 7860 # Default HuggingFace Spaces port + + # API metadata + title: str = "FarmEyes API" + description: str = "AI-Powered Crop Disease Detection for Nigerian Farmers" + version: str = "2.0.0" + + # CORS settings (for frontend access) + cors_origins: List[str] = field(default_factory=lambda: [ + "http://localhost:7860", + "http://127.0.0.1:7860", + "https://*.hf.space", # HuggingFace Spaces + "*" # Allow all for development - restrict in production + ]) + + # Request limits + max_upload_size: int = 10 * 1024 * 1024 # 10MB max image upload + request_timeout: int = 60 # seconds + + # Rate limiting (basic) + rate_limit_requests: int = 100 # requests per minute + rate_limit_window: int = 60 # seconds + + # Debug mode + debug: bool = os.environ.get("DEBUG", "false").lower() == "true" + + # Environment detection + @property + def is_huggingface(self) -> bool: + """Check if running on HuggingFace Spaces""" + return os.environ.get("SPACE_ID") is not None + + @property + def base_url(self) -> str: + """Get base URL based on environment""" + if self.is_huggingface: + space_id = os.environ.get("SPACE_ID", "") + return f"https://{space_id.replace('/', '-')}.hf.space" + return f"http://{self.host}:{self.port}" + + +# ============================================================================= +# SESSION CONFIGURATION +# ============================================================================= + +@dataclass +class SessionConfig: + """Configuration for session management""" + + # Session settings + session_lifetime: int = 3600 # 1 hour in seconds + max_sessions: int = 1000 # Maximum concurrent sessions + + # Chat history settings + max_chat_history: int = 50 # Maximum messages per session + max_message_length: int = 2000 # Maximum characters per message + + # Context retention + retain_diagnosis: bool = True # Keep diagnosis context in session + + # Cleanup settings + cleanup_interval: int = 300 # 5 minutes - check for expired sessions + + +# ============================================================================= +# WHISPER CONFIGURATION +# ============================================================================= + +@dataclass +class WhisperConfig: + """Configuration for Whisper speech-to-text""" + + # Model settings + model_size: str = "base" # tiny, base, small, medium, large + + # Supported model sizes with approximate VRAM/RAM requirements + # tiny: ~1GB - Fastest, least accurate + # base: ~1GB - Good balance (SELECTED) + # small: ~2GB - Better accuracy + # medium: ~5GB - High accuracy + # large: ~10GB - Best accuracy + + # Device settings + device: str = "cpu" # Use CPU for broader compatibility + # Note: On Apple Silicon, Whisper runs well on CPU + # For GPU: set to "cuda" (NVIDIA) or use mlx-whisper for Apple Silicon + + # Audio settings + sample_rate: int = 16000 # Whisper expects 16kHz audio + max_audio_duration: int = 30 # Maximum seconds of audio to process + + # Language settings - Whisper auto-detects but we can hint + language_hints: Dict[str, str] = field(default_factory=lambda: { + "en": "english", + "ha": "hausa", + "yo": "yoruba", + "ig": "igbo" + }) + + # Transcription settings + task: str = "transcribe" # transcribe or translate + + # Performance settings + fp16: bool = False # Use FP32 for CPU compatibility + + # Supported audio formats + supported_formats: List[str] = field(default_factory=lambda: [ + ".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm" + ]) + + # Maximum audio file size (5MB) + max_file_size: int = 5 * 1024 * 1024 + + +# ============================================================================= +# MODEL CONFIGURATIONS +# ============================================================================= + +@dataclass +class YOLOConfig: + """Configuration for YOLOv11 disease detection model""" + + # Path to trained YOLOv11 model weights (.pt file) + model_path: Path = MODELS_DIR / "farmeyes_yolov11.pt" + + # Confidence threshold for detections (0.0 - 1.0) + confidence_threshold: float = 0.5 + + # IoU threshold for non-maximum suppression + iou_threshold: float = 0.45 + + # Input image size (YOLOv11 default) + input_size: int = 640 + + # Maximum number of detections per image + max_detections: int = 10 + + # Device for inference ('mps' for Apple Silicon, 'cuda' for NVIDIA, 'cpu' for CPU) + device: str = "mps" + + +@dataclass +class NATLaSConfig: + """Configuration for N-ATLaS language model (GGUF format)""" + + # Hugging Face model repository + hf_repo: str = "tosinamuda/N-ATLaS-GGUF" + + # GGUF model filename (Q4_K_M quantized version - smaller, faster) + model_filename: str = "N-ATLaS-GGUF-Q4_K_M.gguf" + + # Local path where model will be downloaded/cached + model_path: Path = MODELS_DIR / "natlas" + + # Full path to the GGUF file + @property + def gguf_path(self) -> Path: + return self.model_path / self.model_filename + + # Context window size (tokens) + context_length: int = 4096 + + # Maximum tokens to generate in response + max_tokens: int = 1024 + + # Chat-specific max tokens (shorter for responsiveness) + chat_max_tokens: int = 512 + + # Temperature for text generation (0.0 = deterministic, 1.0 = creative) + temperature: float = 0.7 + + # Chat temperature (slightly lower for more focused responses) + chat_temperature: float = 0.6 + + # Top-p (nucleus) sampling + top_p: float = 0.9 + + # Number of GPU layers to offload (for MPS acceleration) + # Set to -1 to offload all layers, 0 for CPU only + n_gpu_layers: int = -1 + + # Number of threads for CPU computation + n_threads: int = 8 + + # Batch size for prompt processing + n_batch: int = 512 + + # Device for inference + device: str = "mps" + + +# ============================================================================= +# DISEASE CLASS MAPPINGS (6 CLASSES - NO HEALTHY CLASSES) +# ============================================================================= + +# YOLOv11 class index to disease key mapping +CLASS_INDEX_TO_KEY: Dict[int, str] = { + 0: "cassava_bacterial_blight", + 1: "cassava_mosaic_virus", + 2: "cocoa_monilia_disease", + 3: "cocoa_phytophthora_disease", + 4: "tomato_gray_mold", + 5: "tomato_wilt_disease" +} + +# Reverse mapping: disease key to class index +KEY_TO_CLASS_INDEX: Dict[str, int] = {v: k for k, v in CLASS_INDEX_TO_KEY.items()} + +# Class names as they appear in YOLO training (6 classes) +CLASS_NAMES: List[str] = [ + "Cassava Bacteria Blight", # Index 0 + "Cassava Mosaic Virus", # Index 1 + "Cocoa Monilia Disease", # Index 2 + "Cocoa Phytophthora Disease", # Index 3 + "Tomato Gray Mold Disease", # Index 4 + "Tomato Wilt Disease" # Index 5 +] + +# No healthy class indices in 6-class model +HEALTHY_CLASS_INDICES: List[int] = [] + +# All class indices are disease classes +DISEASE_CLASS_INDICES: List[int] = [0, 1, 2, 3, 4, 5] + +# Crop type mapping (6 classes only) +CROP_TYPES: Dict[str, List[int]] = { + "cassava": [0, 1], + "cocoa": [2, 3], + "tomato": [4, 5] +} + +# Reverse mapping: class index to crop type +CLASS_TO_CROP: Dict[int, str] = {} +for crop, indices in CROP_TYPES.items(): + for idx in indices: + CLASS_TO_CROP[idx] = crop + + +# ============================================================================= +# LANGUAGE CONFIGURATIONS +# ============================================================================= + +@dataclass +class LanguageConfig: + """Configuration for supported languages""" + + # Supported language codes + supported_languages: List[str] = field(default_factory=lambda: ["en", "ha", "yo", "ig"]) + + # Default language + default_language: str = "en" + + # Language display names + language_names: Dict[str, str] = field(default_factory=lambda: { + "en": "English", + "ha": "Hausa", + "yo": "Yorùbá", + "ig": "Igbo" + }) + + # Language codes for N-ATLaS prompts + language_full_names: Dict[str, str] = field(default_factory=lambda: { + "en": "English", + "ha": "Hausa", + "yo": "Yoruba", + "ig": "Igbo" + }) + + # Native language names (for display in selector) + native_names: Dict[str, str] = field(default_factory=lambda: { + "en": "English", + "ha": "Hausa", + "yo": "Yorùbá", + "ig": "Asụsụ Igbo" + }) + + +# ============================================================================= +# CHAT CONFIGURATION +# ============================================================================= + +@dataclass +class ChatConfig: + """Configuration for contextual chatbot""" + + # System prompt for agricultural chat + system_prompt: str = """You are FarmEyes, an AI agricultural assistant helping Nigerian farmers. +You are currently discussing a specific crop disease diagnosis with the farmer. +Your role is to: +1. Answer questions ONLY about the diagnosed disease and related agricultural topics +2. Provide practical, actionable advice in simple language +3. Use local context (Nigerian farming practices, costs in Naira) +4. Be respectful, patient, and supportive +5. If asked about unrelated topics, politely redirect to agricultural matters + +IMPORTANT: Stay focused on the diagnosis context provided. Do not make up information. +If you don't know something, say so honestly and suggest consulting a local agricultural extension officer.""" + + # Context template for chat + context_template: str = """CURRENT DIAGNOSIS CONTEXT: +- Crop: {crop_type} +- Disease: {disease_name} +- Confidence: {confidence}% +- Severity: {severity} +- Key symptoms: {symptoms} +- Recommended treatment: {treatment_summary} + +The farmer may ask follow-up questions about this diagnosis.""" + + # Allowed topic keywords (for moderate context restriction) + allowed_topics: List[str] = field(default_factory=lambda: [ + # Disease-related + "disease", "infection", "symptom", "treatment", "cure", "prevention", + "spread", "cause", "severity", "recovery", + # Crop-related + "crop", "plant", "leaf", "stem", "root", "fruit", "harvest", "yield", + "cassava", "cocoa", "tomato", "farming", "agriculture", + # Treatment-related + "medicine", "chemical", "organic", "traditional", "spray", "apply", + "fungicide", "pesticide", "fertilizer", "cost", "price", "naira", + # General farming + "farm", "field", "soil", "water", "weather", "season", "planting", + "seed", "variety", "resistant", "healthy" + ]) + + # Response length limits + max_response_tokens: int = 400 + + # Welcome message template + welcome_template: str = """Hello! I'm your FarmEyes assistant. I've analyzed your {crop_type} plant and detected {disease_name} with {confidence}% confidence. + +I can help you understand: +• More about this disease and its symptoms +• Treatment options and costs +• Prevention methods +• When to seek expert help + +What would you like to know?""" + + +# ============================================================================= +# APPLICATION CONFIGURATIONS +# ============================================================================= + +@dataclass +class AppConfig: + """General application configuration""" + + # App information + app_name: str = "FarmEyes" + app_version: str = "2.0.0" + app_tagline: str = "AI-Powered Crop Disease Detection for Nigerian Farmers" + + # Server settings (legacy Gradio support) + server_host: str = "0.0.0.0" + server_port: int = 7860 + share: bool = False + + # Debug mode + debug: bool = True + + # Maximum image file size (in bytes) - 10MB + max_image_size: int = 10 * 1024 * 1024 + + # Supported image formats + supported_image_formats: List[str] = field(default_factory=lambda: [ + ".jpg", ".jpeg", ".png", ".webp", ".bmp" + ]) + + # Confidence thresholds for user feedback + high_confidence_threshold: float = 0.85 + medium_confidence_threshold: float = 0.60 + low_confidence_threshold: float = 0.40 + + # Enable/disable features + enable_voice_input: bool = True # Voice input with Whisper + enable_chat: bool = True # Contextual chat + enable_history: bool = True # Session history + + +# ============================================================================= +# DEVICE CONFIGURATION (Apple Silicon Specific) +# ============================================================================= + +@dataclass +class DeviceConfig: + """Device and hardware configuration for Apple Silicon M1 Pro""" + + # Primary compute device + compute_device: str = "mps" + + # Fallback device if primary is unavailable + fallback_device: str = "cpu" + + # Enable MPS (Metal Performance Shaders) for PyTorch + use_mps: bool = True + + # Memory management + clear_cache_after_inference: bool = True + + @staticmethod + def get_device() -> str: + """Determine the best available device for computation.""" + import torch + + if torch.backends.mps.is_available(): + return "mps" + elif torch.cuda.is_available(): + return "cuda" + else: + return "cpu" + + @staticmethod + def get_device_info() -> Dict[str, str]: + """Get information about the current compute device.""" + import torch + import platform + + info = { + "platform": platform.system(), + "processor": platform.processor(), + "python_version": platform.python_version(), + "pytorch_version": torch.__version__, + "device": DeviceConfig.get_device() + } + + if torch.backends.mps.is_available(): + info["mps_available"] = "Yes" + info["mps_built"] = str(torch.backends.mps.is_built()) + + return info + + +# ============================================================================= +# PROMPT TEMPLATES CONFIGURATION +# ============================================================================= + +@dataclass +class PromptConfig: + """Configuration for N-ATLaS prompt templates""" + + # System prompt for the N-ATLaS model + system_prompt: str = """You are FarmEyes, an AI agricultural assistant helping Nigerian farmers. +You provide advice about crop diseases and treatments in a clear, simple, and helpful manner. +Always be respectful and use language that farmers can easily understand. +When providing treatment costs, use Nigerian Naira (₦). +Focus on practical advice that farmers can implement.""" + + # Maximum length for translated text + max_translation_length: int = 500 + + # Temperature for different tasks + translation_temperature: float = 0.3 + diagnosis_temperature: float = 0.7 + chat_temperature: float = 0.6 + + +# ============================================================================= +# LOGGING CONFIGURATION +# ============================================================================= + +@dataclass +class LogConfig: + """Logging configuration""" + + # Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL + log_level: str = "INFO" + + # Log file path + log_file: Path = BASE_DIR / "logs" / "farmeyes.log" + + # Log format + log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Enable console logging + console_logging: bool = True + + # Enable file logging + file_logging: bool = True + + +# ============================================================================= +# INSTANTIATE DEFAULT CONFIGURATIONS +# ============================================================================= + +# Create default configuration instances +api_config = APIConfig() +session_config = SessionConfig() +whisper_config = WhisperConfig() +yolo_config = YOLOConfig() +natlas_config = NATLaSConfig() +language_config = LanguageConfig() +chat_config = ChatConfig() +app_config = AppConfig() +device_config = DeviceConfig() +prompt_config = PromptConfig() +log_config = LogConfig() + + +# ============================================================================= +# UTILITY FUNCTIONS +# ============================================================================= + +def get_disease_key(class_index: int) -> Optional[str]: + """Get disease key from class index.""" + return CLASS_INDEX_TO_KEY.get(class_index) + + +def get_class_index(disease_key: str) -> Optional[int]: + """Get class index from disease key.""" + return KEY_TO_CLASS_INDEX.get(disease_key) + + +def get_crop_type(class_index: int) -> Optional[str]: + """Get crop type from class index.""" + return CLASS_TO_CROP.get(class_index) + + +def is_healthy(class_index: int) -> bool: + """Check if class index represents a healthy plant (always False for 6-class).""" + return class_index in HEALTHY_CLASS_INDICES + + +def validate_config() -> Dict[str, bool]: + """Validate that all required configuration files and paths exist.""" + validations = { + "knowledge_base_exists": KNOWLEDGE_BASE_PATH.exists(), + "ui_translations_exists": UI_TRANSLATIONS_PATH.exists(), + "models_dir_exists": MODELS_DIR.exists(), + "yolo_model_exists": yolo_config.model_path.exists(), + "natlas_model_exists": natlas_config.gguf_path.exists(), + "frontend_dir_exists": FRONTEND_DIR.exists(), + } + return validations + + +def print_config_summary(): + """Print a summary of the current configuration.""" + print("=" * 60) + print("FarmEyes Configuration Summary v2.0") + print("=" * 60) + + print(f"\n📁 Paths:") + print(f" Base Directory: {BASE_DIR}") + print(f" Knowledge Base: {KNOWLEDGE_BASE_PATH}") + print(f" Frontend: {FRONTEND_DIR}") + + print(f"\n🌐 API Configuration:") + print(f" Host: {api_config.host}:{api_config.port}") + print(f" Debug: {api_config.debug}") + print(f" HuggingFace: {api_config.is_huggingface}") + + print(f"\n🤖 YOLOv11 Model:") + print(f" Model Path: {yolo_config.model_path}") + print(f" Confidence: {yolo_config.confidence_threshold}") + print(f" Classes: {len(CLASS_NAMES)}") + + print(f"\n🗣️ N-ATLaS Model:") + print(f" HF Repo: {natlas_config.hf_repo}") + print(f" Chat Max Tokens: {natlas_config.chat_max_tokens}") + + print(f"\n🎤 Whisper (Voice):") + print(f" Model Size: {whisper_config.model_size}") + print(f" Device: {whisper_config.device}") + + print(f"\n💬 Chat:") + print(f" Enabled: {app_config.enable_chat}") + print(f" Voice Input: {app_config.enable_voice_input}") + + print(f"\n🌍 Languages:") + print(f" Supported: {', '.join(language_config.supported_languages)}") + + print("\n" + "=" * 60) + + +# ============================================================================= +# MAIN - Run configuration check +# ============================================================================= + +if __name__ == "__main__": + print_config_summary() diff --git a/data/knowledge_base.json b/data/knowledge_base.json new file mode 100644 index 0000000000000000000000000000000000000000..8dd61644505a60a972ee839233cea62e60590fd2 --- /dev/null +++ b/data/knowledge_base.json @@ -0,0 +1,1115 @@ +{ + "_metadata": { + "version": "1.0.0", + "created": "2025-12-13", + "description": "FarmEyes Disease Knowledge Base - 6 disease classes for Nigerian farmers. All content in English - N-ATLaS handles runtime translation to Hausa, Yoruba, Igbo.", + "crops_covered": [ + "cassava", + "cocoa", + "tomato" + ], + "total_classes": 6, + "currency": "NGN", + "last_updated": "2025-12-13", + "note": "6-class model (diseases only, no healthy classes). N-ATLaS model performs all translations to local languages during app usage." + }, + "diseases": { + "cassava_bacterial_blight": { + "id": "CBB_001", + "class_name": "Cassava Bacteria Blight", + "display_name": "Cassava Bacterial Blight", + "scientific_name": "Xanthomonas axonopodis pv. manihotis", + "crop": "cassava", + "category": "bacterial", + "is_disease": true, + "severity": { + "level": "high", + "scale": 4, + "max_scale": 5, + "description": "Severe bacterial disease that can cause significant yield losses, especially during rainy season" + }, + "symptoms": [ + "Angular leaf spots that appear water-soaked", + "Leaf wilting and yellowing starting from the edges", + "Gum exudation (bacterial ooze) from stems - sticky yellowish substance", + "Dieback of shoot tips and young branches", + "Blighting and death of leaves", + "Vascular discoloration (brown streaks) when stem is cut", + "Canker formation on stems in severe cases" + ], + "how_it_spreads": [ + "Infected planting materials (stem cuttings) - most common source", + "Rain splash spreading bacteria between plants", + "Contaminated cutting tools and farm equipment", + "Wind-driven rain carrying bacteria", + "Workers' hands and clothing after touching infected plants" + ], + "favorable_conditions": { + "temperature": "25-30°C", + "humidity": "Above 80%", + "season": "Rainy season (May-October in Nigeria)", + "other": "Waterlogged soils, poor drainage, dense plant spacing" + }, + "yield_loss": { + "min_percent": 20, + "max_percent": 100, + "average_percent": 50, + "description": "Can cause 20-100% yield loss depending on severity, variety susceptibility, and time of infection" + }, + "treatments": { + "cultural": [ + { + "method": "Use disease-free planting materials", + "description": "Select healthy stems from certified disease-free fields. Inspect stems carefully before planting.", + "effectiveness": "high", + "cost_ngn": 0, + "timing": "Before planting" + }, + { + "method": "Roguing (remove infected plants)", + "description": "Immediately remove and burn all infected plants. Do not leave debris in field.", + "effectiveness": "high", + "cost_ngn_per_hectare": 5000, + "timing": "As soon as symptoms appear" + }, + { + "method": "Crop rotation", + "description": "Plant non-host crops like maize, sorghum, or legumes for 2-3 years before returning cassava to the field.", + "effectiveness": "medium", + "cost_ngn": 0, + "timing": "Seasonal planning" + }, + { + "method": "Tool sanitation", + "description": "Disinfect cutting tools with 10% bleach solution between plants and fields.", + "effectiveness": "high", + "cost_ngn": 500, + "timing": "During all field operations" + } + ], + "chemical": [ + { + "product_name": "Copper-based bactericide", + "active_ingredient": "Copper hydroxide", + "local_brands": [ + "Kocide 101", + "Nordox 75", + "Funguran-OH", + "Champion WP" + ], + "cost_ngn_min": 8000, + "cost_ngn_max": 15000, + "cost_unit": "per hectare per application", + "dosage": "2-3 kg per hectare in 400-500L water", + "frequency": "Every 2-3 weeks during rainy season", + "application_method": "Spray thoroughly on leaves and stems, especially undersides of leaves", + "effectiveness": "medium", + "safety_precautions": [ + "Wear protective clothing, gloves, and face mask", + "Do not spray on windy days", + "Avoid contact with skin and eyes", + "Wait 7 days before harvest" + ] + } + ], + "resistant_varieties": [ + { + "variety_name": "TMS 30572", + "resistance_level": "high", + "source": "IITA Ibadan, NRCRI Umudike", + "cost_ngn_per_bundle": 15000, + "notes": "Widely available, good yield potential" + }, + { + "variety_name": "TMS 4(2)1425", + "resistance_level": "high", + "source": "IITA Ibadan", + "cost_ngn_per_bundle": 18000, + "notes": "High yielding with good disease resistance" + }, + { + "variety_name": "NR 8083", + "resistance_level": "medium", + "source": "NRCRI Umudike", + "cost_ngn_per_bundle": 15000, + "notes": "Good for multiple disease resistance" + } + ], + "traditional": [ + { + "method": "Wood ash application", + "description": "Apply wood ash around plant base after rain. Creates alkaline environment less favorable for bacteria.", + "effectiveness": "low", + "cost_ngn": 0 + } + ] + }, + "total_treatment_cost": { + "min_ngn": 8000, + "max_ngn": 25000, + "per": "hectare", + "notes": "Using resistant varieties is the most cost-effective long-term solution" + }, + "prevention": [ + "Use certified disease-free planting materials from reputable sources (IITA, NRCRI, accredited seed companies)", + "Plant resistant varieties (TMS 30572, TMS 4(2)1425, NR 8083)", + "Practice field sanitation - remove all crop debris after harvest", + "Avoid working in fields when plants are wet from rain or dew", + "Disinfect cutting tools with 10% bleach solution between plants", + "Implement 2-3 year crop rotation with non-host crops (maize, legumes)", + "Ensure proper drainage to reduce humidity around plants", + "Maintain recommended plant spacing (1m x 1m) for good air circulation", + "Scout fields regularly for early disease detection" + ], + "health_projection": { + "early_detection": { + "recovery_chance_percent": 80, + "message": "If treated within 2 weeks of first symptoms appearing, approximately 80% of your field can be saved. Remove infected plants immediately and apply copper spray to remaining plants." + }, + "moderate_infection": { + "recovery_chance_percent": 50, + "message": "With moderate infection (less than 30% of plants affected), expect 50% yield recovery with immediate treatment. Focus on protecting healthy plants." + }, + "severe_infection": { + "recovery_chance_percent": 20, + "message": "Severe infection requires removing all affected plants. Only 20% may be salvageable. Consider replanting with resistant varieties next season." + } + }, + "expert_contact": { + "institution": "National Root Crops Research Institute (NRCRI)", + "location": "Umudike, Abia State, Nigeria", + "services": "Disease diagnosis, resistant variety seeds, extension services" + } + }, + "cassava_mosaic_virus": { + "id": "CMD_001", + "class_name": "Cassava Mosaic Virus", + "display_name": "Cassava Mosaic Virus", + "scientific_name": "Cassava mosaic geminiviruses (CMGs) - African cassava mosaic virus, East African cassava mosaic virus", + "crop": "cassava", + "category": "viral", + "is_disease": true, + "severity": { + "level": "very_high", + "scale": 5, + "max_scale": 5, + "description": "Most devastating cassava disease in Africa. Can cause complete crop failure in susceptible varieties." + }, + "symptoms": [ + "Mosaic pattern of yellow/light green and dark green patches on leaves", + "Leaf curling, twisting, and distortion", + "Reduced leaf size compared to healthy plants", + "Severely stunted plant growth", + "Misshapen, small, or no tuber formation", + "Chlorosis (yellowing) along leaf veins", + "Leaves may become completely yellow in severe cases" + ], + "how_it_spreads": [ + "Whiteflies (Bemisia tabaci) - primary vector, transmit virus while feeding", + "Infected stem cuttings used for planting - carries virus to new fields", + "Mechanical transmission through contaminated tools (minor)", + "NOT spread by contact between plants or through soil" + ], + "favorable_conditions": { + "temperature": "25-35°C", + "humidity": "Variable - disease occurs in all humidity levels", + "season": "Year-round, but more severe in dry season when whitefly populations peak", + "other": "High whitefly populations, planting infected cuttings, presence of infected plants nearby" + }, + "yield_loss": { + "min_percent": 30, + "max_percent": 95, + "average_percent": 50, + "description": "Can cause 30-95% yield loss. Severely infected plants may produce no usable tubers at all." + }, + "treatments": { + "cultural": [ + { + "method": "Use virus-free planting materials", + "description": "Source stem cuttings only from certified disease-free multiplication sites. Never take cuttings from infected plants.", + "effectiveness": "very_high", + "cost_ngn": 0, + "timing": "Before planting" + }, + { + "method": "Roguing infected plants", + "description": "Remove and destroy infected plants as soon as symptoms appear. This prevents whiteflies from spreading virus to healthy plants.", + "effectiveness": "high", + "cost_ngn_per_hectare": 5000, + "timing": "Weekly scouting and removal" + }, + { + "method": "Early planting", + "description": "Plant at onset of rains when whitefly populations are lower. This gives plants time to establish before peak whitefly season.", + "effectiveness": "medium", + "cost_ngn": 0, + "timing": "Start of rainy season" + }, + { + "method": "Remove volunteer plants", + "description": "Remove any cassava plants that grow from previous season's debris. These can harbor virus.", + "effectiveness": "medium", + "cost_ngn_per_hectare": 3000, + "timing": "Before and during planting" + } + ], + "chemical": [ + { + "product_name": "Imidacloprid (for whitefly control)", + "active_ingredient": "Imidacloprid", + "local_brands": [ + "Confidor", + "Gaucho", + "Admire", + "Kohinor" + ], + "cost_ngn_min": 5000, + "cost_ngn_max": 12000, + "cost_unit": "per hectare per application", + "dosage": "Follow label instructions - typically 100-200ml per hectare", + "frequency": "Every 2-3 weeks when whitefly pressure is high", + "application_method": "Spray on leaves, targeting undersides where whiteflies feed", + "effectiveness": "medium", + "important_note": "This controls whiteflies but does NOT cure already infected plants. Infected plants must be removed.", + "safety_precautions": [ + "Highly toxic to bees - apply in evening when bees are not active", + "Wear protective clothing and gloves", + "Do not spray near water sources", + "Follow pre-harvest interval on label" + ] + } + ], + "resistant_varieties": [ + { + "variety_name": "TME 419", + "resistance_level": "very_high", + "source": "IITA Ibadan, state ADPs", + "cost_ngn_per_bundle": 20000, + "notes": "Most widely recommended CMD-resistant variety. High yield." + }, + { + "variety_name": "UMUCASS 36 (TMS 01/1368)", + "resistance_level": "very_high", + "source": "NRCRI Umudike", + "cost_ngn_per_bundle": 22000, + "notes": "Excellent CMD resistance with high dry matter content" + }, + { + "variety_name": "UMUCASS 37 (TMS 01/1412)", + "resistance_level": "very_high", + "source": "NRCRI Umudike", + "cost_ngn_per_bundle": 22000, + "notes": "Good for garri processing" + }, + { + "variety_name": "UMUCASS 38 (TMS 01/1371)", + "resistance_level": "high", + "source": "NRCRI Umudike", + "cost_ngn_per_bundle": 20000, + "notes": "Multiple disease resistance" + }, + { + "variety_name": "TMS 98/0581", + "resistance_level": "high", + "source": "IITA Ibadan", + "cost_ngn_per_bundle": 18000, + "notes": "Good yield with CMD tolerance" + } + ], + "traditional": [ + { + "method": "Neem leaf extract spray", + "description": "Crush 1kg fresh neem leaves, soak in 5 liters of water overnight, strain and spray. Repels whiteflies.", + "effectiveness": "low", + "cost_ngn": 2000 + } + ] + }, + "total_treatment_cost": { + "min_ngn": 5000, + "max_ngn": 35000, + "per": "hectare", + "notes": "IMPORTANT: There is NO CURE for viral diseases. The best investment is planting resistant varieties. Infected plants cannot be cured and must be removed." + }, + "prevention": [ + "Plant CMD-resistant varieties (TME 419, UMUCASS 36, 37, 38) - MOST IMPORTANT", + "Source planting materials only from certified disease-free sources", + "Never take cuttings from plants showing any mosaic symptoms", + "Control whitefly populations with insecticides or neem extracts", + "Remove and burn all volunteer cassava plants from previous seasons", + "Practice thorough field sanitation after harvest", + "Avoid planting new cassava fields adjacent to infected fields", + "Inspect plants weekly and remove infected ones immediately", + "Do not transport cuttings from areas with high CMD incidence" + ], + "health_projection": { + "early_detection": { + "recovery_chance_percent": 70, + "message": "If you detect CMD early and immediately remove infected plants, you can protect approximately 70% of your yield. The key is stopping spread to healthy plants." + }, + "moderate_infection": { + "recovery_chance_percent": 40, + "message": "With moderate infection across the field, focus on removing all infected plants and protecting the remaining healthy ones. Expected yield recovery is about 40%." + }, + "severe_infection": { + "recovery_chance_percent": 10, + "message": "Severe CMD infection has spread widely. This season's harvest will be significantly reduced. Plan to replant next season using resistant varieties only." + } + }, + "expert_contact": { + "institution": "International Institute of Tropical Agriculture (IITA)", + "location": "Ibadan, Oyo State, Nigeria", + "services": "CMD-resistant varieties, disease diagnosis, training on CMD management" + } + }, + "cocoa_monilia_disease": { + "id": "CMN_001", + "class_name": "Cocoa Monilia Disease", + "display_name": "Frosty Pod Rot (Monilia Disease)", + "scientific_name": "Moniliophthora roreri", + "crop": "cocoa", + "category": "fungal", + "is_disease": true, + "severity": { + "level": "high", + "scale": 4, + "max_scale": 5, + "description": "Serious fungal disease that can destroy entire pod harvests. The white 'frosty' spore covering produces millions of spores that spread rapidly." + }, + "symptoms": [ + "White or cream-colored powdery coating on pods giving a 'frosty' appearance", + "Brown spots that enlarge rapidly on pod surface", + "Irregular swelling or lumps on pods before external symptoms appear", + "Internal pod rot with liquefied, foul-smelling pulp", + "Premature ripening or blackening of pods", + "Beans inside become sticky, clumped together, and unusable", + "Strong unpleasant odor from infected pods" + ], + "how_it_spreads": [ + "Wind dispersal of spores from infected pods - can travel several kilometers", + "Rain splash spreading spores to nearby pods", + "Contact with infected pods during harvesting", + "Contaminated harvesting tools (machetes, baskets)", + "Leaving infected pods on trees or ground provides continuous spore source" + ], + "favorable_conditions": { + "temperature": "20-28°C (optimal around 25°C)", + "humidity": "Above 85%", + "season": "Peak during rainy season, especially with prolonged wet periods", + "other": "Poor air circulation, excessive shade, leaving infected pods in field" + }, + "yield_loss": { + "min_percent": 25, + "max_percent": 90, + "average_percent": 50, + "description": "Can cause 25-90% pod losses in favorable conditions. Unmanaged outbreaks can destroy nearly entire harvests." + }, + "treatments": { + "cultural": [ + { + "method": "Weekly removal and destruction of infected pods", + "description": "Inspect all trees weekly. Remove any pod showing symptoms. Bury pods 30cm deep or burn them. Never leave infected pods on ground.", + "effectiveness": "high", + "cost_ngn_per_hectare": 10000, + "cost_frequency": "per month (labor)" + }, + { + "method": "Shade management", + "description": "Reduce shade canopy to 50% to improve air circulation and reduce humidity in the canopy.", + "effectiveness": "medium", + "cost_ngn_per_hectare": 15000, + "cost_frequency": "one-time pruning cost" + }, + { + "method": "Tree pruning", + "description": "Regular pruning to open up tree canopy, improve air flow, and make pods more accessible for inspection and harvesting.", + "effectiveness": "medium", + "cost_ngn_per_hectare": 20000, + "cost_frequency": "annually" + }, + { + "method": "Prompt harvesting", + "description": "Harvest mature pods immediately. Overripe pods are more susceptible to infection.", + "effectiveness": "medium", + "cost_ngn": 0 + } + ], + "chemical": [ + { + "product_name": "Copper-based fungicide", + "active_ingredient": "Copper hydroxide or Copper oxychloride", + "local_brands": [ + "Kocide 101", + "Nordox 75", + "Koka Blue 50 WG", + "Funguran-OH" + ], + "cost_ngn_min": 15000, + "cost_ngn_max": 25000, + "cost_unit": "per hectare per application", + "dosage": "2.5-3 kg per hectare in 500L water", + "frequency": "Monthly during pod development season", + "application_method": "Spray pods thoroughly, especially young developing pods. Focus on lower trunk where pods form.", + "effectiveness": "medium", + "safety_precautions": [ + "Wear protective clothing and mask", + "Apply in calm weather conditions", + "Avoid spraying during rain" + ] + }, + { + "product_name": "Metalaxyl + Mancozeb combination", + "active_ingredient": "Metalaxyl 12% + Mancozeb 60%", + "local_brands": [ + "Ridomil Gold MZ", + "Agro-laxyl 63.5 WP" + ], + "cost_ngn_min": 20000, + "cost_ngn_max": 35000, + "cost_unit": "per hectare per application", + "dosage": "2-2.5 kg per hectare", + "frequency": "Every 3-4 weeks during critical period (peak rainy season)", + "application_method": "Apply as preventive treatment before disease onset for best results", + "effectiveness": "high", + "safety_precautions": [ + "Wear full protective equipment", + "Do not apply within 14 days of harvest", + "Store away from food items" + ] + } + ], + "biological": [ + { + "method": "Trichoderma-based biocontrol", + "description": "Beneficial fungi that compete with and suppress disease fungi. Spray on pods and trunk.", + "effectiveness": "medium", + "cost_ngn_per_hectare_min": 12000, + "cost_ngn_per_hectare_max": 20000, + "source": "Available from CRIN (Cocoa Research Institute of Nigeria) and some agro-dealers", + "notes": "Best used as part of integrated management, not as sole treatment" + } + ], + "traditional": [ + { + "method": "Palm oil coating on pods", + "description": "Mix palm oil with water, spray on young pods. Creates a physical barrier against spore infection.", + "effectiveness": "low", + "cost_ngn": 5000, + "notes": "Traditional method with limited scientific validation" + } + ] + }, + "total_treatment_cost": { + "min_ngn": 15000, + "max_ngn": 50000, + "per": "hectare per season", + "notes": "Combination of regular pod removal (sanitation) with fungicide application gives best results" + }, + "prevention": [ + "Inspect trees and remove infected pods at least weekly", + "Maintain shade canopy at 50% for good air circulation", + "Prune trees regularly to reduce humidity in canopy", + "Harvest mature pods promptly - do not leave overripe pods", + "Never leave infected or rotting pods on trees or ground", + "Bury removed pods at least 30cm deep or burn them", + "Clean harvesting tools between trees using soap solution", + "Apply preventive fungicide sprays before peak disease season", + "Maintain good drainage in plantation" + ], + "health_projection": { + "early_detection": { + "recovery_chance_percent": 75, + "message": "Early detection with immediate pod removal can save approximately 75% of your harvest. Start weekly inspections now and remove every infected pod." + }, + "moderate_infection": { + "recovery_chance_percent": 50, + "message": "Moderate infection requires intensive sanitation combined with fungicide application. With immediate action, expect to save about 50% of remaining pods." + }, + "severe_infection": { + "recovery_chance_percent": 25, + "message": "Severe frosty pod rot outbreak. Remove all infected pods, apply fungicide to protect remaining healthy pods. Focus on protecting next season's production." + } + }, + "expert_contact": { + "institution": "Cocoa Research Institute of Nigeria (CRIN)", + "location": "Ibadan, Oyo State, Nigeria", + "services": "Disease diagnosis, fungicide recommendations, resistant varieties, extension services" + } + }, + "cocoa_phytophthora_disease": { + "id": "CPH_001", + "class_name": "Cocoa Phytophthora Disease", + "display_name": "Black Pod Disease", + "scientific_name": "Phytophthora palmivora and Phytophthora megakarya", + "crop": "cocoa", + "category": "oomycete", + "is_disease": true, + "severity": { + "level": "very_high", + "scale": 5, + "max_scale": 5, + "description": "Most serious cocoa disease in West Africa. P. megakarya (found in Nigeria) is more aggressive than P. palmivora and can destroy 60-100% of pods in severe outbreaks." + }, + "symptoms": [ + "Dark brown to black lesions starting at any point on the pod", + "Lesions spread very rapidly, covering entire pod within 10-14 days", + "White or grayish mold growth on pod surface in humid conditions", + "Firm pod becomes soft as internal rot progresses", + "Beans inside become shriveled, stuck together, and turn black", + "Canker lesions on stem bark with reddish-brown gum exudation", + "Wilting of leaves and dieback of branches in severe trunk infections" + ], + "how_it_spreads": [ + "Rain splash from infected pods - most important method", + "Infected pods on ground serve as continuous source of spores", + "Ants (especially Crematogaster striatula) carry spores between pods", + "Wind-driven rain spreading spores", + "Contaminated harvesting tools", + "Spores can survive in soil and plant debris" + ], + "favorable_conditions": { + "temperature": "20-30°C (optimal around 25°C)", + "humidity": "Above 85%", + "season": "Peak during rainy season (May-October in southern Nigeria)", + "other": "High rainfall, poor drainage, excessive shade, infected pods left in field" + }, + "yield_loss": { + "min_percent": 30, + "max_percent": 90, + "average_percent": 60, + "description": "Causes 30-90% pod losses. P. megakarya infections are faster and more destructive than P. palmivora. Annual losses estimated at over $700 million globally." + }, + "treatments": { + "cultural": [ + { + "method": "Frequent pod removal", + "description": "Remove all infected pods every 5-7 days. Bury pods at least 30cm deep or burn them. Never leave on ground surface.", + "effectiveness": "high", + "cost_ngn_per_hectare": 15000, + "cost_frequency": "per month (labor)" + }, + { + "method": "Improve drainage", + "description": "Create drainage channels to prevent waterlogging. Remove stagnant water from around trees.", + "effectiveness": "medium", + "cost_ngn_per_hectare": 30000, + "cost_frequency": "one-time installation" + }, + { + "method": "Shade and canopy management", + "description": "Maintain 50% shade, prune lower branches, and thin canopy to improve air circulation and reduce humidity.", + "effectiveness": "medium", + "cost_ngn_per_hectare": 25000, + "cost_frequency": "annually" + }, + { + "method": "Ant control", + "description": "Destroy ant nests around trees. Ants spread disease spores between pods.", + "effectiveness": "low", + "cost_ngn_per_hectare": 5000 + } + ], + "chemical": [ + { + "product_name": "Metalaxyl + Copper combination", + "active_ingredient": "Metalaxyl 12% + Copper-1-oxide 60%", + "local_brands": [ + "Ridomil 72 Plus", + "Foko", + "Ridomil Gold Plus" + ], + "cost_ngn_min": 25000, + "cost_ngn_max": 40000, + "cost_unit": "per hectare per application", + "dosage": "2.5-3 kg per hectare in 500L water", + "frequency": "3-4 applications during peak season (June, August, September, October)", + "application_method": "Spray on all pods and lower trunk. Most effective when applied before disease onset.", + "effectiveness": "very_high", + "safety_precautions": [ + "Wear full protective equipment", + "Apply in calm weather", + "Follow label directions exactly", + "Observe pre-harvest interval" + ] + }, + { + "product_name": "Copper fungicide", + "active_ingredient": "Copper hydroxide", + "local_brands": [ + "Kocide 101", + "Nordox 75", + "Blue Shield", + "Funguran-OH" + ], + "cost_ngn_min": 15000, + "cost_ngn_max": 25000, + "cost_unit": "per hectare per application", + "dosage": "2-3 kg per hectare", + "frequency": "Every 3-4 weeks during rainy season", + "application_method": "Thorough coverage of all pods. Contact fungicide - must cover pod surface to protect.", + "effectiveness": "medium" + }, + { + "product_name": "Phosphonate (trunk injection)", + "active_ingredient": "Fosetyl-Al or Phosphorous acid", + "local_brands": [ + "Foli-R-Fos 400", + "Aliette" + ], + "cost_ngn_min": 35000, + "cost_ngn_max": 50000, + "cost_unit": "per hectare per application", + "dosage": "As per label - injected into trunk", + "frequency": "1-2 times per season", + "application_method": "Inject directly into main trunk. Provides systemic protection throughout tree.", + "effectiveness": "very_high", + "notes": "Requires training for proper application technique" + } + ], + "biological": [ + { + "method": "Trichoderma asperellum", + "description": "Beneficial fungus that parasitizes Phytophthora. Applied as spray to pods and trunk.", + "effectiveness": "medium", + "cost_ngn_per_hectare_min": 15000, + "cost_ngn_per_hectare_max": 25000, + "source": "Research stage in Nigeria - contact CRIN for availability", + "notes": "Reduces disease but not as effective as chemical fungicides in severe outbreaks" + } + ], + "traditional": [ + { + "method": "Ash application", + "description": "Apply wood ash around base of trees. May help reduce soil moisture and spore survival.", + "effectiveness": "low", + "cost_ngn": 2000 + } + ] + }, + "total_treatment_cost": { + "min_ngn": 25000, + "max_ngn": 80000, + "per": "hectare per season", + "notes": "6-8 fungicide applications may be needed in severe areas. Combining sanitation with fewer fungicide sprays is most cost-effective." + }, + "prevention": [ + "Remove and destroy infected pods every 5-7 days - most important practice", + "Maintain proper tree spacing and reduce shade to 50%", + "Improve drainage in waterlogged areas", + "Harvest pods as soon as they mature - do not leave overripe", + "Control ant populations that spread spores", + "Apply preventive fungicide sprays before rainy season peak", + "Remove all pods from ground surface", + "Prune lower branches to reduce humidity near pods", + "Clean tools between trees with soap solution", + "Remove mummified pods from previous seasons" + ], + "health_projection": { + "early_detection": { + "recovery_chance_percent": 80, + "message": "With immediate treatment and sanitation, approximately 80% of remaining healthy pods can be saved. Begin fungicide application and twice-weekly pod removal immediately." + }, + "moderate_infection": { + "recovery_chance_percent": 50, + "message": "Apply fungicide immediately and intensify pod removal to every 5 days. With aggressive management, expect to save about 50% of your crop." + }, + "severe_infection": { + "recovery_chance_percent": 20, + "message": "Severe black pod outbreak. This season's harvest is significantly compromised. Focus sanitation and fungicide efforts on protecting next season's production." + } + }, + "expert_contact": { + "institution": "Cocoa Research Institute of Nigeria (CRIN)", + "location": "Ibadan, Oyo State, Nigeria", + "services": "Disease diagnosis, fungicide recommendations, integrated management training, tolerant varieties" + } + }, + "tomato_gray_mold": { + "id": "TGM_001", + "class_name": "Tomato Gray Mold Disease", + "display_name": "Gray Mold (Botrytis Blight)", + "scientific_name": "Botrytis cinerea", + "crop": "tomato", + "category": "fungal", + "is_disease": true, + "severity": { + "level": "high", + "scale": 4, + "max_scale": 5, + "description": "Common and destructive fungal disease especially in humid conditions. Can affect all above-ground plant parts and cause significant post-harvest losses." + }, + "symptoms": [ + "Soft, water-soaked spots on leaves, stems, and fruits", + "Distinctive gray fuzzy mold growth (spores) on infected areas", + "Brown to tan lesions on stems, often at pruning wounds or leaf scars", + "Blossom blight - flowers turn brown, wither, and fall off", + "Ghost spots on fruits - pale rings with darker centers", + "Fruit rot starting from stem end, wounds, or where fruit touches ground", + "Stem cankers that can girdle and kill plant" + ], + "how_it_spreads": [ + "Airborne spores (conidia) - primary spread method, released in clouds when disturbed", + "Splashing water from rain or overhead irrigation", + "Contaminated hands, tools, and clothing", + "Infected plant debris in soil - fungus survives as sclerotia", + "Entry through wounds, pruning cuts, flower scars, or senescent tissue" + ], + "favorable_conditions": { + "temperature": "15-25°C (optimal around 20°C)", + "humidity": "Above 93% for at least 8-12 hours", + "season": "Cool, cloudy, humid weather conditions", + "other": "Poor air circulation, overhead irrigation, wounded plants, dense plant canopy" + }, + "yield_loss": { + "min_percent": 15, + "max_percent": 50, + "average_percent": 25, + "description": "Can cause 15-50% losses in greenhouses. Field losses typically lower but can be severe in prolonged wet weather." + }, + "treatments": { + "cultural": [ + { + "method": "Improve air circulation", + "description": "Increase plant spacing, stake plants properly, prune lower leaves, and ensure good ventilation in greenhouses.", + "effectiveness": "high", + "cost_ngn": 0 + }, + { + "method": "Remove infected plant parts", + "description": "Immediately remove and destroy (burn or bury) any infected leaves, stems, flowers, or fruits. Do not compost.", + "effectiveness": "high", + "cost_ngn_per_week": 3000 + }, + { + "method": "Avoid overhead irrigation", + "description": "Use drip irrigation to keep foliage dry. Water early in day so plants dry before evening.", + "effectiveness": "high", + "cost_ngn_per_hectare": 50000, + "notes": "One-time drip system installation cost" + }, + { + "method": "Prune lower leaves", + "description": "Remove leaves touching the ground and lower leaves to improve air flow around plants.", + "effectiveness": "medium", + "cost_ngn_per_week": 5000 + }, + { + "method": "Reduce humidity in greenhouse", + "description": "Ventilate greenhouse, especially in evening. Heat and vent to reduce humidity below 85%.", + "effectiveness": "high", + "cost_ngn": 0 + } + ], + "chemical": [ + { + "product_name": "Fludioxonil", + "active_ingredient": "Fludioxonil", + "local_brands": [ + "Scholar", + "Medallion", + "Geoxe" + ], + "cost_ngn_min": 12000, + "cost_ngn_max": 20000, + "cost_unit": "per hectare per application", + "dosage": "Follow label directions", + "frequency": "Every 7-10 days during humid periods", + "application_method": "Spray to thorough coverage of all plant parts", + "effectiveness": "very_high", + "notes": "One of the most effective fungicides for gray mold" + }, + { + "product_name": "Chlorothalonil", + "active_ingredient": "Chlorothalonil", + "local_brands": [ + "Daconil", + "Bravo", + "Echo" + ], + "cost_ngn_min": 8000, + "cost_ngn_max": 15000, + "cost_unit": "per hectare per application", + "dosage": "2-2.5 L per hectare", + "frequency": "Every 7-14 days", + "application_method": "Apply as preventive spray before disease onset", + "effectiveness": "medium", + "safety_precautions": [ + "Wait 7 days between last spray and harvest", + "Wear protective equipment", + "Do not apply in extreme heat" + ] + }, + { + "product_name": "Iprodione", + "active_ingredient": "Iprodione", + "local_brands": [ + "Rovral", + "Chipco" + ], + "cost_ngn_min": 10000, + "cost_ngn_max": 18000, + "cost_unit": "per hectare per application", + "dosage": "1-1.5 kg per hectare", + "frequency": "Every 10-14 days", + "application_method": "Spray on foliage and stems", + "effectiveness": "high", + "notes": "Rotate with other fungicide classes to prevent resistance" + } + ], + "biological": [ + { + "method": "Bacillus subtilis biofungicide", + "description": "Biological fungicide that colonizes plant surfaces and competes with disease fungi.", + "product_names": [ + "Serenade", + "Cease" + ], + "effectiveness": "medium", + "cost_ngn_per_hectare_min": 10000, + "cost_ngn_per_hectare_max": 18000, + "notes": "Best used preventively. Approved for organic production." + } + ], + "traditional": [ + { + "method": "Neem oil spray", + "description": "Mix 5ml neem oil per liter of water with small amount of liquid soap. Spray weekly as preventive.", + "effectiveness": "low", + "cost_ngn": 5000 + }, + { + "method": "Garlic extract spray", + "description": "Crush 100g garlic, soak in 1 liter water for 24 hours, strain and spray. Has some antifungal properties.", + "effectiveness": "low", + "cost_ngn": 3000 + } + ] + }, + "total_treatment_cost": { + "min_ngn": 8000, + "max_ngn": 25000, + "per": "hectare per application", + "notes": "Prevention through cultural practices (spacing, irrigation method, pruning) is most cost-effective approach" + }, + "prevention": [ + "Maintain good air circulation between plants with proper spacing", + "Use drip irrigation instead of overhead watering", + "Remove plant debris and fallen leaves promptly", + "Prune lower leaves to improve air flow at plant base", + "Avoid working with plants when foliage is wet", + "Sanitize pruning tools with 10% bleach solution between plants", + "Ventilate greenhouses to reduce humidity, especially at night", + "Apply preventive fungicides during cool, humid weather forecasts", + "Avoid excessive nitrogen fertilization which creates dense, soft growth", + "Remove crop debris thoroughly at end of season" + ], + "health_projection": { + "early_detection": { + "recovery_chance_percent": 85, + "message": "Early treatment with fungicide and good sanitation can protect approximately 85% of your crop. Remove all infected parts immediately and improve air circulation." + }, + "moderate_infection": { + "recovery_chance_percent": 60, + "message": "Remove all infected plant parts immediately, apply fungicide, and reduce humidity. With aggressive management, about 60% of crop can be saved." + }, + "severe_infection": { + "recovery_chance_percent": 30, + "message": "Severe gray mold outbreak requires intensive fungicide program and complete removal of infected plants. Expect significant yield reduction this season." + } + } + }, + "tomato_wilt_disease": { + "id": "TWD_001", + "class_name": "Tomato Wilt Disease", + "display_name": "Tomato Wilt Disease", + "scientific_name": "Fusarium oxysporum f. sp. lycopersici (Fusarium wilt) or Ralstonia solanacearum (Bacterial wilt)", + "crop": "tomato", + "category": "fungal_or_bacterial", + "is_disease": true, + "severity": { + "level": "very_high", + "scale": 5, + "max_scale": 5, + "description": "Devastating soil-borne diseases that block water transport in plants. Bacterial wilt (common in tropical Nigeria) can kill plants within days and has no chemical cure." + }, + "symptoms": [ + "Wilting of leaves and stems, often starting on one side of plant", + "Yellowing of lower leaves, progressing upward", + "Wilting during hottest part of day, partial recovery at night (early stage)", + "Permanent wilting that does not recover even with watering", + "Brown discoloration of vascular tissue (cut stem to see brown streaks)", + "Bacterial wilt: milky white bacterial ooze when cut stem is placed in water", + "Stunted growth and eventual plant death", + "Fusarium wilt: symptoms may appear on one side of plant or leaf first" + ], + "how_it_spreads": [ + "Contaminated soil - pathogens survive in soil for many years", + "Infected transplants from nurseries", + "Contaminated water (bacterial wilt spreads easily in irrigation water)", + "Tools and equipment that moved soil between fields", + "Root-to-root contact between plants", + "Nematode damage to roots facilitates infection", + "Workers' boots and clothing carrying contaminated soil" + ], + "favorable_conditions": { + "temperature": "Fusarium: 27-28°C optimal; Bacterial wilt: 30-35°C optimal", + "humidity": "High soil moisture favors bacterial wilt", + "season": "Year-round in Nigeria, worse during rainy season", + "other": "Poor drainage, root damage from nematodes, acidic soil (for Fusarium), continuous cropping" + }, + "yield_loss": { + "min_percent": 30, + "max_percent": 100, + "average_percent": 60, + "description": "Can cause 30-100% losses. Bacterial wilt can destroy an entire field within 2-3 weeks in favorable conditions." + }, + "treatments": { + "cultural": [ + { + "method": "Remove and destroy infected plants", + "description": "Immediately remove wilted plants including roots. Burn or bury at least 1 meter deep away from field. Do not compost.", + "effectiveness": "medium", + "cost_ngn_per_plant": 100 + }, + { + "method": "Long crop rotation", + "description": "Rotate away from tomatoes and related crops (pepper, eggplant, potato) for 4-5 years minimum.", + "effectiveness": "medium", + "cost_ngn": 0, + "notes": "Pathogens can survive in soil for many years" + }, + { + "method": "Improve drainage", + "description": "Plant on raised beds or ridges. Ensure good soil drainage. Avoid waterlogging.", + "effectiveness": "medium", + "cost_ngn_per_hectare": 20000 + }, + { + "method": "Soil solarization", + "description": "Cover moist soil with clear plastic during hottest months for 4-6 weeks. Heat kills pathogens in top soil layer.", + "effectiveness": "medium", + "cost_ngn_per_hectare": 30000 + }, + { + "method": "Use disease-free transplants", + "description": "Purchase transplants only from certified disease-free nurseries. Inspect roots before planting.", + "effectiveness": "high", + "cost_ngn_premium": 5000 + }, + { + "method": "Grafting onto resistant rootstocks", + "description": "Graft susceptible varieties onto wilt-resistant rootstocks. Provides excellent protection.", + "effectiveness": "very_high", + "cost_ngn_per_plant": 150, + "notes": "Labor intensive but very effective" + } + ], + "chemical": [ + { + "product_name": "Soil fumigant (for Fusarium)", + "active_ingredient": "Metam sodium or Dazomet", + "local_brands": [ + "Vapam", + "Basamid" + ], + "cost_ngn_min": 80000, + "cost_ngn_max": 150000, + "cost_unit": "per hectare", + "dosage": "Follow label directions carefully", + "application_method": "Apply to soil before planting, cover with plastic, wait 2-3 weeks before planting", + "effectiveness": "medium", + "important_note": "Expensive and may harm beneficial soil organisms. Not effective against bacterial wilt.", + "safety_precautions": [ + "Highly toxic - requires professional application", + "Keep people and animals away during treatment", + "Follow all waiting periods before planting" + ] + }, + { + "product_name": "Note on bacterial wilt", + "description": "There are NO effective chemical treatments for bacterial wilt. Focus entirely on prevention and resistant varieties.", + "effectiveness": "none" + } + ], + "resistant_varieties": [ + { + "resistance_type": "Fusarium wilt resistant (F, F2, F3)", + "description": "Many commercial varieties have resistance to Fusarium races. Look for F, F2, or F3 on seed packets.", + "effectiveness": "high", + "notes": "Different races exist - variety may resist some but not all" + }, + { + "resistance_type": "Bacterial wilt resistant", + "description": "Few varieties have true resistance. Some have tolerance. Grafting onto resistant rootstocks is most effective.", + "effectiveness": "medium", + "notes": "Check with local extension for recommended varieties" + } + ], + "biological": [ + { + "method": "Trichoderma application", + "description": "Apply Trichoderma-based products to soil before planting. Colonizes roots and provides some protection.", + "effectiveness": "low_to_medium", + "cost_ngn_per_hectare": 15000, + "notes": "Best as part of integrated management, not sole treatment" + }, + { + "method": "Mycorrhizal inoculants", + "description": "Apply mycorrhizal fungi to transplant roots. Improves root health and provides some disease suppression.", + "effectiveness": "low", + "cost_ngn_per_hectare": 10000 + } + ], + "traditional": [ + { + "method": "Organic matter addition", + "description": "Add well-composted organic matter to soil. Improves soil health and beneficial microbe populations.", + "effectiveness": "low", + "cost_ngn_per_hectare": 20000 + }, + { + "method": "Lime application (for Fusarium)", + "description": "Raise soil pH to 6.5-7.0 with agricultural lime. Fusarium prefers acidic soils.", + "effectiveness": "low", + "cost_ngn_per_hectare": 15000 + } + ] + }, + "total_treatment_cost": { + "min_ngn": 20000, + "max_ngn": 150000, + "per": "hectare", + "notes": "Prevention is far more cost-effective than treatment. Once wilt pathogens are in soil, they persist for years. Invest in resistant varieties and grafted transplants." + }, + "prevention": [ + "Plant resistant varieties - most important for Fusarium wilt", + "Use grafted plants with resistant rootstocks - best for bacterial wilt", + "Purchase transplants only from certified disease-free nurseries", + "Practice long crop rotation (4-5 years) away from solanaceous crops", + "Improve soil drainage - plant on raised beds in wet areas", + "Sanitize tools and boots when moving between fields", + "Avoid introducing contaminated soil to clean fields", + "Control root-knot nematodes that facilitate infection", + "Use clean irrigation water - bacterial wilt spreads in water", + "Add organic matter to support beneficial soil microorganisms", + "Solarize soil in heavily infected areas before replanting", + "Never plant in fields with known wilt history without taking precautions" + ], + "health_projection": { + "early_detection": { + "recovery_chance_percent": 50, + "message": "Remove wilted plants immediately to prevent spread. Remaining plants have about 50% chance if action is taken quickly. Do not replant in same location this season." + }, + "moderate_infection": { + "recovery_chance_percent": 25, + "message": "Multiple plants affected indicates pathogen is established in soil. Remove all affected plants. Remaining healthy plants are at high risk. Consider abandoning field for this season." + }, + "severe_infection": { + "recovery_chance_percent": 5, + "message": "Severe wilt outbreak means soil is heavily contaminated. This season's crop is lost. Do not plant tomatoes or related crops in this field for at least 4-5 years. Consider soil solarization before future use." + } + }, + "diagnostic_tip": "To distinguish between Fusarium and Bacterial wilt: Cut a stem and place cut end in clear glass of water. If milky white bacterial streaming appears within minutes, it is Bacterial wilt. Fusarium wilt shows brown vascular discoloration but no bacterial ooze." + } + } +} \ No newline at end of file diff --git a/frontend/.DS_Store b/frontend/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..26d5aeb4f5ea617008bce41e51f467bf9c37fd4c Binary files /dev/null and b/frontend/.DS_Store differ diff --git a/frontend/css/.DS_Store b/frontend/css/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6e03d204b7c23b422dc298ae9a1676999d7547ae Binary files /dev/null and b/frontend/css/.DS_Store differ diff --git a/frontend/css/main.css b/frontend/css/main.css new file mode 100644 index 0000000000000000000000000000000000000000..79b76e34a494dbda89da762008d898338a5c7cd2 --- /dev/null +++ b/frontend/css/main.css @@ -0,0 +1,1451 @@ +/** + * FarmEyes Main Stylesheet + * Modern/Classy design for Diagnosis, ChatGPT-style for Chat only + */ + +/* ========================================================================== + CSS VARIABLES + ========================================================================== */ +:root { + --bg-dark: #0D0D0D; + --bg-card: #1A1A1A; + --bg-elevated: #252525; + --bg-hover: #2D2D2D; + + --text-primary: #FFFFFF; + --text-secondary: #B0B0B0; + --text-muted: #707070; + + --accent: #10B981; + --accent-hover: #34D399; + --accent-muted: rgba(16, 185, 129, 0.15); + + --border: #333333; + --border-light: #2A2A2A; + + --success: #10B981; + --warning: #F59E0B; + --error: #EF4444; + --info: #3B82F6; + + --severity-low: #10B981; + --severity-medium: #F59E0B; + --severity-high: #F97316; + --severity-very-high: #EF4444; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + + --shadow: 0 4px 20px rgba(0,0,0,0.3); + --transition: 200ms ease; +} + +/* ========================================================================== + RESET & BASE + ========================================================================== */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-dark); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; +} + +button { font-family: inherit; cursor: pointer; border: none; background: none; } +input, textarea { font-family: inherit; } + +/* ========================================================================== + LAYOUT + ========================================================================== */ +.app-container { width: 100%; min-height: 100vh; } +.page { display: none; min-height: 100vh; } +.page.active { display: flex; flex-direction: column; } +.hidden { display: none !important; } + +/* ========================================================================== + PAGE 1: LANGUAGE SELECTOR + ========================================================================== */ +.language-page { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + background: linear-gradient(180deg, #0D0D0D 0%, #1A1A1A 100%); +} + +.language-content { max-width: 500px; } + +.logo-large { + font-size: 72px; + margin-bottom: 16px; + animation: float 3s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +.app-title { + font-size: 42px; + font-weight: 700; + color: var(--accent); + margin-bottom: 8px; +} + +.app-tagline { + font-size: 16px; + color: var(--text-secondary); + margin-bottom: 40px; +} + +.language-selection { margin-bottom: 20px; } + +.selection-title { + font-size: 22px; + font-weight: 600; + margin-bottom: 8px; +} + +.selection-subtitle { + font-size: 14px; + color: var(--text-muted); + margin-bottom: 24px; +} + +.language-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 28px; +} + +.language-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px 16px; + background: var(--bg-card); + border: 2px solid var(--border); + border-radius: var(--radius-lg); + transition: all var(--transition); +} + +.language-btn:hover { + border-color: var(--accent); + background: var(--accent-muted); +} + +.language-btn.selected { + border-color: var(--accent); + background: var(--accent-muted); + box-shadow: 0 0 20px rgba(16, 185, 129, 0.2); +} + +.lang-flag { font-size: 28px; } +.lang-name { font-size: 15px; font-weight: 600; color: var(--text-primary); } + +.btn-continue { + width: 100%; + max-width: 280px; + padding: 14px 24px; + font-size: 16px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.page-footer { + position: absolute; + bottom: 20px; + left: 0; + right: 0; + text-align: center; +} + +.page-footer p { + font-size: 12px; + color: var(--text-muted); +} + +/* ========================================================================== + PAGE 2: DIAGNOSIS (Modern/Classy) + ========================================================================== */ +.diagnosis-page { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-dark); +} + +/* Header - STICKY */ +.main-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.header-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.header-logo { font-size: 36px; } + +.header-title { + font-size: 28px; + font-weight: 800; + color: var(--accent); + margin: 0; + letter-spacing: -0.5px; +} + +.header-subtitle { + font-size: 14px; + color: var(--text-secondary); + margin: 4px 0 0 0; +} + +.header-actions { position: relative; } + +.btn-language { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 18px; + background: var(--accent); + border-radius: var(--radius-md); + color: #FFFFFF; + font-size: 15px; + font-weight: 700; + border: 2px solid var(--accent); + transition: var(--transition); +} + +.btn-language:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); + transform: scale(1.02); +} + +.dropdown-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + min-width: 120px; + z-index: 100; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 10px 14px; + text-align: left; + color: var(--text-primary); + font-size: 13px; +} + +.dropdown-item:hover { background: var(--bg-hover); } +.dropdown-item.active { color: var(--accent); } + +/* Main Content */ +.diagnosis-main { + flex: 1; + padding: 20px; + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +/* Upload Section */ +.upload-section { margin-bottom: 20px; } + +.upload-card { + background: var(--bg-card); + border-radius: var(--radius-lg); + padding: 24px; + text-align: center; +} + +.upload-header { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 8px; +} + +.upload-icon-small { font-size: 24px; } + +.upload-header h2 { + font-size: 20px; + font-weight: 600; +} + +.upload-desc { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 20px; +} + +.upload-zone { + border: 2px dashed var(--border); + border-radius: var(--radius-md); + padding: 32px 20px; + cursor: pointer; + transition: all var(--transition); + margin-bottom: 16px; +} + +.upload-zone:hover, .upload-zone.dragover { + border-color: var(--accent); + background: var(--accent-muted); +} + +.upload-icon { margin-bottom: 12px; color: var(--text-muted); } + +.upload-text { + font-size: 15px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 4px; +} + +.upload-formats { + font-size: 12px; + color: var(--text-muted); +} + +.image-preview-container { + position: relative; + margin-bottom: 16px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-elevated); +} + +.image-preview { + width: 100%; + max-height: 250px; + object-fit: contain; +} + +.btn-remove-image { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + background: rgba(0,0,0,0.7); + color: #fff; + border-radius: 50%; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-remove-image:hover { background: var(--error); } + +.btn-analyze { + width: 100%; + padding: 14px; + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.analyzing-loader { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 20px; +} + +.analyzing-loader p { + font-size: 14px; + color: var(--text-secondary); +} + +.supported-crops { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +.crop-tag { + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-elevated); + padding: 6px 12px; + border-radius: 20px; +} + +/* Results Section */ +.results-section { animation: fadeIn 0.3s ease; } + +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.results-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.results-header h2 { + font-size: 18px; + font-weight: 600; +} + +.btn-text { + font-size: 13px; + color: var(--accent); + font-weight: 500; +} + +.btn-text:hover { text-decoration: underline; } + +/* Disease Card */ +.disease-card { + background: var(--bg-card); + border-radius: var(--radius-lg); + padding: 16px; + margin-bottom: 16px; +} + +.disease-top { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.disease-icon { + font-size: 36px; + width: 50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated); + border-radius: var(--radius-md); +} + +.disease-info { flex: 1; } + +.disease-info h3 { + font-size: 17px; + font-weight: 600; + margin-bottom: 2px; +} + +.crop-label { + font-size: 13px; + color: var(--text-secondary); + text-transform: capitalize; +} + +.severity-badge { + padding: 5px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} + +.severity-badge.low { background: rgba(16,185,129,0.15); color: var(--severity-low); } +.severity-badge.medium { background: rgba(245,158,11,0.15); color: var(--severity-medium); } +.severity-badge.high { background: rgba(249,115,22,0.15); color: var(--severity-high); } +.severity-badge.very-high, .severity-badge.very_high { background: rgba(239,68,68,0.15); color: var(--severity-very-high); } + +.disease-confidence { + display: flex; + align-items: center; + gap: 10px; +} + +.conf-label { + font-size: 13px; + color: var(--text-secondary); +} + +.conf-bar-wrap { + flex: 1; + height: 8px; + background: var(--bg-elevated); + border-radius: 4px; + overflow: hidden; +} + +.conf-bar { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 4px; + transition: width 0.5s ease; +} + +.conf-value { + font-size: 14px; + font-weight: 600; + color: var(--accent); + min-width: 40px; + text-align: right; +} + +/* Info Card / Tabs */ +.info-card { + background: var(--bg-card); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 16px; +} + +.tabs { + display: flex; + background: var(--bg-elevated); + padding: 4px; + gap: 4px; +} + +.tab-btn { + flex: 1; + padding: 10px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: all var(--transition); +} + +.tab-btn:hover { color: var(--text-primary); } +.tab-btn.active { background: var(--bg-card); color: var(--text-primary); } + +.tab-content { padding: 16px; } + +.info-list { + list-style: none; + margin: 0 0 16px 0; +} + +.info-list li { + position: relative; + padding-left: 16px; + margin-bottom: 10px; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; +} + +.info-list li::before { + content: ""; + position: absolute; + left: 0; + top: 7px; + width: 6px; + height: 6px; + background: var(--accent); + border-radius: 50%; +} + +.info-block { + margin-bottom: 16px; +} + +.info-block h4 { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--text-primary); +} + +.info-block p { + font-size: 14px; + color: var(--text-secondary); +} + +.recovery-block { + background: var(--bg-elevated); + padding: 12px; + border-radius: var(--radius-md); +} + +.recovery-bar-wrap { + height: 10px; + background: var(--bg-dark); + border-radius: 5px; + overflow: hidden; + margin-bottom: 6px; +} + +.recovery-bar { + height: 100%; + background: var(--success); + border-radius: 5px; + transition: width 0.5s ease; +} + +.recovery-block span { + font-size: 13px; + color: var(--success); +} + +.treatment-grid { + display: flex; + flex-direction: column; + gap: 8px; +} + +.treatment-item { + background: var(--bg-elevated); + padding: 10px 12px; + border-radius: var(--radius-sm); +} + +.treatment-item strong { + font-size: 13px; + display: block; + margin-bottom: 2px; +} + +.treatment-item span { + font-size: 12px; + color: var(--text-secondary); +} + +.cost-block { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--accent-muted); + padding: 12px 16px; + border-radius: var(--radius-md); + margin-top: 12px; +} + +.cost-label { + font-size: 14px; + color: var(--text-secondary); +} + +.cost-value { + font-size: 18px; + font-weight: 700; + color: var(--accent); +} + +/* Chat Button (Simple but visible) */ +.btn-chat { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 14px; + background: var(--accent); + color: #fff; + font-size: 15px; + font-weight: 600; + border-radius: var(--radius-md); + transition: all var(--transition); +} + +.btn-chat:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +/* Footer */ +.main-footer { + padding: 16px; + text-align: center; + border-top: 1px solid var(--border-light); +} + +.main-footer p { + font-size: 12px; + color: var(--text-muted); +} + +/* ========================================================================== + PAGE 3: CHAT (ChatGPT-Inspired) + ========================================================================== */ +.chat-page { + height: 100vh; + display: flex; + flex-direction: column; + background: #0D0D0D; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #1A1A1A; + border-bottom: 1px solid #2D2D2D; +} + +.btn-back { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 14px; + padding: 6px 10px; + border-radius: var(--radius-sm); +} + +.btn-back:hover { background: #2D2D2D; color: #fff; } + +.chat-title { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; +} + +.chat-lang { + font-size: 12px; + color: var(--text-muted); + background: #2D2D2D; + padding: 4px 10px; + border-radius: 12px; +} + +.chat-context { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: rgba(16,185,129,0.08); + font-size: 13px; + color: var(--text-secondary); + border-bottom: 1px solid #2D2D2D; + flex-wrap: wrap; +} + +.chat-context strong { color: var(--accent); } + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.chat-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + padding: 40px 20px; +} + +.welcome-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.6; +} + +.chat-welcome h3 { + font-size: 18px; + margin-bottom: 8px; +} + +.chat-welcome p { + font-size: 14px; + color: var(--text-muted); + max-width: 300px; +} + +/* Chat Messages */ +.message { + display: flex; + gap: 12px; + margin-bottom: 16px; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.message.user { flex-direction: row-reverse; } + +.message-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.message.assistant .message-avatar { background: var(--accent); } +.message.user .message-avatar { background: #4B5563; } + +.message-content { + max-width: 80%; + padding: 12px 16px; + border-radius: 16px; + font-size: 14px; + line-height: 1.5; +} + +/* Message content wrapper for Listen button */ +.message-content-wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + max-width: 80%; +} + +.message.user .message-content-wrapper { + align-items: flex-end; +} + +.message-content-wrapper .message-content { + max-width: 100%; +} + +.message.assistant .message-content { + background: #2D2D2D; + border-bottom-left-radius: 4px; +} + +.message.user .message-content { + background: var(--accent); + color: #fff; + border-bottom-right-radius: 4px; +} + +.typing-indicator { + display: flex; + gap: 4px; + padding: 8px 0; +} + +.typing-dot { + width: 8px; + height: 8px; + background: var(--text-muted); + border-radius: 50%; + animation: bounce 1.4s infinite; +} + +.typing-dot:nth-child(2) { animation-delay: 0.2s; } +.typing-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-6px); } +} + +/* Chat Input */ +.chat-input-wrap { + padding: 12px 16px; + background: #1A1A1A; + border-top: 1px solid #2D2D2D; +} + +.chat-input-box { + display: flex; + align-items: flex-end; + gap: 8px; + background: #2D2D2D; + border-radius: 12px; + padding: 8px 12px; +} + +.chat-input-box textarea { + flex: 1; + background: transparent; + border: none; + color: #fff; + font-size: 14px; + resize: none; + min-height: 24px; + max-height: 120px; + padding: 4px 0; +} + +.chat-input-box textarea:focus { outline: none; } +.chat-input-box textarea::placeholder { color: #6B6B6B; } + +.btn-icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + color: var(--text-secondary); + transition: all var(--transition); +} + +.btn-icon:hover { background: #3D3D3D; color: #fff; } + +.btn-voice.recording { + background: var(--error); + color: #fff; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.btn-send { + background: var(--accent); + color: #fff; +} + +.btn-send:disabled { + background: #3D3D3D; + color: #6B6B6B; + cursor: not-allowed; +} + +.btn-send:not(:disabled):hover { background: var(--accent-hover); } + +.chat-note { + font-size: 11px; + color: var(--text-muted); + text-align: center; + margin-top: 8px; +} + +/* Voice Overlay - LEGACY (hidden, replaced by inline indicator) */ +.voice-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.voice-modal { + text-align: center; + padding: 40px; +} + +.voice-anim { + display: flex; + justify-content: center; + gap: 8px; + margin-bottom: 20px; +} + +.voice-anim span { + width: 12px; + height: 12px; + background: var(--accent); + border-radius: 50%; + animation: voicePulse 1.2s infinite; +} + +.voice-anim span:nth-child(2) { animation-delay: 0.2s; } +.voice-anim span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes voicePulse { + 0%, 100% { transform: scale(1); opacity: 0.5; } + 50% { transform: scale(1.4); opacity: 1; } +} + +.voice-modal p { + font-size: 18px; + margin-bottom: 20px; +} + +/* ========================================================================== + INLINE LISTENING INDICATOR (New - replaces full-screen overlay) + ========================================================================== */ +.listening-indicator { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + padding: 8px 12px; + background: rgba(239, 68, 68, 0.15); + border: 1px solid var(--error); + border-radius: 8px; + animation: listenFadeIn 0.3s ease; +} + +@keyframes listenFadeIn { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +/* Pulsing red dot */ +.listening-pulse { + width: 12px; + height: 12px; + background: var(--error); + border-radius: 50%; + animation: listenPulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes listenPulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); + } + 50% { + transform: scale(1.1); + opacity: 0.8; + box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); + } +} + +/* Listening text */ +.listening-text { + font-size: 15px; + font-weight: 600; + color: var(--error); + flex: 1; +} + +/* Timer display */ +.listening-timer { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + background: rgba(255, 255, 255, 0.1); + padding: 4px 10px; + border-radius: 6px; + min-width: 50px; + text-align: center; +} + +/* Inline stop button */ +.btn-stop-inline { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--error); + color: #fff; + border-radius: 8px; + transition: all var(--transition); + flex-shrink: 0; +} + +.btn-stop-inline:hover { + background: #DC2626; + transform: scale(1.05); +} + +/* Hide elements when recording */ +.chat-input-box textarea.hidden, +.chat-input-box .btn-send.hidden { + display: none; +} + +/* Voice button recording state */ +.btn-voice.recording { + background: var(--error); + color: #fff; + animation: none; /* Remove pulse, we have the indicator now */ +} + +/* ========================================================================== + COMMON COMPONENTS + ========================================================================== */ + +/* ========================================================================== + TTS - LISTEN BUTTON & AUDIO PLAYER + ========================================================================== */ + +/* Listen button on each assistant message */ +.btn-listen { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 10px; + padding: 8px 14px; + background: var(--accent); + color: #fff; + font-size: 13px; + font-weight: 600; + border-radius: 20px; + cursor: pointer; + transition: all var(--transition); + border: none; +} + +.btn-listen:hover { + background: var(--accent-hover); + transform: scale(1.02); +} + +.btn-listen.loading { + background: var(--bg-elevated); + color: var(--text-secondary); + cursor: wait; +} + +.btn-listen.loading::after { + content: ''; + width: 12px; + height: 12px; + border: 2px solid var(--text-muted); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: 6px; +} + +.btn-listen.playing { + background: var(--error); +} + +.btn-listen-icon { + font-size: 14px; +} + +/* Floating TTS Player */ +.tts-player { + position: fixed; + bottom: 100px; + left: 50%; + transform: translateX(-50%) translateY(100px); + width: 90%; + max-width: 400px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + z-index: 150; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.tts-player.active { + transform: translateX(-50%) translateY(0); + opacity: 1; + visibility: visible; +} + +/* Player header */ +.tts-player-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.tts-player-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.tts-player-title-icon { + font-size: 16px; +} + +.btn-tts-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-hover); + color: var(--text-secondary); + border-radius: 6px; + font-size: 18px; + cursor: pointer; + transition: all var(--transition); +} + +.btn-tts-close:hover { + background: var(--error); + color: #fff; +} + +/* Progress bar */ +.tts-progress-container { + height: 6px; + background: var(--bg-hover); + border-radius: 3px; + overflow: hidden; + margin-bottom: 12px; + cursor: pointer; +} + +.tts-progress-bar { + height: 100%; + background: var(--accent); + border-radius: 3px; + width: 0%; + transition: width 0.1s linear; +} + +/* Controls row */ +.tts-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +/* Playback controls */ +.tts-playback-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.btn-tts-control { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent); + color: #fff; + border-radius: 50%; + cursor: pointer; + transition: all var(--transition); +} + +.btn-tts-control:hover { + background: var(--accent-hover); + transform: scale(1.05); +} + +.btn-tts-control.stop { + background: var(--bg-hover); + color: var(--text-secondary); + width: 36px; + height: 36px; +} + +.btn-tts-control.stop:hover { + background: var(--error); + color: #fff; +} + +/* Time display */ +.tts-time { + font-size: 12px; + color: var(--text-muted); + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + min-width: 70px; + text-align: center; +} + +/* Speed controls */ +.tts-speed-controls { + display: flex; + align-items: center; + gap: 4px; +} + +.tts-speed-label { + font-size: 11px; + color: var(--text-muted); + margin-right: 4px; +} + +.tts-speed-btn { + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + background: var(--bg-hover); + border-radius: 4px; + cursor: pointer; + transition: all var(--transition); +} + +.tts-speed-btn:hover { + color: var(--text-primary); + background: var(--bg-card); +} + +.tts-speed-btn.active { + color: #fff; + background: var(--accent); +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .tts-player { + bottom: 80px; + width: 95%; + padding: 12px; + } + + .tts-speed-label { + display: none; + } + + .tts-time { + font-size: 11px; + min-width: 60px; + } +} + +/* Buttons */ +.btn-primary { + background: var(--accent); + color: #fff; + border-radius: var(--radius-md); + font-weight: 600; + transition: all var(--transition); +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + background: #2D2D2D; + color: #fff; + padding: 12px 24px; + border-radius: var(--radius-md); + font-weight: 500; +} + +.btn-secondary:hover { background: #3D3D3D; } + +/* Loader */ +.loader-spinner { + width: 24px; + height: 24px; + border: 3px solid #3D3D3D; + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.loader-spinner.large { width: 40px; height: 40px; } + +@keyframes spin { to { transform: rotate(360deg); } } + +/* Loading Overlay */ +.loading-overlay { + position: fixed; + inset: 0; + background: rgba(13,13,13,0.95); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + z-index: 300; +} + +.loading-overlay p { + color: var(--text-secondary); + font-size: 14px; +} + +/* Toast */ +.toast-container { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 400; + width: 90%; + max-width: 360px; +} + +.toast { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: #2D2D2D; + border-radius: var(--radius-md); + margin-bottom: 8px; + animation: slideUp 0.3s ease; +} + +.toast.success { border-left: 4px solid var(--success); } +.toast.error { border-left: 4px solid var(--error); } +.toast.warning { border-left: 4px solid var(--warning); } + +.toast-message { flex: 1; font-size: 14px; } + +.toast-close { + color: var(--text-muted); + font-size: 18px; + cursor: pointer; +} + +/* ========================================================================== + RESPONSIVE + ========================================================================== */ +@media (max-width: 480px) { + .header-subtitle { display: none; } + .header-title { font-size: 18px; } + .app-title { font-size: 36px; } + .language-grid { gap: 10px; } + .language-btn { padding: 16px 12px; } +} + +@media (min-width: 768px) { + .diagnosis-main { max-width: 900px; padding: 30px; } + .upload-card { padding: 32px; } + .language-grid { grid-template-columns: repeat(4, 1fr); } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..274c2d38673023c096c7bd0e788d4597ca75199e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,321 @@ + + + + + + + + + + + FarmEyes - Crop Disease Detection + + + + + + + +
+ + + + +
+
+
+
🌱
+

FarmEyes

+

AI-Powered Crop Disease Detection for African Farmers

+ +
+

Select Your Language

+

Choose your preferred language to continue

+ +
+ + + + + +
+ + +
+
+ +
+

Designed with AI Powered N-ATLaS Language Platform

+
+
+
+ + + + +
+
+ +
+
+ +
+

FarmEyes

+

AI-Powered Crop Disease Detection for African Farmers

+
+
+
+ + +
+
+ + +
+ +
+
+
+ 📸 +

Upload Crop Image

+
+

Take a clear photo of the affected leaf or plant

+ +
+ +
+ + + +
+

Click or drag image here

+ JPG, PNG, WEBP (max 10MB) +
+ + + + + + + +
+ 🌿 Cassava + 🍫 Cocoa + 🍅 Tomato +
+
+
+ + + +
+ + +
+

Designed with AI Powered N-ATLaS Language Platform

+
+
+
+ + + + +
+
+
+ +
+ 🌱 FarmEyes Assistant +
+ EN +
+ +
+ Discussing: + Disease + + 0% + + Severity +
+ +
+
+
🌱
+

FarmEyes Assistant

+

Ask me anything about your diagnosis, treatments, or prevention tips.

+
+
+ +
+
+ + + +
+

FarmEyes provides guidance only. Consult experts for serious cases.

+
+ + +
+
+ + + + + +
+
+ + + + + + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000000000000000000000000000000000000..58114675444f3ed223e8838537fe52ca0543385c --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,417 @@ +/** + * FarmEyes API Client + * =================== + * Handles all communication with the FastAPI backend. + * Provides clean async methods for detection, chat, and transcription. + */ + +const FarmEyesAPI = { + // Base URL - auto-detect based on environment + baseUrl: window.location.origin, + + // Current session ID + sessionId: null, + + // Current language + language: 'en', + + /** + * Initialize API client + */ + async init() { + // Try to get existing session from storage + this.sessionId = localStorage.getItem('farmeyes_session'); + this.language = localStorage.getItem('farmeyes_language') || 'en'; + + // Create new session if none exists + if (!this.sessionId) { + await this.createSession(this.language); + } + + console.log('[API] Initialized with session:', this.sessionId?.substring(0, 8)); + return this; + }, + + /** + * Make an API request + * @param {string} endpoint - API endpoint + * @param {object} options - Fetch options + * @returns {Promise} Response data + */ + async request(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + + const defaultOptions = { + headers: { + 'Accept': 'application/json', + }, + }; + + // Merge options + const fetchOptions = { ...defaultOptions, ...options }; + + // Add Content-Type for JSON body + if (options.body && !(options.body instanceof FormData)) { + fetchOptions.headers['Content-Type'] = 'application/json'; + fetchOptions.body = JSON.stringify(options.body); + } + + try { + const response = await fetch(url, fetchOptions); + + // Handle non-JSON responses + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return { success: true }; + } + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.detail || data.error || `HTTP ${response.status}`); + } + + return data; + } catch (error) { + console.error('[API] Request failed:', endpoint, error); + throw error; + } + }, + + // ========================================================================= + // SESSION MANAGEMENT + // ========================================================================= + + /** + * Create a new session + * @param {string} language - Language code + * @returns {Promise} Session data + */ + async createSession(language = 'en') { + const data = await this.request(`/api/session?language=${language}`); + + if (data.success && data.session_id) { + this.sessionId = data.session_id; + this.language = language; + localStorage.setItem('farmeyes_session', this.sessionId); + localStorage.setItem('farmeyes_language', language); + console.log('[API] Session created:', this.sessionId.substring(0, 8)); + } + + return data; + }, + + /** + * Get session info + * @returns {Promise} Session info + */ + async getSession() { + if (!this.sessionId) { + return { success: false, error: 'No session' }; + } + return this.request(`/api/session/${this.sessionId}`); + }, + + /** + * Update session language + * @param {string} language - New language code + * @returns {Promise} Updated session + */ + async setLanguage(language) { + if (!this.sessionId) { + await this.createSession(language); + return { success: true }; + } + + const data = await this.request(`/api/session/${this.sessionId}/language?language=${language}`, { + method: 'PUT' + }); + + if (data.success) { + this.language = language; + localStorage.setItem('farmeyes_language', language); + } + + return data; + }, + + /** + * Clear current session and create new one + * @returns {Promise} New session data + */ + async resetSession() { + if (this.sessionId) { + try { + await this.request(`/api/session/${this.sessionId}`, { method: 'DELETE' }); + } catch (e) { + // Ignore errors on delete + } + } + + localStorage.removeItem('farmeyes_session'); + this.sessionId = null; + + return this.createSession(this.language); + }, + + // ========================================================================= + // DISEASE DETECTION + // ========================================================================= + + /** + * Analyze crop image for disease detection + * @param {File} imageFile - Image file to analyze + * @param {string} language - Language for results + * @returns {Promise} Detection results + */ + async detectDisease(imageFile, language = null) { + const formData = new FormData(); + formData.append('file', imageFile); + formData.append('language', language || this.language); + formData.append('session_id', this.sessionId || ''); + + const data = await this.request('/api/detect/', { + method: 'POST', + body: formData + }); + + // Update session ID if returned + if (data.session_id) { + this.sessionId = data.session_id; + localStorage.setItem('farmeyes_session', this.sessionId); + } + + return data; + }, + + /** + * Analyze base64 encoded image + * @param {string} base64Image - Base64 encoded image + * @param {string} language - Language for results + * @returns {Promise} Detection results + */ + async detectDiseaseBase64(base64Image, language = null) { + const data = await this.request('/api/detect/base64', { + method: 'POST', + body: { + image_base64: base64Image, + language: language || this.language, + session_id: this.sessionId + } + }); + + if (data.session_id) { + this.sessionId = data.session_id; + localStorage.setItem('farmeyes_session', this.sessionId); + } + + return data; + }, + + /** + * Get detection service status + * @returns {Promise} Service status + */ + async getDetectionStatus() { + return this.request('/api/detect/status'); + }, + + /** + * Get supported disease classes + * @returns {Promise} Classes info + */ + async getClasses() { + return this.request('/api/detect/classes'); + }, + + /** + * Clear current diagnosis + * @returns {Promise} Result + */ + async clearDiagnosis() { + if (!this.sessionId) { + return { success: false, error: 'No session' }; + } + return this.request(`/api/detect/session/${this.sessionId}`, { + method: 'DELETE' + }); + }, + + // ========================================================================= + // CHAT + // ========================================================================= + + /** + * Send chat message + * @param {string} message - User message + * @param {string} language - Response language + * @returns {Promise} Chat response + */ + async sendChatMessage(message, language = null) { + if (!this.sessionId) { + await this.createSession(language || this.language); + } + + return this.request('/api/chat/', { + method: 'POST', + body: { + session_id: this.sessionId, + message: message, + language: language || this.language + } + }); + }, + + /** + * Get welcome message for chat + * @param {string} language - Language code + * @returns {Promise} Welcome message + */ + async getChatWelcome(language = null) { + if (!this.sessionId) { + return { success: false, error: 'No session' }; + } + + const lang = language || this.language; + return this.request(`/api/chat/welcome?session_id=${this.sessionId}&language=${lang}`); + }, + + /** + * Get chat history + * @param {number} limit - Max messages to return + * @returns {Promise} Chat history + */ + async getChatHistory(limit = 50) { + if (!this.sessionId) { + return { success: false, messages: [] }; + } + + return this.request(`/api/chat/history?session_id=${this.sessionId}&limit=${limit}`); + }, + + /** + * Clear chat history + * @returns {Promise} Result + */ + async clearChatHistory() { + if (!this.sessionId) { + return { success: false }; + } + + return this.request(`/api/chat/history?session_id=${this.sessionId}`, { + method: 'DELETE' + }); + }, + + /** + * Get current diagnosis context + * @returns {Promise} Diagnosis context + */ + async getChatContext() { + if (!this.sessionId) { + return { success: false }; + } + + return this.request(`/api/chat/context?session_id=${this.sessionId}`); + }, + + // ========================================================================= + // VOICE TRANSCRIPTION + // ========================================================================= + + /** + * Transcribe audio file + * @param {File|Blob} audioFile - Audio file to transcribe + * @param {string} languageHint - Language hint + * @returns {Promise} Transcription result + */ + async transcribeAudio(audioFile, languageHint = null) { + const formData = new FormData(); + formData.append('file', audioFile, audioFile.name || 'audio.wav'); + + if (languageHint) { + formData.append('language_hint', languageHint); + } + + return this.request('/api/transcribe/', { + method: 'POST', + body: formData + }); + }, + + /** + * Transcribe base64 audio + * @param {string} base64Audio - Base64 encoded audio + * @param {string} filename - Original filename + * @param {string} languageHint - Language hint + * @returns {Promise} Transcription result + */ + async transcribeBase64(base64Audio, filename = 'audio.wav', languageHint = null) { + return this.request('/api/transcribe/base64', { + method: 'POST', + body: { + audio_base64: base64Audio, + filename: filename, + language_hint: languageHint + } + }); + }, + + /** + * Get transcription service status + * @returns {Promise} Service status + */ + async getTranscriptionStatus() { + return this.request('/api/transcribe/status'); + }, + + /** + * Pre-load Whisper model + * @returns {Promise} Result + */ + async loadWhisperModel() { + return this.request('/api/transcribe/load-model', { + method: 'POST' + }); + }, + + // ========================================================================= + // UI TRANSLATIONS + // ========================================================================= + + /** + * Get UI translations + * @param {string} language - Language code + * @returns {Promise} Translations + */ + async getTranslations(language = null) { + const lang = language || this.language; + return this.request(`/api/translations?language=${lang}`); + }, + + // ========================================================================= + // HEALTH CHECK + // ========================================================================= + + /** + * Check API health + * @returns {Promise} Health status + */ + async healthCheck() { + return this.request('/health'); + }, + + /** + * Get API info + * @returns {Promise} API information + */ + async getApiInfo() { + return this.request('/api'); + } +}; + +// Export for use in other modules +window.FarmEyesAPI = FarmEyesAPI; diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000000000000000000000000000000000000..b6fc11187de4081c0aa4c5c600a1e320a8d5cc25 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,274 @@ +/** + * FarmEyes Main Application + * ========================= + * Main controller - navigation and language handling. + */ + +const App = { + currentPage: 'language', + selectedLanguage: null, + isInitialized: false, + elements: {}, + + /** + * Initialize + */ + async init() { + console.log('[App] Initializing FarmEyes...'); + + this.cacheElements(); + + await FarmEyesAPI.init(); + await I18n.init('en'); + + this.bindEvents(); + + Diagnosis.init(); + Chat.init(); + + // Always start with language page + this.navigateToPage('language'); + + this.isInitialized = true; + console.log('[App] Ready!'); + }, + + /** + * Cache DOM elements + */ + cacheElements() { + this.elements = { + pageLanguage: document.getElementById('page-language'), + pageDiagnosis: document.getElementById('page-diagnosis'), + pageChat: document.getElementById('page-chat'), + + languageButtons: document.querySelectorAll('.language-btn'), + btnContinue: document.getElementById('btn-continue-language'), + + btnLanguageToggle: document.getElementById('btn-language-toggle'), + currentLangDisplay: document.getElementById('current-lang-display'), + languageMenu: document.getElementById('language-menu'), + languageDropdownItems: document.querySelectorAll('#language-menu .dropdown-item'), + + loadingOverlay: document.getElementById('loading-overlay'), + loadingText: document.getElementById('loading-text'), + toastContainer: document.getElementById('toast-container') + }; + }, + + /** + * Bind events + */ + bindEvents() { + // Language selector buttons + this.elements.languageButtons.forEach(btn => { + btn.addEventListener('click', () => this.selectLanguage(btn.dataset.lang)); + }); + + // Continue button + this.elements.btnContinue?.addEventListener('click', () => this.onLanguageContinue()); + + // Language dropdown + this.elements.btnLanguageToggle?.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleLanguageMenu(); + }); + + this.elements.languageDropdownItems.forEach(item => { + item.addEventListener('click', () => this.changeLanguage(item.dataset.lang)); + }); + + document.addEventListener('click', () => this.closeLanguageMenu()); + + // Back button in chat + document.getElementById('btn-back-diagnosis')?.addEventListener('click', () => { + this.navigateToDiagnosis(); + }); + + window.addEventListener('languageChanged', (e) => { + this.onLanguageChanged(e.detail.language); + }); + }, + + /** + * Select language on language page + */ + selectLanguage(lang) { + this.selectedLanguage = lang; + + this.elements.languageButtons.forEach(btn => { + btn.classList.toggle('selected', btn.dataset.lang === lang); + }); + + if (this.elements.btnContinue) { + this.elements.btnContinue.disabled = false; + } + + console.log('[App] Language selected:', lang); + }, + + /** + * Continue after language selection + */ + async onLanguageContinue() { + if (!this.selectedLanguage) return; + + this.showLoading('Setting up...'); + + try { + await I18n.setLanguage(this.selectedLanguage); + this.navigateToDiagnosis(); + } catch (error) { + console.error('[App] Language setup failed:', error); + this.showToast('Failed to set language', 'error'); + } finally { + this.hideLoading(); + } + }, + + /** + * Change language from dropdown + */ + async changeLanguage(lang) { + if (lang === I18n.getLanguage()) { + this.closeLanguageMenu(); + return; + } + + this.closeLanguageMenu(); + this.showLoading('Changing language...'); + + try { + await I18n.setLanguage(lang); + } catch (error) { + console.error('[App] Language change failed:', error); + this.showToast('Failed to change language', 'error'); + } finally { + this.hideLoading(); + } + }, + + /** + * Handle language change event + */ + onLanguageChanged(language) { + // Update header display + if (this.elements.currentLangDisplay) { + this.elements.currentLangDisplay.textContent = language.toUpperCase(); + } + + // Update chat display + const chatLangDisplay = document.getElementById('chat-lang-display'); + if (chatLangDisplay) { + chatLangDisplay.textContent = language.toUpperCase(); + } + + // Update dropdown active state + this.elements.languageDropdownItems.forEach(item => { + item.classList.toggle('active', item.dataset.lang === language); + }); + + console.log('[App] Language updated to:', language); + }, + + toggleLanguageMenu() { + this.elements.languageMenu?.classList.toggle('hidden'); + }, + + closeLanguageMenu() { + this.elements.languageMenu?.classList.add('hidden'); + }, + + /** + * Navigate to page + */ + navigateToPage(pageName) { + const pages = ['language', 'diagnosis', 'chat']; + if (!pages.includes(pageName)) return; + + // Hide all + this.elements.pageLanguage?.classList.remove('active'); + this.elements.pageDiagnosis?.classList.remove('active'); + this.elements.pageChat?.classList.remove('active'); + + // Show target + const target = document.getElementById(`page-${pageName}`); + target?.classList.add('active'); + + // Lifecycle + if (this.currentPage === 'chat' && pageName !== 'chat') { + Chat.onPageLeave?.(); + } + if (pageName === 'chat') { + Chat.onPageEnter?.(); + } + + this.currentPage = pageName; + console.log('[App] Page:', pageName); + }, + + navigateToDiagnosis() { + this.navigateToPage('diagnosis'); + }, + + navigateToChat() { + if (!Diagnosis.hasDiagnosis()) { + this.showToast('Please analyze an image first', 'warning'); + return; + } + this.navigateToPage('chat'); + }, + + /** + * Loading overlay + */ + showLoading(message = 'Loading...') { + if (this.elements.loadingText) { + this.elements.loadingText.textContent = message; + } + this.elements.loadingOverlay?.classList.remove('hidden'); + }, + + hideLoading() { + this.elements.loadingOverlay?.classList.add('hidden'); + }, + + /** + * Toast notifications + */ + showToast(message, type = 'info', duration = 4000) { + const container = this.elements.toastContainer; + if (!container) return; + + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = ` + ${message} + + `; + + container.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => toast.remove(), 300); + }, duration); + }, + + getCurrentPage() { + return this.currentPage; + }, + + isReady() { + return this.isInitialized; + } +}; + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', () => { + App.init().catch(error => { + console.error('[App] Init failed:', error); + }); +}); + +window.App = App; diff --git a/frontend/js/chat.js b/frontend/js/chat.js new file mode 100644 index 0000000000000000000000000000000000000000..60e7cadbcf62dd48ff640280f9d41401680ac737 --- /dev/null +++ b/frontend/js/chat.js @@ -0,0 +1,766 @@ +/** + * FarmEyes Chat Module + * ==================== + * Handles the chat interface, message sending, and voice input. + * + * Updated: Inline "Listening..." indicator with timer (no full-screen overlay) + * Updated: TTS Listen button on assistant messages + */ + +const Chat = { + // State + isLoading: false, + messages: [], + + // Voice recording state + recordingTimer: null, + recordingSeconds: 0, + + // Message ID counter for TTS + messageIdCounter: 0, + + // DOM Elements + elements: {}, + + /** + * Initialize chat module + */ + init() { + this.cacheElements(); + this.bindEvents(); + this.initVoiceInput(); + this.initTTS(); + this.createTTSPlayer(); + console.log('[Chat] Initialized'); + }, + + /** + * Cache DOM elements + */ + cacheElements() { + this.elements = { + // Header + btnBack: document.getElementById('btn-back-diagnosis'), + btnLanguage: document.getElementById('btn-chat-language'), + chatLangDisplay: document.getElementById('chat-lang-display'), + + // Context banner + contextBanner: document.getElementById('chat-context-banner'), + contextDiseaseName: document.getElementById('context-disease-name'), + contextConfidence: document.getElementById('context-confidence'), + contextSeverity: document.getElementById('context-severity'), + + // Messages + messagesContainer: document.getElementById('chat-messages'), + chatWelcome: document.getElementById('chat-welcome'), + + // Input + chatInput: document.getElementById('chat-input'), + btnVoice: document.getElementById('btn-voice-input'), + btnSend: document.getElementById('btn-send-message'), + chatInputBox: document.querySelector('.chat-input-box'), + + // Voice overlay (keep reference but won't use full-screen) + voiceOverlay: document.getElementById('voice-overlay'), + btnStopVoice: document.getElementById('btn-stop-voice') + }; + }, + + /** + * Bind event handlers + */ + bindEvents() { + const { btnBack, chatInput, btnVoice, btnSend, btnStopVoice } = this.elements; + + // Back button + btnBack?.addEventListener('click', () => App.navigateToDiagnosis()); + + // Input events + chatInput?.addEventListener('input', () => this.handleInputChange()); + chatInput?.addEventListener('keydown', (e) => this.handleKeyDown(e)); + + // Send button + btnSend?.addEventListener('click', () => this.sendMessage()); + + // Voice buttons + btnVoice?.addEventListener('click', () => this.toggleVoiceRecording()); + btnStopVoice?.addEventListener('click', () => this.stopVoiceRecording()); + + // Auto-resize input + chatInput?.addEventListener('input', () => this.autoResizeInput()); + }, + + /** + * Initialize voice input + */ + initVoiceInput() { + VoiceInput.init({ + onTranscription: (text, result) => { + this.handleVoiceTranscription(text, result); + }, + onError: (error) => { + App.showToast(error, 'error'); + this.hideListeningIndicator(); + }, + onRecordingStart: () => { + this.showListeningIndicator(); + }, + onRecordingStop: () => { + this.hideListeningIndicator(); + } + }); + }, + + /** + * Initialize TTS (Text-to-Speech) + */ + initTTS() { + TTS.init({ + onPlayStart: () => { + console.log('[Chat] TTS playback started'); + }, + onPlayEnd: () => { + console.log('[Chat] TTS playback ended'); + this.updateListenButtons(); + }, + onError: (error) => { + App.showToast(error, 'error'); + this.updateListenButtons(); + } + }); + }, + + /** + * Create floating TTS player element + */ + createTTSPlayer() { + // Check if player already exists + if (document.getElementById('tts-player')) return; + + const player = document.createElement('div'); + player.id = 'tts-player'; + player.className = 'tts-player'; + player.innerHTML = ` +
+
+ 🔊 + Now Playing +
+ +
+
+
+
+
+
+ + + 0:00 +
+
+ Speed: + + + + +
+
+ `; + + document.body.appendChild(player); + + // Bind player events + document.getElementById('tts-close')?.addEventListener('click', () => { + TTS.stop(); + }); + + document.getElementById('tts-play-pause')?.addEventListener('click', () => { + TTS.togglePlayPause(); + }); + + document.getElementById('tts-stop')?.addEventListener('click', () => { + TTS.stop(); + }); + + // Speed buttons + document.querySelectorAll('.tts-speed-btn').forEach(btn => { + btn.addEventListener('click', () => { + const rate = parseFloat(btn.dataset.rate); + TTS.setPlaybackRate(rate); + }); + }); + + console.log('[Chat] TTS player created'); + }, + + // ========================================================================= + // CHAT PAGE LIFECYCLE + // ========================================================================= + + /** + * Called when chat page becomes active + */ + async onPageEnter() { + console.log('[Chat] Page entered'); + + // Update context banner + this.updateContextBanner(); + + // Update language display + this.updateLanguageDisplay(); + + // Load chat history or get welcome + await this.loadChat(); + + // Focus input + this.elements.chatInput?.focus(); + }, + + /** + * Called when leaving chat page + */ + onPageLeave() { + console.log('[Chat] Page left'); + // Stop any ongoing recording + if (VoiceInput.getIsRecording()) { + VoiceInput.cancelRecording(); + this.hideListeningIndicator(); + } + }, + + /** + * Update the context banner with diagnosis info + */ + updateContextBanner() { + const diagnosis = Diagnosis.getDiagnosis(); + + if (!diagnosis) { + this.elements.contextBanner?.classList.add('hidden'); + return; + } + + this.elements.contextBanner?.classList.remove('hidden'); + + const { detection } = diagnosis; + this.elements.contextDiseaseName.textContent = detection.disease_name || 'Unknown'; + this.elements.contextConfidence.textContent = `${Math.round(detection.confidence_percent || 0)}%`; + this.elements.contextSeverity.textContent = I18n.getSeverity(detection.severity_level || 'unknown'); + }, + + /** + * Update language display + */ + updateLanguageDisplay() { + const lang = I18n.getLanguage(); + if (this.elements.chatLangDisplay) { + this.elements.chatLangDisplay.textContent = lang.toUpperCase(); + } + }, + + /** + * Load chat history or welcome message + */ + async loadChat() { + // Clear existing messages + this.clearMessages(); + + try { + // Try to get existing history + const history = await FarmEyesAPI.getChatHistory(); + + if (history.success && history.messages && history.messages.length > 0) { + // Display existing messages + this.messages = history.messages; + this.displayMessages(history.messages); + } else { + // Get welcome message + const welcome = await FarmEyesAPI.getChatWelcome(I18n.getLanguage()); + + if (welcome.success && welcome.response) { + this.addMessage('assistant', welcome.response); + } else { + // Show default welcome + this.showWelcome(); + } + } + } catch (error) { + console.error('[Chat] Load failed:', error); + this.showWelcome(); + } + }, + + // ========================================================================= + // MESSAGE HANDLING + // ========================================================================= + + /** + * Handle input change + */ + handleInputChange() { + const text = this.elements.chatInput?.value?.trim(); + this.elements.btnSend.disabled = !text || this.isLoading; + }, + + /** + * Handle keyboard input + * @param {KeyboardEvent} event + */ + handleKeyDown(event) { + // Send on Enter (without Shift) + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(); + } + }, + + /** + * Auto-resize textarea + */ + autoResizeInput() { + const input = this.elements.chatInput; + if (!input) return; + + input.style.height = 'auto'; + const newHeight = Math.min(input.scrollHeight, 150); + input.style.height = `${newHeight}px`; + }, + + /** + * Send a chat message + */ + async sendMessage() { + const input = this.elements.chatInput; + const message = input?.value?.trim(); + + if (!message || this.isLoading) return; + + // Clear input + input.value = ''; + this.autoResizeInput(); + this.handleInputChange(); + + // Add user message to UI + this.addMessage('user', message); + + // Send to API + this.isLoading = true; + this.showTypingIndicator(); + + try { + const response = await FarmEyesAPI.sendChatMessage(message, I18n.getLanguage()); + + if (response.success) { + this.addMessage('assistant', response.response); + } else { + throw new Error(response.error || 'Failed to get response'); + } + } catch (error) { + console.error('[Chat] Send failed:', error); + this.addMessage('assistant', 'Sorry, I encountered an error. Please try again.'); + App.showToast(error.message, 'error'); + } finally { + this.isLoading = false; + this.hideTypingIndicator(); + this.handleInputChange(); + } + }, + + /** + * Add a message to the chat + * @param {string} role - 'user' or 'assistant' + * @param {string} content - Message content + */ + addMessage(role, content) { + // Hide welcome if visible + this.elements.chatWelcome?.classList.add('hidden'); + + // Create message element + const messageEl = this.createMessageElement(role, content); + + // Add to container + this.elements.messagesContainer?.appendChild(messageEl); + + // Store in array + this.messages.push({ role, content, timestamp: new Date().toISOString() }); + + // Scroll to bottom + this.scrollToBottom(); + }, + + /** + * Create message DOM element + * @param {string} role + * @param {string} content + * @returns {HTMLElement} + */ + createMessageElement(role, content) { + const div = document.createElement('div'); + div.className = `message ${role}`; + + // Generate unique message ID for TTS caching + const messageId = `msg_${++this.messageIdCounter}_${Date.now()}`; + div.dataset.messageId = messageId; + + const avatar = document.createElement('div'); + avatar.className = 'message-avatar'; + avatar.textContent = role === 'user' ? '👤' : '🌱'; + + const contentWrapper = document.createElement('div'); + contentWrapper.className = 'message-content-wrapper'; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'message-content'; + contentDiv.textContent = content; + + contentWrapper.appendChild(contentDiv); + + // Add Listen button for assistant messages + if (role === 'assistant') { + const listenBtn = document.createElement('button'); + listenBtn.className = 'btn-listen'; + listenBtn.dataset.messageId = messageId; + listenBtn.dataset.text = content; + listenBtn.innerHTML = ` + 🔊 + Listen + `; + listenBtn.title = 'Listen to this message'; + listenBtn.addEventListener('click', () => this.handleListenClick(listenBtn, content, messageId)); + + contentWrapper.appendChild(listenBtn); + } + + div.appendChild(avatar); + div.appendChild(contentWrapper); + + return div; + }, + + /** + * Handle Listen button click + * @param {HTMLElement} button - The listen button + * @param {string} text - Message text + * @param {string} messageId - Unique message ID + */ + async handleListenClick(button, text, messageId) { + // If already playing this message, toggle pause + if (TTS.currentMessageId === messageId) { + if (TTS.getIsPlaying()) { + TTS.pause(); + button.innerHTML = `▶️Resume`; + } else if (TTS.getIsPaused()) { + TTS.resume(); + button.innerHTML = `⏸️Pause`; + } + return; + } + + // Reset all other listen buttons + this.updateListenButtons(); + + // Show loading state + button.classList.add('loading'); + button.innerHTML = `🔊Loading...`; + + // Get current language + const language = I18n.getLanguage(); + + // Start TTS + const success = await TTS.speak(text, language, messageId); + + // Update button state + button.classList.remove('loading'); + + if (success) { + button.classList.add('playing'); + button.innerHTML = `⏸️Pause`; + } else { + button.innerHTML = `🔊Listen`; + } + }, + + /** + * Update all listen buttons to default state + */ + updateListenButtons() { + document.querySelectorAll('.btn-listen').forEach(btn => { + btn.classList.remove('loading', 'playing'); + btn.innerHTML = `🔊Listen`; + }); + }, + + /** + * Display multiple messages + * @param {Array} messages + */ + displayMessages(messages) { + this.elements.chatWelcome?.classList.add('hidden'); + + messages.forEach(msg => { + const messageEl = this.createMessageElement(msg.role, msg.content); + this.elements.messagesContainer?.appendChild(messageEl); + }); + + this.scrollToBottom(); + }, + + /** + * Clear all messages + */ + clearMessages() { + if (this.elements.messagesContainer) { + this.elements.messagesContainer.innerHTML = ''; + // Re-add welcome + const welcome = document.createElement('div'); + welcome.id = 'chat-welcome'; + welcome.className = 'chat-welcome'; + welcome.innerHTML = ` +
🌱
+

Start a conversation about your diagnosis

+ `; + this.elements.messagesContainer.appendChild(welcome); + this.elements.chatWelcome = welcome; + } + this.messages = []; + }, + + /** + * Show welcome screen + */ + showWelcome() { + this.elements.chatWelcome?.classList.remove('hidden'); + }, + + /** + * Show typing indicator + */ + showTypingIndicator() { + // Remove existing indicator + this.hideTypingIndicator(); + + const indicator = document.createElement('div'); + indicator.className = 'message assistant typing-message'; + indicator.innerHTML = ` +
🌱
+
+
+
+
+
+
+
+ `; + + this.elements.messagesContainer?.appendChild(indicator); + this.scrollToBottom(); + }, + + /** + * Hide typing indicator + */ + hideTypingIndicator() { + const indicator = this.elements.messagesContainer?.querySelector('.typing-message'); + indicator?.remove(); + }, + + /** + * Scroll chat to bottom + */ + scrollToBottom() { + const container = this.elements.messagesContainer; + if (container) { + container.scrollTop = container.scrollHeight; + } + }, + + // ========================================================================= + // VOICE INPUT - INLINE LISTENING INDICATOR + // ========================================================================= + + /** + * Toggle voice recording + */ + async toggleVoiceRecording() { + if (!VoiceInput.isSupported()) { + App.showToast('Voice input is not supported in this browser', 'error'); + return; + } + + if (VoiceInput.getIsRecording()) { + this.stopVoiceRecording(); + } else { + await this.startVoiceRecording(); + } + }, + + /** + * Start voice recording + */ + async startVoiceRecording() { + const started = await VoiceInput.startRecording(); + + if (!started) { + // Error handled by VoiceInput callback + return; + } + }, + + /** + * Stop voice recording + */ + stopVoiceRecording() { + VoiceInput.stopRecording(); + }, + + /** + * Handle voice transcription result + * @param {string} text - Transcribed text + * @param {object} result - Full result object + */ + handleVoiceTranscription(text, result) { + if (!text) { + App.showToast('Could not understand audio. Please try again.', 'warning'); + return; + } + + // Put text in input + if (this.elements.chatInput) { + this.elements.chatInput.value = text; + this.autoResizeInput(); + this.handleInputChange(); + + // Optionally auto-send + // this.sendMessage(); + } + + // Show language detected + if (result.language) { + console.log('[Chat] Detected language:', result.language); + } + }, + + /** + * Show inline listening indicator in chat bar + * Replaces the textarea with a listening indicator + timer + */ + showListeningIndicator() { + const inputBox = this.elements.chatInputBox; + const textarea = this.elements.chatInput; + const btnVoice = this.elements.btnVoice; + const btnSend = this.elements.btnSend; + + if (!inputBox) return; + + // Hide textarea and send button + textarea?.classList.add('hidden'); + btnSend?.classList.add('hidden'); + + // Update voice button to stop style + btnVoice?.classList.add('recording'); + + // Create listening indicator + const listeningIndicator = document.createElement('div'); + listeningIndicator.id = 'listening-indicator'; + listeningIndicator.className = 'listening-indicator'; + listeningIndicator.innerHTML = ` +
+ Listening... + 0:00 + + `; + + // Insert before voice button + inputBox.insertBefore(listeningIndicator, btnVoice); + + // Bind stop button + const btnStop = listeningIndicator.querySelector('.btn-stop-inline'); + btnStop?.addEventListener('click', () => this.stopVoiceRecording()); + + // Start timer + this.recordingSeconds = 0; + this.updateRecordingTimer(); + this.recordingTimer = setInterval(() => { + this.recordingSeconds++; + this.updateRecordingTimer(); + }, 1000); + + console.log('[Chat] Listening indicator shown'); + }, + + /** + * Hide inline listening indicator + */ + hideListeningIndicator() { + const inputBox = this.elements.chatInputBox; + const textarea = this.elements.chatInput; + const btnVoice = this.elements.btnVoice; + const btnSend = this.elements.btnSend; + + // Remove listening indicator + const indicator = document.getElementById('listening-indicator'); + indicator?.remove(); + + // Show textarea and send button + textarea?.classList.remove('hidden'); + btnSend?.classList.remove('hidden'); + + // Update voice button + btnVoice?.classList.remove('recording'); + + // Stop timer + if (this.recordingTimer) { + clearInterval(this.recordingTimer); + this.recordingTimer = null; + } + this.recordingSeconds = 0; + + console.log('[Chat] Listening indicator hidden'); + }, + + /** + * Update the recording timer display + */ + updateRecordingTimer() { + const timerEl = document.querySelector('.listening-timer'); + if (timerEl) { + const minutes = Math.floor(this.recordingSeconds / 60); + const seconds = this.recordingSeconds % 60; + timerEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + }, + + // ========================================================================= + // LEGACY OVERLAY METHODS (kept for compatibility but not used) + // ========================================================================= + + /** + * Show voice recording overlay (LEGACY - not used) + */ + showVoiceOverlay() { + // Replaced by showListeningIndicator() + this.showListeningIndicator(); + }, + + /** + * Hide voice recording overlay (LEGACY - not used) + */ + hideVoiceOverlay() { + // Replaced by hideListeningIndicator() + this.hideListeningIndicator(); + } +}; + +// Export for use in other modules +window.Chat = Chat; diff --git a/frontend/js/diagnosis.js b/frontend/js/diagnosis.js new file mode 100644 index 0000000000000000000000000000000000000000..12f283fc54a59454f0a46cee758b755221317790 --- /dev/null +++ b/frontend/js/diagnosis.js @@ -0,0 +1,515 @@ +/** + * FarmEyes Diagnosis Module + * ========================= + * Handles image upload, disease detection, and results display. + * Fixed to correctly map API response structure to UI elements. + */ + +const Diagnosis = { + // State + currentImage: null, + currentDiagnosis: null, + isAnalyzing: false, + + // DOM Elements (cached) + elements: {}, + + /** + * Initialize diagnosis module + */ + init() { + this.cacheElements(); + this.bindEvents(); + console.log('[Diagnosis] Initialized'); + }, + + /** + * Cache DOM elements for performance + */ + cacheElements() { + this.elements = { + // Upload + uploadZone: document.getElementById('upload-zone'), + fileInput: document.getElementById('file-input'), + imagePreviewContainer: document.getElementById('image-preview-container'), + imagePreview: document.getElementById('image-preview'), + btnRemoveImage: document.getElementById('btn-remove-image'), + btnAnalyze: document.getElementById('btn-analyze'), + analyzingLoader: document.getElementById('analyzing-loader'), + + // Sections + uploadSection: document.getElementById('upload-section'), + resultsSection: document.getElementById('results-section'), + + // Results - Disease Card + btnNewScan: document.getElementById('btn-new-scan'), + diseaseIcon: document.getElementById('disease-icon'), + diseaseName: document.getElementById('disease-name'), + cropType: document.getElementById('crop-type'), + confidenceBar: document.getElementById('confidence-bar'), + confidenceValue: document.getElementById('confidence-value'), + severityBadge: document.getElementById('severity-badge'), + + // Tabs + tabButtons: document.querySelectorAll('.tab-btn'), + tabSymptoms: document.getElementById('tab-symptoms'), + tabTreatment: document.getElementById('tab-treatment'), + tabPrevention: document.getElementById('tab-prevention'), + + // Symptoms tab content + symptomsList: document.getElementById('symptoms-list'), + transmissionList: document.getElementById('transmission-list'), + yieldImpactText: document.getElementById('yield-impact-text'), + recoveryBar: document.getElementById('recovery-bar'), + recoveryText: document.getElementById('recovery-text'), + + // Treatment tab content + immediateActionsList: document.getElementById('immediate-actions-list'), + chemicalTreatments: document.getElementById('chemical-treatments'), + costEstimate: document.getElementById('cost-estimate'), + + // Prevention tab content + preventionList: document.getElementById('prevention-list'), + + // Chat button + btnOpenChat: document.getElementById('btn-open-chat') + }; + }, + + /** + * Bind event handlers + */ + bindEvents() { + const { uploadZone, fileInput, btnRemoveImage, btnAnalyze, + btnNewScan, tabButtons, btnOpenChat } = this.elements; + + // Upload zone click + uploadZone?.addEventListener('click', () => fileInput?.click()); + + // File input change + fileInput?.addEventListener('change', (e) => this.handleFileSelect(e)); + + // Drag and drop + uploadZone?.addEventListener('dragover', (e) => this.handleDragOver(e)); + uploadZone?.addEventListener('dragleave', (e) => this.handleDragLeave(e)); + uploadZone?.addEventListener('drop', (e) => this.handleDrop(e)); + + // Remove image + btnRemoveImage?.addEventListener('click', (e) => { + e.stopPropagation(); + this.removeImage(); + }); + + // Analyze button + btnAnalyze?.addEventListener('click', () => this.analyzeImage()); + + // New scan button + btnNewScan?.addEventListener('click', () => this.clearResults()); + + // Tab switching + tabButtons.forEach(btn => { + btn.addEventListener('click', () => this.switchTab(btn.dataset.tab)); + }); + + // Open chat + btnOpenChat?.addEventListener('click', () => { + if (this.currentDiagnosis) { + App.navigateToChat(); + } + }); + }, + + // ========================================================================= + // IMAGE HANDLING + // ========================================================================= + + handleFileSelect(event) { + const file = event.target.files?.[0]; + if (file) { + this.loadImage(file); + } + }, + + handleDragOver(event) { + event.preventDefault(); + event.stopPropagation(); + this.elements.uploadZone?.classList.add('dragover'); + }, + + handleDragLeave(event) { + event.preventDefault(); + event.stopPropagation(); + this.elements.uploadZone?.classList.remove('dragover'); + }, + + handleDrop(event) { + event.preventDefault(); + event.stopPropagation(); + this.elements.uploadZone?.classList.remove('dragover'); + + const file = event.dataTransfer?.files?.[0]; + if (file && file.type.startsWith('image/')) { + this.loadImage(file); + } else { + App.showToast('Please drop an image file', 'error'); + } + }, + + loadImage(file) { + // Validate file type + const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/bmp']; + if (!validTypes.includes(file.type)) { + App.showToast('Invalid image format. Use JPG, PNG, or WEBP.', 'error'); + return; + } + + // Validate file size (10MB max) + if (file.size > 10 * 1024 * 1024) { + App.showToast('Image too large. Maximum 10MB.', 'error'); + return; + } + + this.currentImage = file; + + // Show preview + const reader = new FileReader(); + reader.onload = (e) => { + this.elements.imagePreview.src = e.target.result; + this.elements.uploadZone?.classList.add('hidden'); + this.elements.imagePreviewContainer?.classList.remove('hidden'); + this.elements.btnAnalyze.disabled = false; + }; + reader.readAsDataURL(file); + + console.log('[Diagnosis] Image loaded:', file.name); + }, + + removeImage() { + this.currentImage = null; + this.elements.imagePreview.src = ''; + this.elements.uploadZone?.classList.remove('hidden'); + this.elements.imagePreviewContainer?.classList.add('hidden'); + this.elements.btnAnalyze.disabled = true; + this.elements.fileInput.value = ''; + + console.log('[Diagnosis] Image removed'); + }, + + // ========================================================================= + // ANALYSIS + // ========================================================================= + + async analyzeImage() { + if (!this.currentImage || this.isAnalyzing) { + return; + } + + this.isAnalyzing = true; + this.showAnalyzing(true); + + try { + console.log('[Diagnosis] Starting analysis...'); + + const result = await FarmEyesAPI.detectDisease( + this.currentImage, + I18n.getLanguage() + ); + + console.log('[Diagnosis] API Response:', result); + + if (result.success) { + this.currentDiagnosis = result; + this.displayResults(result); + console.log('[Diagnosis] Analysis complete:', result.detection?.disease_name); + } else { + throw new Error(result.error || 'Analysis failed'); + } + + } catch (error) { + console.error('[Diagnosis] Analysis failed:', error); + App.showToast(error.message || 'Analysis failed. Please try again.', 'error'); + } finally { + this.isAnalyzing = false; + this.showAnalyzing(false); + } + }, + + showAnalyzing(show) { + const { btnAnalyze, analyzingLoader } = this.elements; + + if (show) { + btnAnalyze?.classList.add('hidden'); + analyzingLoader?.classList.remove('hidden'); + } else { + btnAnalyze?.classList.remove('hidden'); + analyzingLoader?.classList.add('hidden'); + } + }, + + // ========================================================================= + // RESULTS DISPLAY - FIXED MAPPING + // ========================================================================= + + displayResults(result) { + const { detection, diagnosis } = result; + + console.log('[Diagnosis] Displaying results:', { detection, diagnosis }); + + // Show results section + this.elements.resultsSection?.classList.remove('hidden'); + + // Disease header + this.elements.diseaseIcon.textContent = this.getDiseaseIcon(detection.crop_type); + + // Disease name - check multiple possible locations + const diseaseName = diagnosis?.disease?.name || detection?.disease_name || 'Unknown Disease'; + this.elements.diseaseName.textContent = diseaseName; + + // Crop type + this.elements.cropType.textContent = this.formatCropName(detection?.crop_type); + + // Confidence + const confidencePercent = detection?.confidence_percent || (detection?.confidence * 100) || 0; + this.elements.confidenceBar.style.width = `${confidencePercent}%`; + this.elements.confidenceValue.textContent = `${Math.round(confidencePercent)}%`; + + // Severity + const severity = diagnosis?.disease?.severity?.level || detection?.severity_level || 'unknown'; + this.elements.severityBadge.textContent = this.formatSeverity(severity); + this.elements.severityBadge.className = `severity-badge ${severity.toLowerCase().replace(/\s+/g, '-')}`; + + // === SYMPTOMS TAB === + // Symptoms from diagnosis.symptoms array + const symptoms = diagnosis?.symptoms || []; + this.populateList(this.elements.symptomsList, symptoms); + + // Transmission from diagnosis.transmission array + const transmission = diagnosis?.transmission || []; + this.populateList(this.elements.transmissionList, transmission); + + // Yield impact + const yieldImpact = diagnosis?.yield_impact; + if (yieldImpact && this.elements.yieldImpactText) { + const minLoss = yieldImpact.min_percent || 0; + const maxLoss = yieldImpact.max_percent || 0; + this.elements.yieldImpactText.textContent = `${minLoss}% - ${maxLoss}% potential yield loss`; + } + + // Recovery/Health projection + const projection = diagnosis?.current_projection || diagnosis?.health_projection; + if (projection && this.elements.recoveryBar) { + const recovery = projection.recovery_chance_percent || projection.recovery_chance || 0; + this.elements.recoveryBar.style.width = `${recovery}%`; + if (this.elements.recoveryText) { + this.elements.recoveryText.textContent = `${recovery}% recovery chance`; + } + } + + // === TREATMENT TAB === + // Immediate actions from diagnosis.treatments.immediate_actions + const treatments = diagnosis?.treatments || {}; + const immediateActions = treatments.immediate_actions || []; + this.populateActionsList(this.elements.immediateActionsList, immediateActions); + + // Chemical treatments + const chemicalTreatments = treatments.chemical || []; + this.populateChemicalTreatments(chemicalTreatments); + + // Cost estimate + const costs = diagnosis?.costs; + if (costs && this.elements.costEstimate) { + const minCost = costs.min_ngn || 0; + const maxCost = costs.max_ngn || 0; + if (minCost && maxCost) { + this.elements.costEstimate.textContent = `₦${minCost.toLocaleString()} - ₦${maxCost.toLocaleString()}`; + } else { + this.elements.costEstimate.textContent = 'Contact local supplier'; + } + } + + // === PREVENTION TAB === + // Prevention tips from diagnosis.prevention array + const prevention = diagnosis?.prevention || []; + this.populateList(this.elements.preventionList, prevention); + + // Scroll to results + this.elements.resultsSection.scrollIntoView({ behavior: 'smooth' }); + }, + + /** + * Populate a simple list with items + */ + populateList(listElement, items) { + if (!listElement) return; + + listElement.innerHTML = ''; + + if (!items || items.length === 0) { + const li = document.createElement('li'); + li.textContent = 'No information available'; + li.style.fontStyle = 'italic'; + li.style.color = 'var(--text-muted)'; + listElement.appendChild(li); + return; + } + + items.slice(0, 6).forEach(item => { + const li = document.createElement('li'); + // Handle both string items and object items + if (typeof item === 'string') { + li.textContent = item; + } else if (typeof item === 'object') { + li.textContent = item.text || item.description || item.name || JSON.stringify(item); + } + listElement.appendChild(li); + }); + }, + + /** + * Populate immediate actions list + */ + populateActionsList(listElement, actions) { + if (!listElement) return; + + listElement.innerHTML = ''; + + if (!actions || actions.length === 0) { + const li = document.createElement('li'); + li.textContent = 'Consult agricultural expert for guidance'; + listElement.appendChild(li); + return; + } + + actions.slice(0, 5).forEach(action => { + const li = document.createElement('li'); + if (typeof action === 'string') { + li.textContent = action; + } else if (typeof action === 'object') { + li.textContent = action.action || action.description || action.text || ''; + } + listElement.appendChild(li); + }); + }, + + /** + * Populate chemical treatments + */ + populateChemicalTreatments(treatments) { + const container = this.elements.chemicalTreatments; + if (!container) return; + + container.innerHTML = ''; + + if (!treatments || treatments.length === 0) { + const div = document.createElement('div'); + div.className = 'treatment-item'; + div.innerHTML = 'Consult local agricultural store'; + container.appendChild(div); + return; + } + + treatments.slice(0, 4).forEach(treatment => { + const div = document.createElement('div'); + div.className = 'treatment-item'; + + const name = treatment.product || treatment.product_name || treatment.name || 'Treatment'; + const dosage = treatment.dosage || treatment.application || ''; + const costMin = treatment.cost_min || treatment.cost_ngn_min || ''; + const costMax = treatment.cost_max || treatment.cost_ngn_max || ''; + + let costText = ''; + if (costMin && costMax) { + costText = ` - ₦${costMin.toLocaleString()} to ₦${costMax.toLocaleString()}`; + } + + div.innerHTML = ` + ${name} + ${dosage}${costText} + `; + + container.appendChild(div); + }); + }, + + /** + * Get disease icon based on crop type + */ + getDiseaseIcon(cropType) { + const icons = { + cassava: '🌿', + cocoa: '🍫', + tomato: '🍅' + }; + return icons[cropType?.toLowerCase()] || '🌱'; + }, + + /** + * Format crop name + */ + formatCropName(cropType) { + if (!cropType) return 'Unknown'; + return cropType.charAt(0).toUpperCase() + cropType.slice(1).toLowerCase(); + }, + + /** + * Format severity level + */ + formatSeverity(severity) { + if (!severity) return 'Unknown'; + return severity.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + }, + + /** + * Switch between tabs + */ + switchTab(tabName) { + // Update button states + this.elements.tabButtons.forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + + // Update content visibility + const tabs = ['symptoms', 'treatment', 'prevention']; + tabs.forEach(tab => { + const tabElement = this.elements[`tab${tab.charAt(0).toUpperCase() + tab.slice(1)}`]; + if (tabElement) { + tabElement.classList.toggle('active', tab === tabName); + tabElement.classList.toggle('hidden', tab !== tabName); + } + }); + }, + + /** + * Clear results and reset for new scan + */ + clearResults() { + this.currentDiagnosis = null; + this.elements.resultsSection?.classList.add('hidden'); + this.removeImage(); + + // Clear API diagnosis + FarmEyesAPI.clearDiagnosis().catch(() => {}); + + // Scroll to top + window.scrollTo({ top: 0, behavior: 'smooth' }); + + console.log('[Diagnosis] Results cleared'); + }, + + /** + * Get current diagnosis data + */ + getDiagnosis() { + return this.currentDiagnosis; + }, + + /** + * Check if there's a valid diagnosis + */ + hasDiagnosis() { + return this.currentDiagnosis !== null; + } +}; + +// Export +window.Diagnosis = Diagnosis; diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js new file mode 100644 index 0000000000000000000000000000000000000000..ef4bc1b40363f94308479603c58b01dd604214ff --- /dev/null +++ b/frontend/js/i18n.js @@ -0,0 +1,328 @@ +/** + * FarmEyes Internationalization (i18n) + * ===================================== + * Static translations for UI elements. + * Always works - no API dependency. + */ + +const I18n = { + currentLanguage: 'en', + + // Static translations - embedded for reliability + translations: { + en: { + // Buttons + "buttons.continue": "Continue", + "buttons.analyze": "Analyze Crop", + "buttons.new_scan": "+ New Scan", + "buttons.back": "Back", + "buttons.chat": "Chat with Assistant", + "buttons.stop": "Stop", + + // Diagnosis page + "diagnosis.upload_title": "Upload Crop Image", + "diagnosis.upload_desc": "Take a clear photo of the affected leaf or plant", + "diagnosis.click_or_drag": "Click or drag image here", + "diagnosis.analyzing": "Analyzing your crop...", + + // Results + "results.title": "Diagnosis Results", + "results.confidence": "Confidence:", + "results.transmission": "How It Spreads", + "results.yield_impact": "Yield Impact", + "results.recovery": "Recovery Chance", + + // Tabs + "tabs.symptoms": "Symptoms", + "tabs.treatment": "Treatment", + "tabs.prevention": "Prevention", + + // Treatment + "treatment.immediate": "Immediate Actions", + "treatment.chemical": "Chemical Treatment", + "treatment.cost": "Estimated Cost:", + + // Chat + "chat.discussing": "Discussing:", + "chat.welcome": "Ask me anything about your diagnosis, treatments, or prevention tips.", + "chat.placeholder": "Ask about your diagnosis...", + "chat.disclaimer": "FarmEyes provides guidance only. Consult experts for serious cases.", + + // Voice + "voice.listening": "Listening...", + + // Severity + "severity_levels.very_high": "Very High", + "severity_levels.high": "High", + "severity_levels.medium": "Medium", + "severity_levels.low": "Low", + + // Crops + "crops.cassava": "Cassava", + "crops.cocoa": "Cocoa", + "crops.tomato": "Tomato" + }, + + ha: { + // Buttons + "buttons.continue": "Ci gaba", + "buttons.analyze": "Bincika Amfanin Gona", + "buttons.new_scan": "+ Sabon Duba", + "buttons.back": "Koma", + "buttons.chat": "Yi magana da Mataimaki", + "buttons.stop": "Daina", + + // Diagnosis page + "diagnosis.upload_title": "Ɗora Hoton Amfanin Gona", + "diagnosis.upload_desc": "Ɗauki hoto mai kyau na ganyen da ya kamu", + "diagnosis.click_or_drag": "Danna ko ja hoto nan", + "diagnosis.analyzing": "Ana bincika amfanin gonar ku...", + + // Results + "results.title": "Sakamakon Bincike", + "results.confidence": "Tabbaci:", + "results.transmission": "Yadda Yake Yaɗuwa", + "results.yield_impact": "Tasirin Amfanin Gona", + "results.recovery": "Damar Murmurewa", + + // Tabs + "tabs.symptoms": "Alamomi", + "tabs.treatment": "Magani", + "tabs.prevention": "Rigakafi", + + // Treatment + "treatment.immediate": "Matakai na Gaggawa", + "treatment.chemical": "Maganin Sinadari", + "treatment.cost": "Ƙiyasin Farashi:", + + // Chat + "chat.discussing": "Muna tattaunawa:", + "chat.welcome": "Tambaye ni komai game da binciken ku.", + "chat.placeholder": "Tambaya game da binciken ku...", + "chat.disclaimer": "FarmEyes yana ba da jagora kawai.", + + // Voice + "voice.listening": "Ana saurara...", + + // Severity + "severity_levels.very_high": "Mai Tsanani Sosai", + "severity_levels.high": "Mai Tsanani", + "severity_levels.medium": "Matsakaici", + "severity_levels.low": "Ƙasa", + + // Crops + "crops.cassava": "Rogo", + "crops.cocoa": "Koko", + "crops.tomato": "Tumatir" + }, + + yo: { + // Buttons + "buttons.continue": "Tẹ̀síwájú", + "buttons.analyze": "Ṣe Àyẹ̀wò Ohun Ọ̀gbìn", + "buttons.new_scan": "+ Àyẹ̀wò Tuntun", + "buttons.back": "Padà", + "buttons.chat": "Bá Olùrànlọ́wọ́ sọ̀rọ̀", + "buttons.stop": "Dúró", + + // Diagnosis page + "diagnosis.upload_title": "Gbé Àwòrán Ohun Ọ̀gbìn Sókè", + "diagnosis.upload_desc": "Ya àwòrán tó ṣe kedere ti ewé tó ní àrùn", + "diagnosis.click_or_drag": "Tẹ tàbí fà àwòrán síbí", + "diagnosis.analyzing": "A ń ṣe àyẹ̀wò ohun ọ̀gbìn yín...", + + // Results + "results.title": "Àbájáde Àyẹ̀wò", + "results.confidence": "Ìgbẹ́kẹ̀lé:", + "results.transmission": "Bí Ó Ṣe Ń Tàn Kálẹ̀", + "results.yield_impact": "Ipa Lórí Èso", + "results.recovery": "Àǹfààní Ìmúlàradà", + + // Tabs + "tabs.symptoms": "Àmì Àrùn", + "tabs.treatment": "Ìtọ́jú", + "tabs.prevention": "Ìdènà", + + // Treatment + "treatment.immediate": "Ìgbésẹ̀ Lẹ́sẹ̀kẹsẹ̀", + "treatment.chemical": "Ìtọ́jú Kẹ́míkà", + "treatment.cost": "Iye Owó Tí A Ṣe Àfojúsùn:", + + // Chat + "chat.discussing": "A ń sọ̀rọ̀ nípa:", + "chat.welcome": "Bi mi nípa àyẹ̀wò rẹ, ìtọ́jú, tàbí ìdènà.", + "chat.placeholder": "Béèrè nípa àyẹ̀wò rẹ...", + "chat.disclaimer": "FarmEyes pèsè ìtọ́sọ́nà nìkan.", + + // Voice + "voice.listening": "A ń gbọ́...", + + // Severity + "severity_levels.very_high": "Ga Jù", + "severity_levels.high": "Ga", + "severity_levels.medium": "Àárín", + "severity_levels.low": "Kéré", + + // Crops + "crops.cassava": "Ẹ̀gẹ́", + "crops.cocoa": "Koko", + "crops.tomato": "Tòmátì" + }, + + ig: { + // Buttons + "buttons.continue": "Gaa n'ihu", + "buttons.analyze": "Nyochaa Ihe Ọkụkụ", + "buttons.new_scan": "+ Nyocha Ọhụụ", + "buttons.back": "Laghachi", + "buttons.chat": "Soro Onye enyemaka", + "buttons.stop": "Kwụsị", + + // Diagnosis page + "diagnosis.upload_title": "Bulite Foto Ihe Ọkụkụ", + "diagnosis.upload_desc": "See foto doro anya nke akwụkwọ nke nwere nsogbu", + "diagnosis.click_or_drag": "Pịa ma ọ bụ dọrọ foto ebe a", + "diagnosis.analyzing": "Anyị na-enyocha ihe ọkụkụ gị...", + + // Results + "results.title": "Nsonaazụ Nyocha", + "results.confidence": "Ntụkwasị Obi:", + "results.transmission": "Otu Ọ Si Agbasa", + "results.yield_impact": "Mmetụta Ọnụ Ego", + "results.recovery": "Ohere Ịlaghachi", + + // Tabs + "tabs.symptoms": "Ihe Ngosi", + "tabs.treatment": "Ọgwụgwọ", + "tabs.prevention": "Mgbochi", + + // Treatment + "treatment.immediate": "Ihe Ọsịịsọ", + "treatment.chemical": "Ọgwụgwọ Kemịkalụ", + "treatment.cost": "Ego A Tụrụ Anya:", + + // Chat + "chat.discussing": "Anyị na-atụ:", + "chat.welcome": "Jụọ m ihe ọ bụla gbasara nyocha gị.", + "chat.placeholder": "Jụọ maka nyocha gị...", + "chat.disclaimer": "FarmEyes na-enye nduzi nọọ.", + + // Voice + "voice.listening": "Anyị na-ege...", + + // Severity + "severity_levels.very_high": "Dị Elu Nnọọ", + "severity_levels.high": "Dị Elu", + "severity_levels.medium": "Etiti", + "severity_levels.low": "Dị Ala", + + // Crops + "crops.cassava": "Akpụ", + "crops.cocoa": "Koko", + "crops.tomato": "Tomato" + } + }, + + /** + * Initialize + */ + async init(language = 'en') { + this.currentLanguage = language; + this.applyTranslations(); + console.log('[I18n] Initialized:', language); + }, + + /** + * Set language + */ + async setLanguage(language) { + if (!['en', 'ha', 'yo', 'ig'].includes(language)) { + language = 'en'; + } + + this.currentLanguage = language; + localStorage.setItem('farmeyes_language', language); + + // Update API + try { + await FarmEyesAPI.setLanguage(language); + } catch (e) { + console.warn('[I18n] API update failed:', e); + } + + this.applyTranslations(); + + window.dispatchEvent(new CustomEvent('languageChanged', { detail: { language } })); + + console.log('[I18n] Language changed:', language); + }, + + /** + * Get translation + */ + t(key, params = {}) { + const langData = this.translations[this.currentLanguage] || this.translations.en; + let value = langData[key] || this.translations.en[key] || key; + + // Interpolate + if (typeof value === 'string' && Object.keys(params).length > 0) { + value = value.replace(/\{(\w+)\}/g, (match, k) => params[k] !== undefined ? params[k] : match); + } + + return value; + }, + + /** + * Apply translations to DOM + */ + applyTranslations() { + // Text content + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + const translation = this.t(key); + if (translation !== key) { + el.textContent = translation; + } + }); + + // Placeholders + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + const translation = this.t(key); + if (translation !== key) { + el.placeholder = translation; + } + }); + + document.documentElement.lang = this.currentLanguage; + }, + + getLanguage() { + return this.currentLanguage; + }, + + formatCurrency(amount) { + if (amount == null) return ''; + return new Intl.NumberFormat('en-NG', { + style: 'currency', + currency: 'NGN', + minimumFractionDigits: 0 + }).format(amount); + }, + + getSeverity(level) { + if (!level) return 'Unknown'; + const key = `severity_levels.${level.toLowerCase().replace(/\s+/g, '_')}`; + const t = this.t(key); + return t !== key ? t : level; + }, + + getCropName(crop) { + if (!crop) return ''; + const key = `crops.${crop.toLowerCase()}`; + const t = this.t(key); + return t !== key ? t : crop; + } +}; + +window.I18n = I18n; diff --git a/frontend/js/tts.js b/frontend/js/tts.js new file mode 100644 index 0000000000000000000000000000000000000000..4dbc7ae328e0ef01a2342b203213a12c2850ebde --- /dev/null +++ b/frontend/js/tts.js @@ -0,0 +1,526 @@ +/** + * FarmEyes TTS Module + * =================== + * Text-to-Speech functionality using Meta MMS-TTS via HuggingFace API. + * + * Features: + * - Play/Pause/Stop controls + * - Speed control (0.75x, 1x, 1.25x, 1.5x) + * - Audio caching (browser session) + * - Web Speech API fallback for English + * - Floating player for last message + */ + +const TTS = { + // ========================================================================== + // STATE + // ========================================================================== + + // Current audio state + isPlaying: false, + isPaused: false, + currentAudio: null, + currentMessageId: null, + + // Playback settings + playbackRate: 1.0, + + // Cache for generated audio (session-based) + audioCache: new Map(), + + // Web Speech API fallback + speechSynthesis: window.speechSynthesis || null, + + // Callbacks + onPlayStart: null, + onPlayEnd: null, + onError: null, + + // ========================================================================== + // INITIALIZATION + // ========================================================================== + + /** + * Initialize TTS module + * @param {object} callbacks - Optional callback functions + */ + init(callbacks = {}) { + this.onPlayStart = callbacks.onPlayStart || (() => {}); + this.onPlayEnd = callbacks.onPlayEnd || (() => {}); + this.onError = callbacks.onError || ((err) => console.error('[TTS]', err)); + + // Check Web Speech API availability + if (this.speechSynthesis) { + console.log('[TTS] Web Speech API available for fallback'); + } + + console.log('[TTS] Initialized'); + }, + + // ========================================================================== + // PUBLIC API + // ========================================================================== + + /** + * Speak text using TTS + * @param {string} text - Text to speak + * @param {string} language - Language code (en, ha, yo, ig) + * @param {string} messageId - Unique message identifier for caching + * @returns {Promise} Success status + */ + async speak(text, language = 'en', messageId = null) { + // Stop any current playback + this.stop(); + + // Generate cache key + const cacheKey = messageId || this.generateCacheKey(text, language); + + // Check cache first + if (this.audioCache.has(cacheKey)) { + console.log('[TTS] Using cached audio'); + return this.playFromCache(cacheKey); + } + + // Try MMS-TTS API + try { + console.log(`[TTS] Synthesizing: lang=${language}, length=${text.length}`); + + const result = await this.synthesizeAPI(text, language); + + if (result.success && result.audio_base64) { + // Cache the audio + this.audioCache.set(cacheKey, { + audio_base64: result.audio_base64, + content_type: result.content_type, + language: language + }); + + // Play it + return this.playAudio(result.audio_base64, result.content_type, cacheKey); + } else { + throw new Error(result.error || 'TTS synthesis failed'); + } + + } catch (error) { + console.error('[TTS] API failed:', error); + + // Try fallback for English + if (language === 'en' && this.speechSynthesis) { + console.log('[TTS] Falling back to Web Speech API'); + return this.speakWithWebSpeech(text); + } + + // No fallback available + this.onError(`Voice playback failed: ${error.message}`); + return false; + } + }, + + /** + * Play audio from cache + * @param {string} cacheKey - Cache key + * @returns {Promise} Success status + */ + async playFromCache(cacheKey) { + const cached = this.audioCache.get(cacheKey); + if (!cached) return false; + + return this.playAudio(cached.audio_base64, cached.content_type, cacheKey); + }, + + /** + * Pause current playback + */ + pause() { + if (this.currentAudio && this.isPlaying) { + this.currentAudio.pause(); + this.isPlaying = false; + this.isPaused = true; + console.log('[TTS] Paused'); + this.updatePlayerUI('paused'); + } + }, + + /** + * Resume paused playback + */ + resume() { + if (this.currentAudio && this.isPaused) { + this.currentAudio.play(); + this.isPlaying = true; + this.isPaused = false; + console.log('[TTS] Resumed'); + this.updatePlayerUI('playing'); + } + }, + + /** + * Toggle play/pause + */ + togglePlayPause() { + if (this.isPlaying) { + this.pause(); + } else if (this.isPaused) { + this.resume(); + } + }, + + /** + * Stop playback completely + */ + stop() { + // Stop HTML5 Audio + if (this.currentAudio) { + this.currentAudio.pause(); + this.currentAudio.currentTime = 0; + this.currentAudio = null; + } + + // Stop Web Speech API + if (this.speechSynthesis) { + this.speechSynthesis.cancel(); + } + + this.isPlaying = false; + this.isPaused = false; + this.currentMessageId = null; + + console.log('[TTS] Stopped'); + this.updatePlayerUI('stopped'); + this.onPlayEnd(); + }, + + /** + * Set playback speed + * @param {number} rate - Playback rate (0.5 - 2.0) + */ + setPlaybackRate(rate) { + this.playbackRate = Math.max(0.5, Math.min(2.0, rate)); + + if (this.currentAudio) { + this.currentAudio.playbackRate = this.playbackRate; + } + + console.log(`[TTS] Playback rate: ${this.playbackRate}x`); + this.updateSpeedButtonsUI(); + }, + + /** + * Check if currently playing + * @returns {boolean} + */ + getIsPlaying() { + return this.isPlaying; + }, + + /** + * Check if paused + * @returns {boolean} + */ + getIsPaused() { + return this.isPaused; + }, + + /** + * Get current playback rate + * @returns {number} + */ + getPlaybackRate() { + return this.playbackRate; + }, + + // ========================================================================== + // API COMMUNICATION + // ========================================================================== + + /** + * Call TTS API to synthesize speech + * @param {string} text - Text to synthesize + * @param {string} language - Language code + * @returns {Promise} API response + */ + async synthesizeAPI(text, language) { + const response = await fetch('/api/tts', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + text: text, + language: language + }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + return await response.json(); + }, + + // ========================================================================== + // AUDIO PLAYBACK + // ========================================================================== + + /** + * Play audio from base64 data + * @param {string} audioBase64 - Base64 encoded audio + * @param {string} contentType - MIME type + * @param {string} messageId - Message identifier + * @returns {Promise} Success status + */ + async playAudio(audioBase64, contentType, messageId) { + try { + // Create blob from base64 + const binaryString = atob(audioBase64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: contentType }); + const audioUrl = URL.createObjectURL(blob); + + // Create audio element + this.currentAudio = new Audio(audioUrl); + this.currentAudio.playbackRate = this.playbackRate; + this.currentMessageId = messageId; + + // Set up event handlers + this.currentAudio.onplay = () => { + this.isPlaying = true; + this.isPaused = false; + this.onPlayStart(); + this.updatePlayerUI('playing'); + }; + + this.currentAudio.onpause = () => { + if (!this.currentAudio.ended) { + this.updatePlayerUI('paused'); + } + }; + + this.currentAudio.onended = () => { + this.isPlaying = false; + this.isPaused = false; + this.currentMessageId = null; + URL.revokeObjectURL(audioUrl); + this.onPlayEnd(); + this.updatePlayerUI('stopped'); + console.log('[TTS] Playback ended'); + }; + + this.currentAudio.onerror = (e) => { + console.error('[TTS] Audio error:', e); + this.stop(); + this.onError('Audio playback failed'); + }; + + // Update UI for time tracking + this.currentAudio.ontimeupdate = () => { + this.updateProgressUI(); + }; + + // Start playback + await this.currentAudio.play(); + console.log('[TTS] Playing audio'); + + return true; + + } catch (error) { + console.error('[TTS] Play error:', error); + this.onError('Failed to play audio'); + return false; + } + }, + + // ========================================================================== + // WEB SPEECH API FALLBACK (English only) + // ========================================================================== + + /** + * Speak using Web Speech API (fallback for English) + * @param {string} text - Text to speak + * @returns {Promise} Success status + */ + async speakWithWebSpeech(text) { + return new Promise((resolve) => { + if (!this.speechSynthesis) { + this.onError('Web Speech API not available'); + resolve(false); + return; + } + + // Cancel any ongoing speech + this.speechSynthesis.cancel(); + + // Create utterance + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = 'en-US'; + utterance.rate = this.playbackRate; + utterance.pitch = 1.0; + + // Get English voice + const voices = this.speechSynthesis.getVoices(); + const englishVoice = voices.find(v => v.lang.startsWith('en')); + if (englishVoice) { + utterance.voice = englishVoice; + } + + // Event handlers + utterance.onstart = () => { + this.isPlaying = true; + this.isPaused = false; + this.onPlayStart(); + this.updatePlayerUI('playing'); + }; + + utterance.onend = () => { + this.isPlaying = false; + this.isPaused = false; + this.onPlayEnd(); + this.updatePlayerUI('stopped'); + resolve(true); + }; + + utterance.onerror = (e) => { + console.error('[TTS] Web Speech error:', e); + this.onError('Speech synthesis failed'); + resolve(false); + }; + + // Speak + this.speechSynthesis.speak(utterance); + console.log('[TTS] Using Web Speech API'); + }); + }, + + // ========================================================================== + // UI HELPERS + // ========================================================================== + + /** + * Generate cache key from text and language + * @param {string} text - Text content + * @param {string} language - Language code + * @returns {string} Cache key + */ + generateCacheKey(text, language) { + // Simple hash-like key + const hash = text.slice(0, 50).replace(/\s+/g, '_'); + return `${language}_${hash}_${text.length}`; + }, + + /** + * Update player UI state + * @param {string} state - 'playing', 'paused', 'stopped' + */ + updatePlayerUI(state) { + const player = document.getElementById('tts-player'); + const btnPlayPause = document.getElementById('tts-play-pause'); + + if (!player) return; + + switch (state) { + case 'playing': + player.classList.add('active'); + if (btnPlayPause) { + btnPlayPause.innerHTML = this.getPauseIcon(); + btnPlayPause.title = 'Pause'; + } + break; + + case 'paused': + player.classList.add('active'); + if (btnPlayPause) { + btnPlayPause.innerHTML = this.getPlayIcon(); + btnPlayPause.title = 'Resume'; + } + break; + + case 'stopped': + player.classList.remove('active'); + this.resetProgressUI(); + break; + } + }, + + /** + * Update progress bar and time display + */ + updateProgressUI() { + if (!this.currentAudio) return; + + const progress = document.getElementById('tts-progress'); + const timeDisplay = document.getElementById('tts-time'); + + const current = this.currentAudio.currentTime; + const duration = this.currentAudio.duration || 0; + + if (progress && duration > 0) { + const percent = (current / duration) * 100; + progress.style.width = `${percent}%`; + } + + if (timeDisplay && duration > 0) { + const formatTime = (t) => { + const mins = Math.floor(t / 60); + const secs = Math.floor(t % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + timeDisplay.textContent = `${formatTime(current)} / ${formatTime(duration)}`; + } + }, + + /** + * Reset progress UI + */ + resetProgressUI() { + const progress = document.getElementById('tts-progress'); + const timeDisplay = document.getElementById('tts-time'); + + if (progress) progress.style.width = '0%'; + if (timeDisplay) timeDisplay.textContent = '0:00'; + }, + + /** + * Update speed buttons to show active state + */ + updateSpeedButtonsUI() { + const buttons = document.querySelectorAll('.tts-speed-btn'); + buttons.forEach(btn => { + const rate = parseFloat(btn.dataset.rate); + btn.classList.toggle('active', rate === this.playbackRate); + }); + }, + + /** + * Get play icon SVG + */ + getPlayIcon() { + return ` + + `; + }, + + /** + * Get pause icon SVG + */ + getPauseIcon() { + return ` + + + `; + }, + + /** + * Clear audio cache + */ + clearCache() { + this.audioCache.clear(); + console.log('[TTS] Cache cleared'); + } +}; + +// Export for use in other modules +window.TTS = TTS; diff --git a/frontend/js/voice.js b/frontend/js/voice.js new file mode 100644 index 0000000000000000000000000000000000000000..e19261f3e9706b4d04e48545950b59418099c50b --- /dev/null +++ b/frontend/js/voice.js @@ -0,0 +1,951 @@ +/** + * FarmEyes Voice Input - Robust Implementation + * ============================================= + * Handles voice recording and transcription using Web Audio API + * and backend Whisper service. + * + * Pipeline: Voice → Whisper → Text → N-ATLaS → Response + * + * Features: + * - Comprehensive browser compatibility detection + * - Detailed error logging for debugging + * - Secure context verification + * - Graceful fallbacks for older browsers + * - Safari and Chrome-specific handling + * + * @author FarmEyes Team + * @version 2.0.0 + */ + +const VoiceInput = { + // ========================================================================== + // STATE MANAGEMENT + // ========================================================================== + + // Recording state + isRecording: false, + mediaRecorder: null, + audioChunks: [], + stream: null, + + // Configuration + maxDuration: 30000, // 30 seconds max recording + minDuration: 500, // 0.5 seconds minimum + recordingTimer: null, + recordingStartTime: null, + + // Browser capabilities (cached after first check) + _capabilities: null, + + // Callbacks + onTranscription: null, + onError: null, + onRecordingStart: null, + onRecordingStop: null, + onPermissionDenied: null, + + // ========================================================================== + // INITIALIZATION + // ========================================================================== + + /** + * Initialize voice input with callbacks + * @param {object} callbacks - Callback functions + * @param {function} callbacks.onTranscription - Called with transcribed text + * @param {function} callbacks.onError - Called on errors + * @param {function} callbacks.onRecordingStart - Called when recording starts + * @param {function} callbacks.onRecordingStop - Called when recording stops + * @param {function} callbacks.onPermissionDenied - Called when mic permission denied + */ + init(callbacks = {}) { + // Set callbacks with defaults + this.onTranscription = callbacks.onTranscription || ((text) => { + console.log('[Voice] Transcription:', text); + }); + this.onError = callbacks.onError || ((err) => { + console.error('[Voice] Error:', err); + }); + this.onRecordingStart = callbacks.onRecordingStart || (() => { + console.log('[Voice] Recording started'); + }); + this.onRecordingStop = callbacks.onRecordingStop || (() => { + console.log('[Voice] Recording stopped'); + }); + this.onPermissionDenied = callbacks.onPermissionDenied || (() => { + console.warn('[Voice] Permission denied'); + }); + + // Check and cache browser capabilities + this._capabilities = this.checkCapabilities(); + + // Log initialization status + console.log('[Voice] Initialized with capabilities:', this._capabilities); + + return this._capabilities.supported; + }, + + // ========================================================================== + // BROWSER CAPABILITY DETECTION + // ========================================================================== + + /** + * Comprehensive browser capability check + * Returns detailed information about what's supported + * @returns {object} Capability report + */ + checkCapabilities() { + const capabilities = { + supported: false, + secureContext: false, + mediaDevices: false, + getUserMedia: false, + mediaRecorder: false, + audioContext: false, + supportedMimeTypes: [], + browser: this.detectBrowser(), + issues: [] + }; + + // Check 1: Secure Context (required for getUserMedia) + // localhost is considered secure, but let's verify + capabilities.secureContext = this.isSecureContext(); + if (!capabilities.secureContext) { + capabilities.issues.push('Not in a secure context (HTTPS or localhost required)'); + console.warn('[Voice] ❌ Not in secure context. URL:', window.location.href); + } else { + console.log('[Voice] ✓ Secure context verified'); + } + + // Check 2: navigator.mediaDevices exists + capabilities.mediaDevices = !!(navigator.mediaDevices); + if (!capabilities.mediaDevices) { + capabilities.issues.push('navigator.mediaDevices not available'); + console.warn('[Voice] ❌ navigator.mediaDevices is undefined'); + + // Try to diagnose why + if (typeof navigator === 'undefined') { + console.error('[Voice] navigator object is undefined'); + } else { + console.log('[Voice] navigator exists, but mediaDevices is:', navigator.mediaDevices); + } + } else { + console.log('[Voice] ✓ navigator.mediaDevices available'); + } + + // Check 3: getUserMedia function exists + if (capabilities.mediaDevices) { + capabilities.getUserMedia = !!(navigator.mediaDevices.getUserMedia); + if (!capabilities.getUserMedia) { + capabilities.issues.push('getUserMedia not available'); + console.warn('[Voice] ❌ getUserMedia not found on mediaDevices'); + } else { + console.log('[Voice] ✓ getUserMedia available'); + } + } + + // Check 4: MediaRecorder API exists + capabilities.mediaRecorder = !!(window.MediaRecorder); + if (!capabilities.mediaRecorder) { + capabilities.issues.push('MediaRecorder API not available'); + console.warn('[Voice] ❌ MediaRecorder not available'); + } else { + console.log('[Voice] ✓ MediaRecorder available'); + + // Check supported MIME types + capabilities.supportedMimeTypes = this.getSupportedMimeTypes(); + console.log('[Voice] Supported MIME types:', capabilities.supportedMimeTypes); + } + + // Check 5: AudioContext (optional but useful) + capabilities.audioContext = !!(window.AudioContext || window.webkitAudioContext); + if (capabilities.audioContext) { + console.log('[Voice] ✓ AudioContext available'); + } + + // Final determination + capabilities.supported = ( + capabilities.secureContext && + capabilities.mediaDevices && + capabilities.getUserMedia && + capabilities.mediaRecorder + ); + + if (capabilities.supported) { + console.log('[Voice] ✅ All capabilities supported - voice input ready'); + } else { + console.error('[Voice] ❌ Voice input NOT supported. Issues:', capabilities.issues); + } + + return capabilities; + }, + + /** + * Check if we're in a secure context + * @returns {boolean} Is secure context + */ + isSecureContext() { + // Modern browsers have window.isSecureContext + if (typeof window.isSecureContext === 'boolean') { + return window.isSecureContext; + } + + // Fallback check for older browsers + const protocol = window.location.protocol; + const hostname = window.location.hostname; + + // HTTPS is always secure + if (protocol === 'https:') { + return true; + } + + // localhost and 127.0.0.1 are considered secure even over HTTP + if (protocol === 'http:') { + if (hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname.endsWith('.localhost')) { + return true; + } + } + + // file:// protocol - depends on browser + if (protocol === 'file:') { + console.warn('[Voice] file:// protocol detected - may not support getUserMedia'); + return false; + } + + return false; + }, + + /** + * Detect browser type and version + * @returns {object} Browser info + */ + detectBrowser() { + const ua = navigator.userAgent; + let browser = { name: 'unknown', version: 'unknown' }; + + if (ua.includes('Chrome') && !ua.includes('Edg')) { + const match = ua.match(/Chrome\/(\d+)/); + browser = { name: 'chrome', version: match ? match[1] : 'unknown' }; + } else if (ua.includes('Safari') && !ua.includes('Chrome')) { + const match = ua.match(/Version\/(\d+)/); + browser = { name: 'safari', version: match ? match[1] : 'unknown' }; + } else if (ua.includes('Firefox')) { + const match = ua.match(/Firefox\/(\d+)/); + browser = { name: 'firefox', version: match ? match[1] : 'unknown' }; + } else if (ua.includes('Edg')) { + const match = ua.match(/Edg\/(\d+)/); + browser = { name: 'edge', version: match ? match[1] : 'unknown' }; + } + + console.log('[Voice] Detected browser:', browser.name, browser.version); + return browser; + }, + + /** + * Get all supported MIME types for MediaRecorder + * @returns {string[]} Array of supported MIME types + */ + getSupportedMimeTypes() { + const types = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus', + 'audio/ogg', + 'audio/mp4', + 'audio/mp4;codecs=mp4a.40.2', + 'audio/mpeg', + 'audio/wav', + 'audio/aac' + ]; + + return types.filter(type => { + try { + return MediaRecorder.isTypeSupported(type); + } catch (e) { + return false; + } + }); + }, + + // ========================================================================== + // PUBLIC API - COMPATIBILITY CHECK + // ========================================================================== + + /** + * Simple check if voice input is supported + * @returns {boolean} Support status + */ + isSupported() { + // Use cached capabilities if available + if (this._capabilities) { + return this._capabilities.supported; + } + + // Quick check without full diagnostics + return !!( + this.isSecureContext() && + navigator.mediaDevices && + navigator.mediaDevices.getUserMedia && + window.MediaRecorder + ); + }, + + /** + * Get detailed capability report + * @returns {object} Capability details + */ + getCapabilities() { + if (!this._capabilities) { + this._capabilities = this.checkCapabilities(); + } + return this._capabilities; + }, + + /** + * Get human-readable error message for unsupported browsers + * @returns {string} Error message + */ + getUnsupportedMessage() { + const caps = this.getCapabilities(); + + if (caps.supported) { + return null; + } + + // Provide specific guidance based on what's missing + if (!caps.secureContext) { + return 'Voice input requires a secure connection. Please access via HTTPS or localhost.'; + } + + if (!caps.mediaDevices || !caps.getUserMedia) { + if (caps.browser.name === 'safari') { + return 'Voice input requires Safari 11 or later. Please update your browser.'; + } + return 'Your browser does not support voice input. Please use Chrome, Firefox, or Edge.'; + } + + if (!caps.mediaRecorder) { + return 'Your browser does not support audio recording. Please use a modern browser.'; + } + + return 'Voice input is not supported in this browser configuration.'; + }, + + // ========================================================================== + // PUBLIC API - PERMISSIONS + // ========================================================================== + + /** + * Request microphone permission + * @returns {Promise} Permission granted + */ + async requestPermission() { + // Check capabilities first + if (!this.isSupported()) { + const message = this.getUnsupportedMessage(); + console.error('[Voice] Cannot request permission:', message); + this.onError(message); + return false; + } + + try { + console.log('[Voice] Requesting microphone permission...'); + + // Request permission with audio constraints + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: { ideal: 16000 }, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + // Permission granted - stop the test stream immediately + stream.getTracks().forEach(track => { + track.stop(); + console.log('[Voice] Test track stopped:', track.label); + }); + + console.log('[Voice] ✅ Microphone permission granted'); + return true; + + } catch (error) { + console.error('[Voice] Permission error:', error.name, error.message); + + // Handle specific error types + let errorMessage; + + switch (error.name) { + case 'NotAllowedError': + case 'PermissionDeniedError': + errorMessage = 'Microphone access denied. Please allow microphone permission in your browser settings.'; + this.onPermissionDenied(); + break; + + case 'NotFoundError': + case 'DevicesNotFoundError': + errorMessage = 'No microphone found. Please connect a microphone and try again.'; + break; + + case 'NotReadableError': + case 'TrackStartError': + errorMessage = 'Microphone is in use by another application. Please close other apps using the microphone.'; + break; + + case 'OverconstrainedError': + // Try again with simpler constraints + console.log('[Voice] Retrying with basic audio constraints...'); + return await this.requestPermissionBasic(); + + case 'AbortError': + errorMessage = 'Microphone access was aborted. Please try again.'; + break; + + case 'SecurityError': + errorMessage = 'Microphone access blocked due to security policy. Please use HTTPS.'; + break; + + default: + errorMessage = `Microphone error: ${error.message || error.name}`; + } + + this.onError(errorMessage); + return false; + } + }, + + /** + * Fallback permission request with basic constraints + * @returns {Promise} Permission granted + */ + async requestPermissionBasic() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach(track => track.stop()); + console.log('[Voice] ✅ Microphone permission granted (basic)'); + return true; + } catch (error) { + console.error('[Voice] Basic permission also failed:', error); + this.onError('Microphone access denied. Please check browser permissions.'); + this.onPermissionDenied(); + return false; + } + }, + + /** + * Check current permission status without prompting + * @returns {Promise} Permission state: 'granted', 'denied', 'prompt', or 'unknown' + */ + async checkPermissionStatus() { + try { + // Use Permissions API if available + if (navigator.permissions && navigator.permissions.query) { + const result = await navigator.permissions.query({ name: 'microphone' }); + console.log('[Voice] Permission status:', result.state); + return result.state; + } + } catch (e) { + // Permissions API not supported or microphone not queryable + console.log('[Voice] Permissions API not available for microphone'); + } + + return 'unknown'; + }, + + // ========================================================================== + // PUBLIC API - RECORDING + // ========================================================================== + + /** + * Start recording audio + * @returns {Promise} Started successfully + */ + async startRecording() { + // Prevent double recording + if (this.isRecording) { + console.warn('[Voice] Already recording'); + return false; + } + + // Check support + if (!this.isSupported()) { + const message = this.getUnsupportedMessage(); + this.onError(message); + return false; + } + + try { + console.log('[Voice] Starting recording...'); + + // Get audio stream with optimal settings for speech + this.stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: { ideal: 16000, min: 8000, max: 48000 }, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + + console.log('[Voice] Audio stream acquired'); + + // Get best MIME type for this browser + const mimeType = this.getBestMimeType(); + console.log('[Voice] Using MIME type:', mimeType || 'default'); + + // Create MediaRecorder with options + const options = {}; + if (mimeType) { + options.mimeType = mimeType; + } + + // Add bitrate for better quality/size balance + options.audioBitsPerSecond = 128000; + + try { + this.mediaRecorder = new MediaRecorder(this.stream, options); + } catch (e) { + // Fallback without options if it fails + console.warn('[Voice] MediaRecorder with options failed, using defaults'); + this.mediaRecorder = new MediaRecorder(this.stream); + } + + this.audioChunks = []; + + // Handle data available + this.mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + this.audioChunks.push(event.data); + console.log('[Voice] Chunk received:', event.data.size, 'bytes'); + } + }; + + // Handle recording stop + this.mediaRecorder.onstop = () => { + console.log('[Voice] MediaRecorder stopped, processing...'); + this.processRecording(); + }; + + // Handle errors + this.mediaRecorder.onerror = (event) => { + console.error('[Voice] MediaRecorder error:', event.error); + this.onError('Recording error: ' + (event.error?.message || 'Unknown error')); + this.cleanup(); + }; + + // Start recording - collect data every 500ms for responsive UI + this.mediaRecorder.start(500); + this.isRecording = true; + this.recordingStartTime = Date.now(); + + // Set maximum duration timer + this.recordingTimer = setTimeout(() => { + if (this.isRecording) { + console.log('[Voice] Maximum duration reached, stopping...'); + this.stopRecording(); + } + }, this.maxDuration); + + // Notify callback + this.onRecordingStart(); + console.log('[Voice] ✅ Recording started'); + + return true; + + } catch (error) { + console.error('[Voice] Start recording failed:', error); + + // Handle specific errors + if (error.name === 'NotAllowedError') { + this.onError('Microphone permission denied. Please allow access.'); + this.onPermissionDenied(); + } else if (error.name === 'NotFoundError') { + this.onError('No microphone found. Please connect a microphone.'); + } else { + this.onError('Failed to start recording: ' + (error.message || error.name)); + } + + this.cleanup(); + return false; + } + }, + + /** + * Stop recording + */ + stopRecording() { + if (!this.isRecording) { + console.log('[Voice] Not recording, nothing to stop'); + return; + } + + console.log('[Voice] Stopping recording...'); + + // Clear max duration timer + if (this.recordingTimer) { + clearTimeout(this.recordingTimer); + this.recordingTimer = null; + } + + // Check recording duration + const duration = Date.now() - (this.recordingStartTime || Date.now()); + if (duration < this.minDuration) { + console.warn('[Voice] Recording too short:', duration, 'ms'); + } + + // Stop the MediaRecorder (triggers onstop -> processRecording) + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + try { + this.mediaRecorder.stop(); + } catch (e) { + console.warn('[Voice] Error stopping MediaRecorder:', e); + } + } + + // Stop all audio tracks + if (this.stream) { + this.stream.getTracks().forEach(track => { + track.stop(); + console.log('[Voice] Track stopped:', track.label); + }); + } + + this.isRecording = false; + this.onRecordingStop(); + console.log('[Voice] Recording stopped after', duration, 'ms'); + }, + + /** + * Toggle recording state + * @returns {Promise} New recording state (true = now recording) + */ + async toggleRecording() { + if (this.isRecording) { + this.stopRecording(); + return false; + } else { + return await this.startRecording(); + } + }, + + /** + * Cancel recording without processing + */ + cancelRecording() { + console.log('[Voice] Cancelling recording...'); + + // Clear timer + if (this.recordingTimer) { + clearTimeout(this.recordingTimer); + this.recordingTimer = null; + } + + // Remove the onstop handler to prevent processing + if (this.mediaRecorder) { + this.mediaRecorder.onstop = null; + + if (this.mediaRecorder.state !== 'inactive') { + try { + this.mediaRecorder.stop(); + } catch (e) { + // Ignore errors during cancel + } + } + } + + this.cleanup(); + this.onRecordingStop(); + console.log('[Voice] Recording cancelled'); + }, + + // ========================================================================== + // AUDIO PROCESSING + // ========================================================================== + + /** + * Process recorded audio and send for transcription + */ + async processRecording() { + // Check if we have audio data + if (!this.audioChunks || this.audioChunks.length === 0) { + console.warn('[Voice] No audio chunks to process'); + this.onError('No audio recorded. Please try again.'); + this.cleanup(); + return; + } + + try { + // Create blob from chunks + const mimeType = this.mediaRecorder?.mimeType || 'audio/webm'; + const audioBlob = new Blob(this.audioChunks, { type: mimeType }); + + console.log('[Voice] Processing audio blob:', { + size: audioBlob.size, + type: mimeType, + chunks: this.audioChunks.length + }); + + // Validate blob size + if (audioBlob.size < 1000) { + console.warn('[Voice] Audio blob too small:', audioBlob.size); + this.onError('Recording too short. Please speak longer.'); + this.cleanup(); + return; + } + + // Get file extension + const extension = this.getExtensionFromMimeType(mimeType); + const filename = `recording_${Date.now()}.${extension}`; + + // Create File object for upload + const audioFile = new File([audioBlob], filename, { type: mimeType }); + + console.log('[Voice] Sending for transcription:', filename); + + // Get current language for hint + const languageHint = typeof I18n !== 'undefined' ? I18n.getLanguage() : 'en'; + + // Send to backend + const result = await this.sendForTranscription(audioFile, languageHint); + + if (result.success && result.text) { + console.log('[Voice] ✅ Transcription successful:', result.text); + this.onTranscription(result.text, result); + } else { + const errorMsg = result.error || 'Transcription failed. Please try again.'; + console.error('[Voice] Transcription failed:', errorMsg); + this.onError(errorMsg); + } + + } catch (error) { + console.error('[Voice] Processing error:', error); + this.onError('Failed to process recording: ' + (error.message || 'Unknown error')); + } finally { + this.cleanup(); + } + }, + + /** + * Send audio file to backend for transcription + * @param {File} audioFile - Audio file to transcribe + * @param {string} languageHint - Language hint (en, ha, yo, ig) + * @returns {Promise} Transcription result + */ + async sendForTranscription(audioFile, languageHint = 'en') { + // Check if FarmEyesAPI is available + if (typeof FarmEyesAPI !== 'undefined' && FarmEyesAPI.transcribeAudio) { + return await FarmEyesAPI.transcribeAudio(audioFile, languageHint); + } + + // Fallback: Direct API call + console.log('[Voice] Using direct API call for transcription'); + + try { + const formData = new FormData(); + formData.append('audio', audioFile); + formData.append('language_hint', languageHint); + + const response = await fetch('/api/transcribe', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || `HTTP ${response.status}`); + } + + return await response.json(); + + } catch (error) { + console.error('[Voice] Transcription API error:', error); + return { + success: false, + error: error.message || 'Failed to connect to transcription service' + }; + } + }, + + // ========================================================================== + // UTILITY METHODS + // ========================================================================== + + /** + * Get the best MIME type for the current browser + * @returns {string|null} Best supported MIME type + */ + getBestMimeType() { + // Preferred order: webm with opus is best for speech + const preferredTypes = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus', + 'audio/ogg', + 'audio/mp4', + 'audio/wav' + ]; + + for (const type of preferredTypes) { + try { + if (MediaRecorder.isTypeSupported(type)) { + return type; + } + } catch (e) { + // Continue to next type + } + } + + return null; + }, + + /** + * Get file extension from MIME type + * @param {string} mimeType - MIME type + * @returns {string} File extension + */ + getExtensionFromMimeType(mimeType) { + const mimeToExt = { + 'audio/webm': 'webm', + 'audio/webm;codecs=opus': 'webm', + 'audio/ogg': 'ogg', + 'audio/ogg;codecs=opus': 'ogg', + 'audio/mp4': 'm4a', + 'audio/mp4;codecs=mp4a.40.2': 'm4a', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/aac': 'aac' + }; + + // Handle MIME types with parameters + const baseMime = mimeType.split(';')[0]; + return mimeToExt[mimeType] || mimeToExt[baseMime] || 'webm'; + }, + + /** + * Cleanup all resources + */ + cleanup() { + console.log('[Voice] Cleaning up resources...'); + + // Clear audio chunks + this.audioChunks = []; + + // Clear MediaRecorder + if (this.mediaRecorder) { + this.mediaRecorder.ondataavailable = null; + this.mediaRecorder.onstop = null; + this.mediaRecorder.onerror = null; + this.mediaRecorder = null; + } + + // Stop and clear stream + if (this.stream) { + this.stream.getTracks().forEach(track => { + try { + track.stop(); + } catch (e) { + // Ignore errors during cleanup + } + }); + this.stream = null; + } + + // Clear timer + if (this.recordingTimer) { + clearTimeout(this.recordingTimer); + this.recordingTimer = null; + } + + // Reset state + this.isRecording = false; + this.recordingStartTime = null; + }, + + /** + * Get current recording state + * @returns {boolean} Is currently recording + */ + getIsRecording() { + return this.isRecording; + }, + + /** + * Get recording duration in milliseconds + * @returns {number} Duration in ms, or 0 if not recording + */ + getRecordingDuration() { + if (!this.isRecording || !this.recordingStartTime) { + return 0; + } + return Date.now() - this.recordingStartTime; + }, + + // ========================================================================== + // DIAGNOSTIC METHODS + // ========================================================================== + + /** + * Run full diagnostic and log to console + * Useful for debugging issues + */ + runDiagnostic() { + console.group('[Voice] Running Diagnostic'); + + console.log('=== Browser Info ==='); + console.log('User Agent:', navigator.userAgent); + console.log('Platform:', navigator.platform); + + console.log('\n=== Security Context ==='); + console.log('URL:', window.location.href); + console.log('Protocol:', window.location.protocol); + console.log('Hostname:', window.location.hostname); + console.log('isSecureContext:', window.isSecureContext); + console.log('Our check:', this.isSecureContext()); + + console.log('\n=== API Availability ==='); + console.log('navigator:', typeof navigator); + console.log('navigator.mediaDevices:', typeof navigator.mediaDevices); + console.log('getUserMedia:', typeof navigator.mediaDevices?.getUserMedia); + console.log('MediaRecorder:', typeof window.MediaRecorder); + console.log('AudioContext:', typeof (window.AudioContext || window.webkitAudioContext)); + + console.log('\n=== MediaRecorder MIME Types ==='); + if (window.MediaRecorder) { + const types = this.getSupportedMimeTypes(); + types.forEach(type => console.log(' ✓', type)); + if (types.length === 0) { + console.log(' ❌ No supported MIME types'); + } + } + + console.log('\n=== Full Capabilities ==='); + const caps = this.checkCapabilities(); + console.log('Supported:', caps.supported); + console.log('Issues:', caps.issues); + + console.groupEnd(); + + return caps; + } +}; + +// ========================================================================== +// EXPORT +// ========================================================================== + +// Export for use in other modules +window.VoiceInput = VoiceInput; + +// Auto-run diagnostic in development +if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + // Delay diagnostic to ensure page is fully loaded + setTimeout(() => { + console.log('[Voice] Development mode - running diagnostic...'); + VoiceInput.runDiagnostic(); + }, 1000); +} diff --git a/gitignore b/gitignore new file mode 100644 index 0000000000000000000000000000000000000000..917b7e2c300c155e5efcffea13765dae909f669f --- /dev/null +++ b/gitignore @@ -0,0 +1,119 @@ +# ============================================================================= +# FarmEyes - .gitignore for HuggingFace Spaces +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Python +# ----------------------------------------------------------------------------- +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# ----------------------------------------------------------------------------- +# Virtual Environments +# ----------------------------------------------------------------------------- +env/ +venv/ +.venv/ +ENV/ +env.bak/ +venv.bak/ + +# ----------------------------------------------------------------------------- +# Environment Variables (NEVER commit secrets!) +# ----------------------------------------------------------------------------- +.env +.env.local +.env.*.local +*.env + +# ----------------------------------------------------------------------------- +# IDE & Editors +# ----------------------------------------------------------------------------- +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project +.project +.pydevproject +.settings/ + +# ----------------------------------------------------------------------------- +# macOS +# ----------------------------------------------------------------------------- +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# ----------------------------------------------------------------------------- +# Large Model Files (Download at runtime instead) +# ----------------------------------------------------------------------------- +# GGUF models are downloaded from HuggingFace Hub at runtime +*.gguf +*.bin +*.safetensors + +# Note: YOLOv11 model (farmeyes_yolov11.pt) IS uploaded +# because it's only 19.2MB + +# ----------------------------------------------------------------------------- +# Logs & Temp Files +# ----------------------------------------------------------------------------- +*.log +logs/ +temp/ +tmp/ +*.tmp +*.temp + +# ----------------------------------------------------------------------------- +# User Uploads & Generated Files +# ----------------------------------------------------------------------------- +uploads/ +outputs/ +*.wav +*.mp3 +*.ogg + +# ----------------------------------------------------------------------------- +# Jupyter Notebooks +# ----------------------------------------------------------------------------- +.ipynb_checkpoints/ +*.ipynb + +# ----------------------------------------------------------------------------- +# Testing +# ----------------------------------------------------------------------------- +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# ----------------------------------------------------------------------------- +# HuggingFace Cache (created at runtime) +# ----------------------------------------------------------------------------- +.cache/ +huggingface/ diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..a157211a16bdcc5ad45561443ef472c88d591501 --- /dev/null +++ b/main.py @@ -0,0 +1,442 @@ +""" +FarmEyes Main Application +========================= +FastAPI backend server for FarmEyes crop disease detection. + +FIXED: +- Preloads GGUF model at startup for better performance +- Serves static files correctly for frontend + +Run: python main.py +""" + +import os +import sys +from pathlib import Path +from contextlib import asynccontextmanager +from datetime import datetime +import logging + +# Add project root to path +PROJECT_ROOT = Path(__file__).parent.resolve() +sys.path.insert(0, str(PROJECT_ROOT)) + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse +import uvicorn + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# APPLICATION LIFESPAN +# ============================================================================= + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Application lifespan manager. + Handles startup and shutdown events. + FIXED: Preloads GGUF model for better chat performance. + """ + # STARTUP + logger.info("=" * 60) + logger.info("🌱 FarmEyes Starting Up...") + logger.info("=" * 60) + + # Print config + try: + from config import print_config_summary + print_config_summary() + except ImportError as e: + logger.warning(f"Could not load config: {e}") + + # Initialize session manager + try: + from services.session_manager import get_session_manager + get_session_manager() + logger.info("✅ Session manager initialized") + except Exception as e: + logger.warning(f"Session manager init failed: {e}") + + # PRELOAD GGUF MODEL FOR PERFORMANCE + try: + from models.natlas_model import get_natlas_model + logger.info("🔄 Preloading N-ATLaS GGUF model...") + model = get_natlas_model(auto_load_local=True) + if model.local_model.is_loaded: + logger.info("✅ N-ATLaS GGUF model preloaded successfully!") + else: + logger.warning("⚠️ GGUF model not loaded - will load on first use") + except Exception as e: + logger.warning(f"⚠️ GGUF model preload failed: {e}") + logger.warning(" Model will load on first use (slower first request)") + + logger.info("=" * 60) + logger.info("🚀 FarmEyes Ready!") + logger.info("=" * 60) + + yield # Application runs + + # SHUTDOWN + logger.info("=" * 60) + logger.info("🛑 FarmEyes Shutting Down...") + logger.info("=" * 60) + + try: + from services.whisper_service import unload_whisper_service + unload_whisper_service() + except Exception: + pass + + try: + from models.natlas_model import unload_natlas_model + unload_natlas_model() + except Exception: + pass + + logger.info("👋 Goodbye!") + + +# ============================================================================= +# CREATE APPLICATION +# ============================================================================= + +app = FastAPI( + title="FarmEyes API", + description="AI-Powered Crop Disease Detection for African Farmers", + version="2.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", + lifespan=lifespan +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ============================================================================= +# REQUEST LOGGING MIDDLEWARE +# ============================================================================= + +@app.middleware("http") +async def log_requests(request: Request, call_next): + """Log all requests with timing.""" + start_time = datetime.now() + + response = await call_next(request) + + # Skip logging for static files + if not request.url.path.startswith("/static"): + duration = (datetime.now() - start_time).total_seconds() * 1000 + logger.info(f"{request.method} {request.url.path} - {response.status_code} - {duration:.1f}ms") + + return response + + +# ============================================================================= +# INCLUDE API ROUTERS +# ============================================================================= + +try: + from api.routes.detection import router as detection_router + app.include_router(detection_router) + logger.info("✅ Detection routes loaded") +except ImportError as e: + logger.error(f"Failed to load detection routes: {e}") + +try: + from api.routes.chat import router as chat_router + app.include_router(chat_router) + logger.info("✅ Chat routes loaded") +except ImportError as e: + logger.error(f"Failed to load chat routes: {e}") + +try: + from api.routes.transcribe import router as transcribe_router + app.include_router(transcribe_router) + logger.info("✅ Transcribe routes loaded") +except ImportError as e: + logger.error(f"Failed to load transcribe routes: {e}") + +try: + from api.routes.tts import router as tts_router + app.include_router(tts_router) + logger.info("✅ TTS routes loaded") +except ImportError as e: + logger.error(f"Failed to load TTS routes: {e}") + + +# ============================================================================= +# STATIC FILES +# ============================================================================= + +# Mount static files for CSS, JS +static_dir = PROJECT_ROOT / "frontend" +if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + logger.info(f"✅ Static files mounted from: {static_dir}") +else: + logger.warning(f"⚠️ Frontend directory not found: {static_dir}") + + +# ============================================================================= +# ROOT ENDPOINTS +# ============================================================================= + +@app.get("/", response_class=HTMLResponse) +async def root(): + """Serve the frontend application.""" + index_path = PROJECT_ROOT / "frontend" / "index.html" + + if index_path.exists(): + return FileResponse(index_path) + else: + return HTMLResponse(content=""" + + + FarmEyes + +

🌱 FarmEyes API

+

Frontend not found. API is running.

+

Visit /api/docs for API documentation.

+ + + """) + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return { + "status": "healthy", + "service": "FarmEyes", + "version": "2.0.0", + "timestamp": datetime.now().isoformat() + } + + +@app.get("/api") +async def api_info(): + """API information endpoint.""" + return { + "name": "FarmEyes API", + "version": "2.0.0", + "description": "AI-Powered Crop Disease Detection for African Farmers", + "endpoints": { + "detection": "/api/detect", + "chat": "/api/chat", + "transcribe": "/api/transcribe", + "docs": "/api/docs" + }, + "supported_languages": ["en", "ha", "yo", "ig"], + "supported_crops": ["cassava", "cocoa", "tomato"] + } + + +# ============================================================================= +# SESSION ENDPOINTS +# ============================================================================= + +@app.get("/api/session") +async def create_session(language: str = "en"): + """Create a new session.""" + try: + from services.session_manager import get_session_manager + + session_manager = get_session_manager() + session = session_manager.create_session(language) + + # Note: created_at is already an ISO format string from session_manager + return { + "success": True, + "session_id": session.session_id, + "language": session.language, + "created_at": session.created_at + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/session/{session_id}") +async def get_session(session_id: str): + """Get session information.""" + try: + from services.session_manager import get_session_manager + + session_manager = get_session_manager() + session = session_manager.get_session(session_id) + + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + return { + "success": True, + "session_id": session.session_id, + "language": session.language, + "has_diagnosis": session.diagnosis is not None, + "chat_messages": len(session.chat_history), + "created_at": session.created_at, # Already ISO string + "last_accessed": session.last_accessed # Unix timestamp float + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/session/{session_id}/language") +async def update_session_language(session_id: str, language: str = "en"): + """Update session language.""" + try: + from services.session_manager import get_session_manager + + valid_languages = ["en", "ha", "yo", "ig"] + if language not in valid_languages: + raise HTTPException(status_code=400, detail=f"Invalid language. Use: {valid_languages}") + + session_manager = get_session_manager() + success = session_manager.set_language(session_id, language) + + if not success: + raise HTTPException(status_code=404, detail="Session not found") + + return { + "success": True, + "session_id": session_id, + "language": language + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/api/session/{session_id}") +async def delete_session(session_id: str): + """Delete a session.""" + try: + from services.session_manager import get_session_manager + + session_manager = get_session_manager() + success = session_manager.delete_session(session_id) + + return { + "success": success, + "session_id": session_id + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# TRANSLATIONS ENDPOINT +# ============================================================================= + +@app.get("/api/translations") +async def get_translations(language: str = "en"): + """Get UI translations.""" + try: + translations_path = PROJECT_ROOT / "static" / "ui_translations.json" + + if translations_path.exists(): + import json + with open(translations_path, "r", encoding="utf-8") as f: + all_translations = json.load(f) + + lang_translations = all_translations.get(language, all_translations.get("en", {})) + + return { + "success": True, + "language": language, + "translations": lang_translations + } + else: + return { + "success": False, + "language": language, + "translations": {}, + "error": "Translations file not found" + } + except Exception as e: + return { + "success": False, + "language": language, + "translations": {}, + "error": str(e) + } + + +# ============================================================================= +# ERROR HANDLERS +# ============================================================================= + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc: HTTPException): + """Handle 404 errors - serve SPA for non-API routes.""" + if not request.url.path.startswith("/api"): + index_path = PROJECT_ROOT / "frontend" / "index.html" + if index_path.exists(): + return FileResponse(index_path) + + return JSONResponse( + status_code=404, + content={"error": "Not found", "path": request.url.path} + ) + + +@app.exception_handler(500) +async def server_error_handler(request: Request, exc: Exception): + """Handle 500 errors.""" + logger.error(f"Server error: {exc}") + return JSONResponse( + status_code=500, + content={"error": "Internal server error"} + ) + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + +if __name__ == "__main__": + # Check if running on HuggingFace Spaces + is_spaces = os.environ.get("SPACE_ID") is not None + + if is_spaces: + # HuggingFace Spaces config - must use 0.0.0.0 for external access + host = "0.0.0.0" + port = 7860 + reload = False + else: + # Local development config + # FIXED: Use 127.0.0.1 instead of 0.0.0.0 for secure context + # This allows navigator.mediaDevices (microphone) to work in Chrome + # Access via http://localhost:7860 (NOT http://0.0.0.0:7860) + host = os.environ.get("HOST", "127.0.0.1") + port = int(os.environ.get("PORT", 7860)) + reload = os.environ.get("RELOAD", "false").lower() == "true" + + logger.info(f"Starting server on {host}:{port}") + logger.info(f"Access the app at: http://localhost:{port}") + + uvicorn.run( + "main:app", + host=host, + port=port, + reload=reload, + log_level="info" + ) diff --git a/models/.DS_Store b/models/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d071559e83f3bee4d16b92068889597067659a79 Binary files /dev/null and b/models/.DS_Store differ diff --git a/models/.ipynb_checkpoints/__init__-checkpoint.py b/models/.ipynb_checkpoints/__init__-checkpoint.py new file mode 100644 index 0000000000000000000000000000000000000000..80e40e5bc285a61ae4b19f39710aaa6230bd0a73 --- /dev/null +++ b/models/.ipynb_checkpoints/__init__-checkpoint.py @@ -0,0 +1,31 @@ +""" +FarmEyes Models Package +======================= +Machine learning model integrations for FarmEyes. +- N-ATLaS: Multilingual language model for translation and text generation +- YOLOv11: Computer vision model for disease detection +""" + +from .natlas_model import ( + NATLaSModel, + get_natlas_model, + unload_natlas_model, + translate_text, + generate_diagnosis +) + +# YOLOv11 model will be added in next step +# from .yolo_model import YOLOModel, get_yolo_model + +__all__ = [ + # N-ATLaS exports + "NATLaSModel", + "get_natlas_model", + "unload_natlas_model", + "translate_text", + "generate_diagnosis", + + # YOLO exports (to be added) + # "YOLOModel", + # "get_yolo_model", +] diff --git a/models/.ipynb_checkpoints/natlas_model-checkpoint.py b/models/.ipynb_checkpoints/natlas_model-checkpoint.py new file mode 100644 index 0000000000000000000000000000000000000000..976b11b8f0b925467fe5319ff6915e05d2bda6ea --- /dev/null +++ b/models/.ipynb_checkpoints/natlas_model-checkpoint.py @@ -0,0 +1,787 @@ +""" +FarmEyes N-ATLaS Model Integration +================================== +Handles loading and inference with N-ATLaS GGUF model using llama-cpp-python. +Optimized for Apple Silicon M1 Pro with Metal acceleration. + +Model: tosinamuda/N-ATLaS-GGUF (8B parameters, 16-bit quantized) +Supported Languages: English, Hausa, Yoruba, Igbo +""" + +import os +import sys +from pathlib import Path +from typing import Optional, Dict, List, Generator +from dataclasses import dataclass +import logging + +# Add parent directory to path for imports +sys.path.append(str(Path(__file__).parent.parent)) + +from config import natlas_config, device_config +from utils.prompt_templates import ( + TranslationPrompts, + DiagnosisPrompts, + ReportPrompts, + ConversationalPrompts, + get_system_prompt, + format_prompt_for_natlas, + LANGUAGE_NAMES +) + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# N-ATLaS MODEL CLASS +# ============================================================================= + +class NATLaSModel: + """ + N-ATLaS Language Model wrapper for FarmEyes application. + Uses llama-cpp-python for GGUF model inference with Metal acceleration. + """ + + def __init__( + self, + model_path: Optional[str] = None, + n_ctx: int = 4096, + n_gpu_layers: int = -1, + n_threads: int = 8, + n_batch: int = 512, + verbose: bool = False + ): + """ + Initialize N-ATLaS model. + + Args: + model_path: Path to GGUF model file (downloads if not exists) + n_ctx: Context window size + n_gpu_layers: Number of layers to offload to GPU (-1 for all) + n_threads: Number of CPU threads + n_batch: Batch size for prompt processing + verbose: Enable verbose output from llama.cpp + """ + self.model_path = model_path or str(natlas_config.gguf_path) + self.n_ctx = n_ctx + self.n_gpu_layers = n_gpu_layers + self.n_threads = n_threads + self.n_batch = n_batch + self.verbose = verbose + + # Model instance (lazy loaded) + self._model = None + self._is_loaded = False + + logger.info(f"NATLaSModel initialized with config:") + logger.info(f" Model path: {self.model_path}") + logger.info(f" Context length: {self.n_ctx}") + logger.info(f" GPU layers: {self.n_gpu_layers}") + + # ========================================================================= + # MODEL LOADING + # ========================================================================= + + def download_model(self) -> str: + """ + Download N-ATLaS GGUF model from HuggingFace if not already present. + + Returns: + Path to the downloaded model file + """ + from huggingface_hub import hf_hub_download + + model_dir = Path(self.model_path).parent + model_dir.mkdir(parents=True, exist_ok=True) + + # Check if model already exists + if Path(self.model_path).exists(): + logger.info(f"Model already exists at {self.model_path}") + return self.model_path + + logger.info(f"Downloading N-ATLaS model from HuggingFace...") + logger.info(f" Repository: {natlas_config.hf_repo}") + logger.info(f" Filename: {natlas_config.model_filename}") + + try: + # Download from HuggingFace Hub + downloaded_path = hf_hub_download( + repo_id=natlas_config.hf_repo, + filename=natlas_config.model_filename, + local_dir=str(model_dir), + local_dir_use_symlinks=False + ) + + logger.info(f"Model downloaded successfully to {downloaded_path}") + return downloaded_path + + except Exception as e: + logger.error(f"Failed to download model: {e}") + raise RuntimeError(f"Could not download N-ATLaS model: {e}") + + def load_model(self) -> bool: + """ + Load the N-ATLaS GGUF model into memory. + + Returns: + True if model loaded successfully + """ + if self._is_loaded: + logger.info("Model already loaded") + return True + + try: + from llama_cpp import Llama + + # Ensure model is downloaded + model_path = self.download_model() + + logger.info("Loading N-ATLaS model into memory...") + logger.info(" This may take a minute for the first load...") + + # Initialize Llama model with Metal acceleration + self._model = Llama( + model_path=model_path, + n_ctx=self.n_ctx, + n_gpu_layers=self.n_gpu_layers, # -1 = offload all to GPU + n_threads=self.n_threads, + n_batch=self.n_batch, + verbose=self.verbose, + # Metal-specific settings for Apple Silicon + use_mlock=True, # Lock model in RAM + use_mmap=True, # Memory-map the model + ) + + self._is_loaded = True + logger.info("✅ N-ATLaS model loaded successfully!") + logger.info(f" Context window: {self.n_ctx} tokens") + logger.info(f" GPU acceleration: {'Enabled (Metal)' if self.n_gpu_layers != 0 else 'Disabled'}") + + return True + + except ImportError: + logger.error("llama-cpp-python not installed!") + logger.error("Install with: CMAKE_ARGS=\"-DLLAMA_METAL=on\" pip install llama-cpp-python") + raise ImportError("llama-cpp-python is required. Install with Metal support for Apple Silicon.") + + except Exception as e: + logger.error(f"Failed to load model: {e}") + self._is_loaded = False + raise RuntimeError(f"Could not load N-ATLaS model: {e}") + + def unload_model(self): + """Unload model from memory to free resources.""" + if self._model is not None: + del self._model + self._model = None + self._is_loaded = False + logger.info("Model unloaded from memory") + + @property + def is_loaded(self) -> bool: + """Check if model is currently loaded.""" + return self._is_loaded + + # ========================================================================= + # TEXT GENERATION + # ========================================================================= + + def generate( + self, + prompt: str, + max_tokens: int = 512, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + repeat_penalty: float = 1.1, + stop: Optional[List[str]] = None, + system_prompt: Optional[str] = None + ) -> str: + """ + Generate text completion using N-ATLaS model. + + Args: + prompt: Input prompt/instruction + max_tokens: Maximum tokens to generate + temperature: Sampling temperature (0.0 = deterministic) + top_p: Nucleus sampling parameter + top_k: Top-k sampling parameter + repeat_penalty: Penalty for repeating tokens + stop: List of stop sequences + system_prompt: Optional system prompt to prepend + + Returns: + Generated text response + """ + # Ensure model is loaded + if not self._is_loaded: + self.load_model() + + # Format prompt with system instruction if provided + if system_prompt: + full_prompt = format_prompt_for_natlas(system_prompt, prompt) + else: + full_prompt = prompt + + # Default stop sequences + if stop is None: + stop = ["<|eot_id|>", "<|end_of_text|>", "\n\n\n"] + + try: + # Generate completion + response = self._model( + full_prompt, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + top_k=top_k, + repeat_penalty=repeat_penalty, + stop=stop, + echo=False # Don't include prompt in response + ) + + # Extract generated text + generated_text = response["choices"][0]["text"].strip() + + return generated_text + + except Exception as e: + logger.error(f"Generation error: {e}") + raise RuntimeError(f"Text generation failed: {e}") + + def generate_stream( + self, + prompt: str, + max_tokens: int = 512, + temperature: float = 0.7, + system_prompt: Optional[str] = None + ) -> Generator[str, None, None]: + """ + Generate text with streaming output. + + Args: + prompt: Input prompt + max_tokens: Maximum tokens to generate + temperature: Sampling temperature + system_prompt: Optional system prompt + + Yields: + Generated text tokens one at a time + """ + if not self._is_loaded: + self.load_model() + + if system_prompt: + full_prompt = format_prompt_for_natlas(system_prompt, prompt) + else: + full_prompt = prompt + + try: + # Stream generation + for output in self._model( + full_prompt, + max_tokens=max_tokens, + temperature=temperature, + stream=True, + stop=["<|eot_id|>", "<|end_of_text|>"] + ): + token = output["choices"][0]["text"] + yield token + + except Exception as e: + logger.error(f"Streaming generation error: {e}") + raise RuntimeError(f"Streaming generation failed: {e}") + + # ========================================================================= + # TRANSLATION METHODS + # ========================================================================= + + def translate( + self, + text: str, + target_language: str, + temperature: float = 0.3 + ) -> str: + """ + Translate text to target language. + + Args: + text: Text to translate + target_language: Target language code (ha, yo, ig, en) + temperature: Lower temperature for more accurate translation + + Returns: + Translated text + """ + # Validate language + if target_language not in LANGUAGE_NAMES: + raise ValueError(f"Unsupported language: {target_language}") + + # If target is English and text appears to be English, return as-is + if target_language == "en": + return text + + # Create translation prompt + prompt = TranslationPrompts.translate_text(text, target_language) + system = get_system_prompt("translation") + + # Generate translation with lower temperature for accuracy + translation = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=len(text) * 3, # Allow for expansion + temperature=temperature, + repeat_penalty=1.0 # Disable repeat penalty for translation + ) + + return translation.strip() + + def translate_disease_name( + self, + disease_name: str, + target_language: str + ) -> str: + """ + Translate a disease name to target language. + + Args: + disease_name: Disease name in English + target_language: Target language code + + Returns: + Translated disease name + """ + if target_language == "en": + return disease_name + + prompt = TranslationPrompts.translate_disease_name(disease_name, target_language) + system = get_system_prompt("translation") + + translation = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=100, + temperature=0.3 + ) + + return translation.strip() + + def translate_symptoms( + self, + symptoms: List[str], + target_language: str + ) -> List[str]: + """ + Translate a list of symptoms to target language. + + Args: + symptoms: List of symptom descriptions + target_language: Target language code + + Returns: + List of translated symptoms + """ + if target_language == "en": + return symptoms + + prompt = TranslationPrompts.translate_symptoms(symptoms, target_language) + system = get_system_prompt("translation") + + translation = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=len(" ".join(symptoms)) * 3, + temperature=0.3 + ) + + # Parse translated symptoms (expecting dash-separated list) + translated = [] + for line in translation.strip().split("\n"): + line = line.strip() + if line.startswith("-"): + translated.append(line[1:].strip()) + elif line: + translated.append(line) + + return translated if translated else symptoms + + def batch_translate( + self, + texts: List[str], + target_language: str + ) -> List[str]: + """ + Translate multiple texts at once. + + Args: + texts: List of texts to translate + target_language: Target language code + + Returns: + List of translated texts + """ + if target_language == "en": + return texts + + prompt = TranslationPrompts.batch_translate(texts, target_language) + system = get_system_prompt("translation") + + translation = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=len(" ".join(texts)) * 3, + temperature=0.3 + ) + + # Parse numbered translations + translated = [] + for line in translation.strip().split("\n"): + line = line.strip() + # Remove numbering (e.g., "1. ", "2. ") + if line and line[0].isdigit(): + parts = line.split(".", 1) + if len(parts) > 1: + translated.append(parts[1].strip()) + else: + translated.append(line) + elif line: + translated.append(line) + + # Ensure we have same number of translations + while len(translated) < len(texts): + translated.append(texts[len(translated)]) + + return translated[:len(texts)] + + # ========================================================================= + # DIAGNOSIS GENERATION METHODS + # ========================================================================= + + def generate_diagnosis_summary( + self, + disease_name: str, + crop: str, + confidence: float, + severity: str, + target_language: str = "en" + ) -> str: + """ + Generate a diagnosis summary for the farmer. + + Args: + disease_name: Detected disease name + crop: Crop type + confidence: Detection confidence (0.0-1.0) + severity: Severity level + target_language: Output language + + Returns: + Diagnosis summary text + """ + prompt = DiagnosisPrompts.generate_diagnosis_summary( + disease_name=disease_name, + crop=crop, + confidence=confidence, + severity=severity, + target_language=target_language + ) + system = get_system_prompt("diagnosis") + + summary = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=300, + temperature=0.7 + ) + + return summary.strip() + + def generate_treatment_advice( + self, + disease_name: str, + treatments: Dict, + target_language: str = "en" + ) -> str: + """ + Generate treatment recommendations for the farmer. + + Args: + disease_name: Disease name + treatments: Treatment information from knowledge base + target_language: Output language + + Returns: + Treatment advice text + """ + prompt = DiagnosisPrompts.generate_treatment_recommendation( + disease_name=disease_name, + treatments=treatments, + target_language=target_language + ) + system = get_system_prompt("diagnosis") + + advice = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=600, + temperature=0.7 + ) + + return advice.strip() + + def generate_prevention_advice( + self, + disease_name: str, + prevention_tips: List[str], + target_language: str = "en" + ) -> str: + """ + Generate prevention advice for the farmer. + + Args: + disease_name: Disease name + prevention_tips: List of prevention methods + target_language: Output language + + Returns: + Prevention advice text + """ + prompt = DiagnosisPrompts.generate_prevention_advice( + disease_name=disease_name, + prevention_tips=prevention_tips, + target_language=target_language + ) + system = get_system_prompt("diagnosis") + + advice = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=400, + temperature=0.7 + ) + + return advice.strip() + + def generate_full_report( + self, + disease_data: Dict, + confidence: float, + target_language: str = "en" + ) -> str: + """ + Generate a complete diagnosis report. + + Args: + disease_data: Full disease information from knowledge base + confidence: Detection confidence + target_language: Output language + + Returns: + Complete diagnosis report + """ + prompt = ReportPrompts.generate_full_report( + disease_data=disease_data, + confidence=confidence, + target_language=target_language + ) + system = get_system_prompt("diagnosis") + + report = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=800, + temperature=0.7 + ) + + return report.strip() + + def generate_healthy_message( + self, + crop: str, + target_language: str = "en" + ) -> str: + """ + Generate a message for healthy plant detection. + + Args: + crop: Crop type + target_language: Output language + + Returns: + Healthy plant message + """ + prompt = DiagnosisPrompts.generate_healthy_plant_message( + crop=crop, + target_language=target_language + ) + system = get_system_prompt("diagnosis") + + message = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=250, + temperature=0.7 + ) + + return message.strip() + + # ========================================================================= + # CONVERSATIONAL METHODS + # ========================================================================= + + def answer_question( + self, + question: str, + context: Optional[str] = None, + target_language: str = "en" + ) -> str: + """ + Answer a farmer's question. + + Args: + question: The farmer's question + context: Optional context (current diagnosis, etc.) + target_language: Response language + + Returns: + Answer text + """ + prompt = ConversationalPrompts.answer_farmer_question( + question=question, + context=context, + target_language=target_language + ) + system = get_system_prompt("conversation") + + answer = self.generate( + prompt=prompt, + system_prompt=system, + max_tokens=400, + temperature=0.7 + ) + + return answer.strip() + + +# ============================================================================= +# SINGLETON INSTANCE +# ============================================================================= + +# Global model instance (lazy loaded) +_model_instance: Optional[NATLaSModel] = None + + +def get_natlas_model() -> NATLaSModel: + """ + Get the singleton N-ATLaS model instance. + Creates and loads the model on first call. + + Returns: + NATLaSModel instance + """ + global _model_instance + + if _model_instance is None: + _model_instance = NATLaSModel( + n_ctx=natlas_config.context_length, + n_gpu_layers=natlas_config.n_gpu_layers, + n_threads=natlas_config.n_threads, + n_batch=natlas_config.n_batch + ) + + return _model_instance + + +def unload_natlas_model(): + """Unload the singleton model instance to free memory.""" + global _model_instance + + if _model_instance is not None: + _model_instance.unload_model() + _model_instance = None + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + +def translate_text(text: str, target_language: str) -> str: + """ + Convenience function to translate text. + + Args: + text: Text to translate + target_language: Target language code + + Returns: + Translated text + """ + model = get_natlas_model() + return model.translate(text, target_language) + + +def generate_diagnosis( + disease_data: Dict, + confidence: float, + target_language: str = "en" +) -> str: + """ + Convenience function to generate diagnosis report. + + Args: + disease_data: Disease information + confidence: Detection confidence + target_language: Output language + + Returns: + Diagnosis report + """ + model = get_natlas_model() + return model.generate_full_report(disease_data, confidence, target_language) + + +# ============================================================================= +# MAIN - Test the model +# ============================================================================= + +if __name__ == "__main__": + print("=" * 60) + print("N-ATLaS Model Test") + print("=" * 60) + + # Initialize model + print("\n1. Initializing N-ATLaS model...") + model = NATLaSModel(verbose=False) + + # Load model + print("\n2. Loading model (this may take a minute)...") + model.load_model() + + # Test translation + print("\n3. Testing translation (English to Hausa)...") + english_text = "Your cassava plant has a disease. Please remove infected leaves." + hausa_text = model.translate(english_text, "ha") + print(f" English: {english_text}") + print(f" Hausa: {hausa_text}") + + # Test diagnosis summary + print("\n4. Testing diagnosis summary generation (Yoruba)...") + summary = model.generate_diagnosis_summary( + disease_name="Cassava Mosaic Disease", + crop="cassava", + confidence=0.89, + severity="high", + target_language="yo" + ) + print(f" Summary: {summary}") + + # Test healthy message + print("\n5. Testing healthy plant message (Igbo)...") + message = model.generate_healthy_message(crop="tomato", target_language="ig") + print(f" Message: {message}") + + print("\n" + "=" * 60) + print("✅ All tests completed!") + print("=" * 60) diff --git a/models/.ipynb_checkpoints/yolo_model-checkpoint.py b/models/.ipynb_checkpoints/yolo_model-checkpoint.py new file mode 100644 index 0000000000000000000000000000000000000000..4073ec722e4bd070c4598e87ae3d43548365ef8d --- /dev/null +++ b/models/.ipynb_checkpoints/yolo_model-checkpoint.py @@ -0,0 +1,699 @@ +""" +FarmEyes YOLOv11 Model Integration +================================== +Handles loading and inference with YOLOv11 model for crop disease detection. +Optimized for Apple Silicon M1 Pro with MPS (Metal Performance Shaders) acceleration. + +Model: Custom trained YOLOv11 for 10 disease classes +Crops: Cassava, Cocoa, Tomato +""" + +import os +import sys +from pathlib import Path +from typing import Optional, Dict, List, Tuple, Union +from dataclasses import dataclass +import logging + +# Add parent directory to path for imports +sys.path.append(str(Path(__file__).parent.parent)) + +import numpy as np +from PIL import Image + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# PREDICTION RESULT DATACLASS +# ============================================================================= + +@dataclass +class PredictionResult: + """ + Container for disease prediction results. + """ + class_index: int # Index of predicted class (0-9) + class_name: str # Human-readable class name + disease_key: str # Key for knowledge base lookup + confidence: float # Confidence score (0.0 - 1.0) + crop_type: str # Crop type (cassava, cocoa, tomato) + is_healthy: bool # Whether plant is healthy + bbox: Optional[List[float]] = None # Bounding box [x1, y1, x2, y2] if available + + def to_dict(self) -> Dict: + """Convert to dictionary for JSON serialization.""" + return { + "class_index": self.class_index, + "class_name": self.class_name, + "disease_key": self.disease_key, + "confidence": round(self.confidence, 4), + "confidence_percent": round(self.confidence * 100, 1), + "crop_type": self.crop_type, + "is_healthy": self.is_healthy, + "bbox": self.bbox + } + + def __repr__(self) -> str: + return f"PredictionResult({self.class_name}, conf={self.confidence:.2%}, crop={self.crop_type})" + + +# ============================================================================= +# YOLO MODEL CLASS +# ============================================================================= + +class YOLOModel: + """ + YOLOv11 Model wrapper for FarmEyes crop disease detection. + Uses Ultralytics library with MPS acceleration for Apple Silicon. + """ + + # Class mappings (must match your trained model) + CLASS_NAMES: List[str] = [ + "Cassava Bacteria Blight", + "Cassava Healthy Leaf", + "Cassava Mosaic Disease", + "Cocoa Healthy Leaf", + "Cocoa Monilia Disease", + "Cocoa Phytophthora Disease", + "Tomato Gray Mold Disease", + "Tomato Healthy Leaf", + "Tomato Viral Disease", + "Tomato Wilt Disease" + ] + + # Class index to knowledge base key mapping + CLASS_TO_KEY: Dict[int, str] = { + 0: "cassava_bacterial_blight", + 1: "cassava_healthy", + 2: "cassava_mosaic_disease", + 3: "cocoa_healthy", + 4: "cocoa_monilia_disease", + 5: "cocoa_phytophthora_disease", + 6: "tomato_gray_mold", + 7: "tomato_healthy", + 8: "tomato_viral_disease", + 9: "tomato_wilt_disease" + } + + # Class index to crop type mapping + CLASS_TO_CROP: Dict[int, str] = { + 0: "cassava", 1: "cassava", 2: "cassava", + 3: "cocoa", 4: "cocoa", 5: "cocoa", + 6: "tomato", 7: "tomato", 8: "tomato", 9: "tomato" + } + + # Healthy class indices + HEALTHY_INDICES: List[int] = [1, 3, 7] + + def __init__( + self, + model_path: Optional[str] = None, + confidence_threshold: float = 0.5, + iou_threshold: float = 0.45, + device: str = "mps", + input_size: int = 640 + ): + """ + Initialize YOLOv11 model. + + Args: + model_path: Path to trained YOLOv11 .pt weights file + confidence_threshold: Minimum confidence for detections + iou_threshold: IoU threshold for NMS + device: Compute device ('mps' for Apple Silicon, 'cuda', 'cpu') + input_size: Input image size for the model + """ + # Import config here to avoid circular imports + from config import yolo_config, MODELS_DIR + + self.model_path = model_path or str(yolo_config.model_path) + self.confidence_threshold = confidence_threshold + self.iou_threshold = iou_threshold + self.input_size = input_size + + # Determine best device + self.device = self._get_best_device(device) + + # Model instance (lazy loaded) + self._model = None + self._is_loaded = False + + logger.info(f"YOLOModel initialized:") + logger.info(f" Model path: {self.model_path}") + logger.info(f" Device: {self.device}") + logger.info(f" Confidence threshold: {self.confidence_threshold}") + logger.info(f" Input size: {self.input_size}") + + # ========================================================================= + # DEVICE MANAGEMENT + # ========================================================================= + + def _get_best_device(self, preferred: str = "mps") -> str: + """ + Determine the best available compute device. + + Args: + preferred: Preferred device ('mps', 'cuda', 'cpu') + + Returns: + Best available device string + """ + import torch + + if preferred == "mps" and torch.backends.mps.is_available(): + logger.info("Using MPS (Metal Performance Shaders) for Apple Silicon") + return "mps" + elif preferred == "cuda" and torch.cuda.is_available(): + logger.info(f"Using CUDA: {torch.cuda.get_device_name(0)}") + return "cuda" + else: + logger.info("Using CPU for inference") + return "cpu" + + # ========================================================================= + # MODEL LOADING + # ========================================================================= + + def load_model(self) -> bool: + """ + Load the YOLOv11 model into memory. + + Returns: + True if model loaded successfully + """ + if self._is_loaded: + logger.info("Model already loaded") + return True + + try: + from ultralytics import YOLO + + # Check if model file exists + if not Path(self.model_path).exists(): + logger.warning(f"Model file not found at {self.model_path}") + logger.warning("Using placeholder - please provide trained model") + + # Create a placeholder with pretrained YOLOv11n for testing + # Replace this with your actual trained model + logger.info("Loading pretrained YOLOv11n as placeholder...") + self._model = YOLO("yolo11n.pt") # Downloads pretrained model + self._is_placeholder = True + else: + logger.info(f"Loading YOLOv11 model from {self.model_path}...") + self._model = YOLO(self.model_path) + self._is_placeholder = False + + # Move model to device + self._model.to(self.device) + + self._is_loaded = True + logger.info(f"✅ YOLOv11 model loaded successfully on {self.device}!") + + return True + + except ImportError: + logger.error("Ultralytics not installed!") + logger.error("Install with: pip install ultralytics") + raise ImportError("ultralytics package is required") + + except Exception as e: + logger.error(f"Failed to load model: {e}") + self._is_loaded = False + raise RuntimeError(f"Could not load YOLOv11 model: {e}") + + def unload_model(self): + """Unload model from memory.""" + if self._model is not None: + del self._model + self._model = None + self._is_loaded = False + + # Clear GPU cache + import torch + if self.device == "mps": + torch.mps.empty_cache() + elif self.device == "cuda": + torch.cuda.empty_cache() + + logger.info("Model unloaded from memory") + + @property + def is_loaded(self) -> bool: + """Check if model is currently loaded.""" + return self._is_loaded + + # ========================================================================= + # IMAGE PREPROCESSING + # ========================================================================= + + def preprocess_image( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> Image.Image: + """ + Preprocess image for model inference. + + Args: + image: Input image (file path, PIL Image, or numpy array) + + Returns: + Preprocessed PIL Image + """ + # Load image if path provided + if isinstance(image, (str, Path)): + image_path = Path(image) + if not image_path.exists(): + raise FileNotFoundError(f"Image not found: {image_path}") + image = Image.open(image_path) + + # Convert numpy array to PIL Image + elif isinstance(image, np.ndarray): + # Handle different array formats + if image.dtype != np.uint8: + image = (image * 255).astype(np.uint8) + if len(image.shape) == 2: + image = Image.fromarray(image, mode='L').convert('RGB') + elif image.shape[2] == 4: + image = Image.fromarray(image, mode='RGBA').convert('RGB') + else: + image = Image.fromarray(image, mode='RGB') + + # Ensure PIL Image + if not isinstance(image, Image.Image): + raise ValueError(f"Unsupported image type: {type(image)}") + + # Convert to RGB if necessary + if image.mode != 'RGB': + image = image.convert('RGB') + + return image + + def validate_image(self, image: Image.Image) -> Tuple[bool, str]: + """ + Validate image for disease detection. + + Args: + image: PIL Image to validate + + Returns: + Tuple of (is_valid, message) + """ + # Check image size + width, height = image.size + min_size = 64 + max_size = 8192 + + if width < min_size or height < min_size: + return False, f"Image too small. Minimum size is {min_size}x{min_size} pixels." + + if width > max_size or height > max_size: + return False, f"Image too large. Maximum size is {max_size}x{max_size} pixels." + + # Check aspect ratio (should be reasonable for a leaf photo) + aspect_ratio = max(width, height) / min(width, height) + if aspect_ratio > 10: + return False, "Image aspect ratio is too extreme. Please take a more centered photo." + + return True, "Image is valid" + + # ========================================================================= + # INFERENCE + # ========================================================================= + + def predict( + self, + image: Union[str, Path, Image.Image, np.ndarray], + return_all: bool = False + ) -> Union[PredictionResult, List[PredictionResult]]: + """ + Run disease detection on an image. + + Args: + image: Input image (file path, PIL Image, or numpy array) + return_all: If True, return all predictions; otherwise return top prediction + + Returns: + PredictionResult or list of PredictionResults + """ + # Ensure model is loaded + if not self._is_loaded: + self.load_model() + + # Preprocess image + pil_image = self.preprocess_image(image) + + # Validate image + is_valid, message = self.validate_image(pil_image) + if not is_valid: + raise ValueError(message) + + try: + # Run inference + results = self._model( + pil_image, + conf=self.confidence_threshold, + iou=self.iou_threshold, + imgsz=self.input_size, + device=self.device, + verbose=False + ) + + # Parse results + predictions = self._parse_results(results) + + if not predictions: + # No confident detection - return low confidence result + logger.warning("No confident detection found") + return self._create_low_confidence_result() + + # Return results + if return_all: + return predictions + else: + return predictions[0] # Top prediction + + except Exception as e: + logger.error(f"Inference error: {e}") + raise RuntimeError(f"Disease detection failed: {e}") + + def predict_with_visualization( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> Tuple[PredictionResult, Image.Image]: + """ + Run detection and return annotated image. + + Args: + image: Input image + + Returns: + Tuple of (PredictionResult, annotated PIL Image) + """ + if not self._is_loaded: + self.load_model() + + pil_image = self.preprocess_image(image) + + # Run inference + results = self._model( + pil_image, + conf=self.confidence_threshold, + iou=self.iou_threshold, + imgsz=self.input_size, + device=self.device, + verbose=False + ) + + # Get prediction + predictions = self._parse_results(results) + prediction = predictions[0] if predictions else self._create_low_confidence_result() + + # Get annotated image + annotated = results[0].plot() # Returns numpy array with annotations + annotated_image = Image.fromarray(annotated) + + return prediction, annotated_image + + def _parse_results(self, results) -> List[PredictionResult]: + """ + Parse YOLO results into PredictionResult objects. + + Args: + results: YOLO inference results + + Returns: + List of PredictionResult objects sorted by confidence + """ + predictions = [] + + for result in results: + # Check if we have classification results (for classification model) + if hasattr(result, 'probs') and result.probs is not None: + probs = result.probs + + # Get top prediction + top_idx = int(probs.top1) + top_conf = float(probs.top1conf) + + # Handle placeholder model (pretrained YOLO) + if hasattr(self, '_is_placeholder') and self._is_placeholder: + # Map to our classes for demo purposes + top_idx = top_idx % len(self.CLASS_NAMES) + + if top_idx < len(self.CLASS_NAMES): + prediction = PredictionResult( + class_index=top_idx, + class_name=self.CLASS_NAMES[top_idx], + disease_key=self.CLASS_TO_KEY[top_idx], + confidence=top_conf, + crop_type=self.CLASS_TO_CROP[top_idx], + is_healthy=top_idx in self.HEALTHY_INDICES + ) + predictions.append(prediction) + + # Check for detection results (for detection model) + elif hasattr(result, 'boxes') and result.boxes is not None: + boxes = result.boxes + + for i in range(len(boxes)): + cls_idx = int(boxes.cls[i]) + conf = float(boxes.conf[i]) + bbox = boxes.xyxy[i].tolist() if boxes.xyxy is not None else None + + # Handle placeholder model + if hasattr(self, '_is_placeholder') and self._is_placeholder: + cls_idx = cls_idx % len(self.CLASS_NAMES) + + if cls_idx < len(self.CLASS_NAMES): + prediction = PredictionResult( + class_index=cls_idx, + class_name=self.CLASS_NAMES[cls_idx], + disease_key=self.CLASS_TO_KEY[cls_idx], + confidence=conf, + crop_type=self.CLASS_TO_CROP[cls_idx], + is_healthy=cls_idx in self.HEALTHY_INDICES, + bbox=bbox + ) + predictions.append(prediction) + + # Sort by confidence (highest first) + predictions.sort(key=lambda x: x.confidence, reverse=True) + + return predictions + + def _create_low_confidence_result(self) -> PredictionResult: + """Create a result indicating low confidence / no detection.""" + return PredictionResult( + class_index=-1, + class_name="Unknown", + disease_key="unknown", + confidence=0.0, + crop_type="unknown", + is_healthy=False + ) + + # ========================================================================= + # BATCH INFERENCE + # ========================================================================= + + def predict_batch( + self, + images: List[Union[str, Path, Image.Image, np.ndarray]] + ) -> List[PredictionResult]: + """ + Run detection on multiple images. + + Args: + images: List of input images + + Returns: + List of PredictionResult objects (one per image) + """ + if not self._is_loaded: + self.load_model() + + results = [] + for image in images: + try: + result = self.predict(image) + results.append(result) + except Exception as e: + logger.error(f"Failed to process image: {e}") + results.append(self._create_low_confidence_result()) + + return results + + # ========================================================================= + # UTILITY METHODS + # ========================================================================= + + def get_class_info(self, class_index: int) -> Dict: + """ + Get information about a class by index. + + Args: + class_index: Index of the class (0-9) + + Returns: + Dictionary with class information + """ + if class_index < 0 or class_index >= len(self.CLASS_NAMES): + return { + "class_index": class_index, + "class_name": "Unknown", + "disease_key": "unknown", + "crop_type": "unknown", + "is_healthy": False + } + + return { + "class_index": class_index, + "class_name": self.CLASS_NAMES[class_index], + "disease_key": self.CLASS_TO_KEY[class_index], + "crop_type": self.CLASS_TO_CROP[class_index], + "is_healthy": class_index in self.HEALTHY_INDICES + } + + def get_model_info(self) -> Dict: + """Get information about the loaded model.""" + info = { + "model_path": self.model_path, + "is_loaded": self._is_loaded, + "device": self.device, + "confidence_threshold": self.confidence_threshold, + "input_size": self.input_size, + "num_classes": len(self.CLASS_NAMES), + "classes": self.CLASS_NAMES + } + + if self._is_loaded and hasattr(self, '_is_placeholder'): + info["is_placeholder"] = self._is_placeholder + + return info + + +# ============================================================================= +# SINGLETON INSTANCE +# ============================================================================= + +_model_instance: Optional[YOLOModel] = None + + +def get_yolo_model() -> YOLOModel: + """ + Get the singleton YOLO model instance. + + Returns: + YOLOModel instance + """ + global _model_instance + + if _model_instance is None: + from config import yolo_config + + _model_instance = YOLOModel( + model_path=str(yolo_config.model_path), + confidence_threshold=yolo_config.confidence_threshold, + iou_threshold=yolo_config.iou_threshold, + device=yolo_config.device, + input_size=yolo_config.input_size + ) + + return _model_instance + + +def unload_yolo_model(): + """Unload the singleton YOLO model to free memory.""" + global _model_instance + + if _model_instance is not None: + _model_instance.unload_model() + _model_instance = None + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + +def detect_disease( + image: Union[str, Path, Image.Image, np.ndarray] +) -> PredictionResult: + """ + Convenience function to detect disease in an image. + + Args: + image: Input image (path, PIL Image, or numpy array) + + Returns: + PredictionResult with disease information + """ + model = get_yolo_model() + return model.predict(image) + + +def detect_disease_with_image( + image: Union[str, Path, Image.Image, np.ndarray] +) -> Tuple[PredictionResult, Image.Image]: + """ + Detect disease and return annotated image. + + Args: + image: Input image + + Returns: + Tuple of (PredictionResult, annotated Image) + """ + model = get_yolo_model() + return model.predict_with_visualization(image) + + +# ============================================================================= +# MAIN - Test the model +# ============================================================================= + +if __name__ == "__main__": + import torch + + print("=" * 60) + print("YOLOv11 Model Test") + print("=" * 60) + + # Check device + print("\n1. Checking compute device...") + print(f" PyTorch version: {torch.__version__}") + print(f" MPS available: {torch.backends.mps.is_available()}") + print(f" MPS built: {torch.backends.mps.is_built()}") + + # Initialize model + print("\n2. Initializing YOLOv11 model...") + model = YOLOModel() + + # Load model + print("\n3. Loading model...") + model.load_model() + + # Print model info + print("\n4. Model information:") + info = model.get_model_info() + for key, value in info.items(): + print(f" {key}: {value}") + + # Test with a sample image (if available) + print("\n5. Testing inference...") + print(" To test with an actual image, run:") + print(" >>> result = model.predict('path/to/your/image.jpg')") + print(" >>> print(result)") + + # Print class mappings + print("\n6. Class mappings:") + for idx, name in enumerate(model.CLASS_NAMES): + crop = model.CLASS_TO_CROP[idx] + healthy = "✓ Healthy" if idx in model.HEALTHY_INDICES else "✗ Disease" + print(f" {idx}: {name} ({crop}) - {healthy}") + + print("\n" + "=" * 60) + print("✅ YOLOv11 model test completed!") + print("=" * 60) diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6ee6617e4f26c74b538298c62cd3d46d788302e4 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,51 @@ +""" +FarmEyes Models Package +======================= +AI model wrappers for the FarmEyes application. + +Models: +- natlas_model: N-ATLaS hybrid model (API + GGUF) for translation and chat +- yolo_model: YOLOv11 for crop disease detection +""" + +from models.natlas_model import ( + NATLaSModel, + HuggingFaceAPIClient, + LocalGGUFModel, + get_natlas_model, + unload_natlas_model, + translate_text, + translate_batch, + LANGUAGE_NAMES, + NATIVE_LANGUAGE_NAMES +) + +from models.yolo_model import ( + YOLOModel, + PredictionResult, + get_yolo_model, + unload_yolo_model, + detect_disease, + detect_disease_with_image +) + +__all__ = [ + # N-ATLaS + "NATLaSModel", + "HuggingFaceAPIClient", + "LocalGGUFModel", + "get_natlas_model", + "unload_natlas_model", + "translate_text", + "translate_batch", + "LANGUAGE_NAMES", + "NATIVE_LANGUAGE_NAMES", + + # YOLO + "YOLOModel", + "PredictionResult", + "get_yolo_model", + "unload_yolo_model", + "detect_disease", + "detect_disease_with_image" +] diff --git a/models/farmeyes_yolov11.pt b/models/farmeyes_yolov11.pt new file mode 100644 index 0000000000000000000000000000000000000000..c4c87c6a27386b5bbcc9e0771bfdd5c80ff3ff0f --- /dev/null +++ b/models/farmeyes_yolov11.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db0ae83b787eb371120737cdf92519406b5dc01aa74faa9963d7a36ed730a1b6 +size 19189523 diff --git a/models/natlas_model.py b/models/natlas_model.py new file mode 100644 index 0000000000000000000000000000000000000000..d6ade7a23dfc85f230fbc6a23f5f830d21b33b69 --- /dev/null +++ b/models/natlas_model.py @@ -0,0 +1,647 @@ +""" +FarmEyes N-ATLaS Model Integration (Hybrid) +============================================ +HYBRID APPROACH: +1. PRIMARY: HuggingFace Inference API (fast, cloud-based) +2. FALLBACK: Local GGUF model (downloads at runtime, always works) + +API Model: NCAIR1/N-ATLaS +GGUF Model: tosinamuda/N-ATLaS-GGUF (N-ATLaS-GGUF-Q4_K_M.gguf) + +HUGGINGFACE SPACES OPTIMIZED: +- Downloads GGUF model at runtime (no need to upload 4.92GB) +- CPU-only mode for free tier (no GPU layers) +- Caches model in HF cache directory + +Languages: English, Hausa, Yoruba, Igbo +""" + +import os +import sys +from pathlib import Path +from typing import Optional, Dict, List +import logging +import time + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# ENVIRONMENT DETECTION +# ============================================================================= + +# Check if running on HuggingFace Spaces +IS_HF_SPACES = os.environ.get("SPACE_ID") is not None + +# On Spaces, use CPU-only mode (free tier has no GPU) +if IS_HF_SPACES: + logger.info("🤗 Running on HuggingFace Spaces - CPU mode enabled") + DEFAULT_GPU_LAYERS = 0 # CPU only + DEFAULT_THREADS = 4 # Spaces has multi-core CPU +else: + logger.info("🖥️ Running locally") + DEFAULT_GPU_LAYERS = -1 # Use all GPU layers (for Apple Silicon MPS) + DEFAULT_THREADS = 4 + + +# ============================================================================= +# LANGUAGE MAPPINGS +# ============================================================================= + +LANGUAGE_NAMES = { + "en": "English", + "ha": "Hausa", + "yo": "Yoruba", + "ig": "Igbo" +} + +NATIVE_LANGUAGE_NAMES = { + "en": "English", + "ha": "Yaren Hausa", + "yo": "Èdè Yorùbá", + "ig": "Asụsụ Igbo" +} + + +# ============================================================================= +# HUGGINGFACE INFERENCE API CLIENT (PRIMARY) +# ============================================================================= + +class HuggingFaceAPIClient: + """ + Client for HuggingFace Serverless Inference API. + Primary method - fast cloud-based inference. + + NOTE: This API may not always be available for N-ATLaS model. + The GGUF fallback ensures reliability. + """ + + MODEL_ID = "NCAIR1/N-ATLaS" + API_URL = "https://api-inference.huggingface.co/models/NCAIR1/N-ATLaS" + + def __init__(self, api_token: Optional[str] = None): + self.api_token = api_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN") + self._is_available = None + self._last_check = 0 + self._check_interval = 300 # 5 minutes + + if self.api_token: + logger.info("✅ HuggingFace API token found") + else: + logger.warning("⚠️ No HF_TOKEN set - will use GGUF model only") + + def is_available(self) -> bool: + """Check if API is available.""" + if not self.api_token: + return False + + current_time = time.time() + if self._is_available is not None and current_time - self._last_check < self._check_interval: + return self._is_available + + try: + import requests + + headers = {"Authorization": "Bearer " + self.api_token} + response = requests.get( + "https://huggingface.co/api/models/" + self.MODEL_ID, + headers=headers, + timeout=10 + ) + + self._is_available = response.status_code == 200 + self._last_check = current_time + + if self._is_available: + logger.info("✅ HuggingFace API is available") + else: + logger.warning("⚠️ HuggingFace API unavailable: " + str(response.status_code)) + + return self._is_available + + except Exception as e: + logger.warning("⚠️ API check failed: " + str(e)) + self._is_available = False + self._last_check = current_time + return False + + def generate( + self, + prompt: str, + max_new_tokens: int = 512, + temperature: float = 0.7, + top_p: float = 0.9 + ) -> Optional[str]: + """Generate text using HuggingFace Inference API.""" + if not self.api_token: + return None + + try: + import requests + + headers = { + "Authorization": "Bearer " + self.api_token, + "Content-Type": "application/json" + } + + payload = { + "inputs": prompt, + "parameters": { + "max_new_tokens": max_new_tokens, + "temperature": temperature, + "top_p": top_p, + "do_sample": True, + "return_full_text": False + }, + "options": { + "wait_for_model": True + } + } + + logger.info("📡 Calling HuggingFace Inference API...") + + response = requests.post( + self.API_URL, + headers=headers, + json=payload, + timeout=120 + ) + + if response.status_code == 200: + result = response.json() + if isinstance(result, list) and len(result) > 0: + text = result[0].get("generated_text", "") + if text: + logger.info("✅ API generation successful: " + str(len(text)) + " chars") + return text + return None + else: + logger.warning("⚠️ API request failed: " + str(response.status_code)) + return None + + except Exception as e: + logger.error("❌ API call failed: " + str(e)) + return None + + def translate(self, text: str, target_language: str) -> Optional[str]: + """Translate text using the API.""" + if target_language == "en" or not text: + return text + + lang_name = LANGUAGE_NAMES.get(target_language, target_language) + prompt = "Translate to " + lang_name + ": " + text + + result = self.generate(prompt, max_new_tokens=len(text) * 3, temperature=0.3) + + if result: + result = result.strip() + # Clean up prefixes + for prefix in [lang_name + ":", "Translation:"]: + if result.lower().startswith(prefix.lower()): + result = result[len(prefix):].strip() + return result + + return None + + +# ============================================================================= +# LOCAL GGUF MODEL (FALLBACK - ALWAYS WORKS) +# ============================================================================= + +class LocalGGUFModel: + """ + Local GGUF model using llama-cpp-python. + FALLBACK: Always works - downloads model at runtime if not present. + + Model: tosinamuda/N-ATLaS-GGUF + File: N-ATLaS-GGUF-Q4_K_M.gguf (4.92GB) + + HUGGINGFACE SPACES: + - Model downloads automatically on first startup (~5-10 min) + - Cached in HF cache directory + - Uses CPU-only inference (free tier) + """ + + HF_REPO = "tosinamuda/N-ATLaS-GGUF" + MODEL_FILENAME = "N-ATLaS-GGUF-Q4_K_M.gguf" + + def __init__( + self, + model_path: Optional[str] = None, + n_ctx: int = 2048, # Reduced for Spaces memory + n_gpu_layers: int = DEFAULT_GPU_LAYERS, + n_threads: int = DEFAULT_THREADS, + n_batch: int = 256, # Reduced for Spaces memory + verbose: bool = False + ): + self.model_path = model_path + self.n_ctx = n_ctx + self.n_gpu_layers = n_gpu_layers + self.n_threads = n_threads + self.n_batch = n_batch + self.verbose = verbose + + self._model = None + self._is_loaded = False + + # Log configuration + logger.info(f"GGUF Config: ctx={n_ctx}, gpu_layers={n_gpu_layers}, threads={n_threads}, batch={n_batch}") + + def download_model(self) -> str: + """ + Download GGUF model from HuggingFace Hub. + + This is the KEY feature for HuggingFace Spaces: + - Downloads the 4.92GB model at runtime + - Caches in HF cache directory + - No need to upload large model files + """ + try: + from huggingface_hub import hf_hub_download + + logger.info("=" * 60) + logger.info("📥 DOWNLOADING N-ATLaS GGUF MODEL") + logger.info("=" * 60) + logger.info(f" Repository: {self.HF_REPO}") + logger.info(f" File: {self.MODEL_FILENAME}") + logger.info(f" Size: ~4.92 GB") + logger.info(" This may take 5-15 minutes on first startup...") + logger.info("=" * 60) + + # Download with progress + model_path = hf_hub_download( + repo_id=self.HF_REPO, + filename=self.MODEL_FILENAME, + cache_dir=None, # Use default HF cache + resume_download=True # Resume if interrupted + ) + + logger.info("=" * 60) + logger.info("✅ MODEL DOWNLOAD COMPLETE!") + logger.info(f" Path: {model_path}") + logger.info("=" * 60) + + return model_path + + except Exception as e: + logger.error("=" * 60) + logger.error("❌ MODEL DOWNLOAD FAILED!") + logger.error(f" Error: {str(e)}") + logger.error("=" * 60) + raise + + def load_model(self) -> bool: + """Load GGUF model.""" + if self._is_loaded: + return True + + try: + from llama_cpp import Llama + + # Download if not present + if self.model_path is None or not Path(self.model_path).exists(): + logger.info("Model not found locally, downloading...") + self.model_path = self.download_model() + + logger.info("🔄 Loading GGUF model into memory...") + logger.info(f" Path: {self.model_path}") + logger.info(f" GPU Layers: {self.n_gpu_layers}") + logger.info(f" Context: {self.n_ctx}") + + self._model = Llama( + model_path=self.model_path, + n_ctx=self.n_ctx, + n_gpu_layers=self.n_gpu_layers, + n_threads=self.n_threads, + n_batch=self.n_batch, + verbose=self.verbose + ) + + self._is_loaded = True + logger.info("✅ GGUF model loaded successfully!") + return True + + except ImportError: + logger.error("❌ llama-cpp-python not installed!") + logger.error(" Run: pip install llama-cpp-python") + return False + except Exception as e: + logger.error(f"❌ Model load failed: {str(e)}") + return False + + def unload_model(self): + """Unload model to free memory.""" + if self._model is not None: + del self._model + self._model = None + self._is_loaded = False + logger.info("Model unloaded") + + @property + def is_loaded(self) -> bool: + return self._is_loaded + + def generate( + self, + prompt: str, + max_tokens: int = 512, + temperature: float = 0.7, + top_p: float = 0.9, + stop: Optional[List[str]] = None + ) -> Optional[str]: + """Generate text using GGUF model with Llama-3 format.""" + if not self._is_loaded: + if not self.load_model(): + return None + + try: + # Format prompt for Llama-3 chat format + formatted_prompt = ( + "<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n" + "You are a helpful AI assistant for African farmers. You help with crop disease diagnosis, " + "treatment advice, and agricultural questions. Respond in the same language the user writes in." + "<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n" + + prompt + + "<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n" + ) + + response = self._model( + formatted_prompt, + max_tokens=max_tokens, + temperature=temperature, + top_p=top_p, + stop=stop or ["<|eot_id|>", "<|end_of_text|>"], + echo=False + ) + + text = response["choices"][0]["text"].strip() + + # Clean up special tokens + for token in ["<|eot_id|>", "<|end_of_text|>", "<|start_header_id|>", "<|end_header_id|>"]: + text = text.replace(token, "") + + text = text.strip() + + if text: + logger.info(f"✅ GGUF generation: {len(text)} chars") + return text + else: + logger.warning("⚠️ GGUF returned empty response") + return None + + except Exception as e: + logger.error(f"❌ GGUF generation error: {str(e)}") + return None + + def translate(self, text: str, target_language: str) -> Optional[str]: + """Translate text using GGUF model.""" + if target_language == "en" or not text: + return text + + lang_name = LANGUAGE_NAMES.get(target_language, target_language) + prompt = "Translate to " + lang_name + ": " + text + + result = self.generate( + prompt, + max_tokens=len(text) * 4, + temperature=0.3 + ) + + if result: + result = result.strip() + for prefix in [lang_name + ":", "Translation:", "In " + lang_name + ":"]: + if result.lower().startswith(prefix.lower()): + result = result[len(prefix):].strip() + return result + + return text + + def chat_response(self, message: str, context: Dict, language: str = "en") -> Optional[str]: + """Generate chat response with diagnosis context.""" + + crop = context.get("crop_type", "crop").capitalize() + disease = context.get("disease_name", "unknown disease") + severity = context.get("severity_level", "unknown") + confidence = context.get("confidence", 0) + if confidence <= 1: + confidence = int(confidence * 100) + + # Language instruction + lang_instructions = { + "ha": "Respond in Hausa language.", + "yo": "Respond in Yoruba language.", + "ig": "Respond in Igbo language." + } + lang_instruction = lang_instructions.get(language, "Respond in English.") + + prompt = ( + "You are FarmEyes, an AI assistant helping African farmers with crop diseases.\n\n" + "Current diagnosis:\n" + "- Crop: " + crop + "\n" + "- Disease: " + disease + "\n" + "- Severity: " + severity + "\n" + "- Confidence: " + str(confidence) + "%\n\n" + + lang_instruction + "\n\n" + "Farmer's question: " + message + "\n\n" + "Provide a helpful, practical response about this disease or related farming advice. " + "Keep it concise (2-3 paragraphs max)." + ) + + return self.generate(prompt, max_tokens=400, temperature=0.7) + + +# ============================================================================= +# HYBRID N-ATLAS MODEL (MAIN CLASS) +# ============================================================================= + +class NATLaSModel: + """ + HYBRID N-ATLaS model. + + Strategy: + 1. Try HuggingFace Inference API first (if token available) + 2. Fall back to local GGUF model (downloads at runtime, always works) + + This ensures: + - Fast responses when API is available + - Reliable fallback when offline or API fails + - Works on HuggingFace Spaces (free tier) + """ + + def __init__( + self, + api_token: Optional[str] = None, + prefer_api: bool = True, + auto_load_local: bool = True, + **local_kwargs + ): + self.prefer_api = prefer_api + + # Initialize API client (PRIMARY) + self.api_client = HuggingFaceAPIClient(api_token) + + # Initialize GGUF model (FALLBACK) + self.local_model = LocalGGUFModel(**local_kwargs) + + # Translation cache + self._cache: Dict[str, str] = {} + + # Auto-load GGUF for reliable fallback + if auto_load_local: + logger.info("🔄 Pre-loading GGUF model for fallback...") + self.local_model.load_model() + + logger.info("=" * 60) + logger.info("✅ NATLaSModel (Hybrid) initialized") + logger.info(f" API token: {'Yes' if self.api_client.api_token else 'No'}") + logger.info(f" GGUF loaded: {'Yes' if self.local_model.is_loaded else 'No'}") + logger.info(f" Running on: {'HuggingFace Spaces' if IS_HF_SPACES else 'Local'}") + logger.info("=" * 60) + + @property + def is_loaded(self) -> bool: + return self.api_client.is_available() or self.local_model.is_loaded + + def load_model(self) -> bool: + if self.api_client.is_available(): + return True + return self.local_model.load_model() + + def translate(self, text: str, target_language: str, use_cache: bool = True) -> str: + """ + Translate text using hybrid approach. + 1. Try API first + 2. Fall back to GGUF + """ + if target_language == "en" or not text or not text.strip(): + return text + + # Check cache + cache_key = target_language + ":" + str(hash(text)) + if use_cache and cache_key in self._cache: + return self._cache[cache_key] + + result = None + + # Try API first if preferred and available + if self.prefer_api and self.api_client.api_token: + logger.info("📡 Trying API translation...") + result = self.api_client.translate(text, target_language) + if result: + logger.info("✅ API translation successful") + + # Fall back to GGUF + if result is None: + logger.info("🔄 Using GGUF for translation (fallback)...") + result = self.local_model.translate(text, target_language) + + # Cache and return + if result and result != text and use_cache: + self._cache[cache_key] = result + if len(self._cache) > 500: + keys = list(self._cache.keys())[:100] + for k in keys: + del self._cache[k] + + return result if result else text + + def translate_batch(self, texts: List[str], target_language: str) -> List[str]: + """Translate multiple texts.""" + return [self.translate(text, target_language) for text in texts] + + def generate(self, prompt: str, max_tokens: int = 512, temperature: float = 0.7, **kwargs) -> str: + """ + Generate text using hybrid approach. + 1. Try API first + 2. Fall back to GGUF + """ + result = None + + # Try API first if preferred and available + if self.prefer_api and self.api_client.api_token: + logger.info("📡 Trying API generation...") + result = self.api_client.generate(prompt, max_tokens, temperature) + if result: + logger.info("✅ API generation successful") + + # Fall back to GGUF + if result is None: + logger.info("🔄 Using GGUF for generation (fallback)...") + result = self.local_model.generate(prompt, max_tokens, temperature) + + return result if result else "" + + def chat_response(self, message: str, context: Dict, language: str = "en") -> str: + """Generate chat response with context (uses GGUF directly for better context handling).""" + result = self.local_model.chat_response(message, context, language) + return result if result else "" + + def load_local_model(self) -> bool: + return self.local_model.load_model() + + def unload_local_model(self): + self.local_model.unload_model() + + def get_status(self) -> Dict: + return { + "api_available": self.api_client.is_available(), + "api_token_set": bool(self.api_client.api_token), + "local_model_loaded": self.local_model.is_loaded, + "prefer_api": self.prefer_api, + "cache_size": len(self._cache), + "running_on": "HuggingFace Spaces" if IS_HF_SPACES else "Local" + } + + def clear_cache(self): + self._cache.clear() + + +# ============================================================================= +# SINGLETON +# ============================================================================= + +_model_instance: Optional[NATLaSModel] = None + + +def get_natlas_model( + api_token: Optional[str] = None, + auto_load_local: bool = True, + **kwargs +) -> NATLaSModel: + """Get singleton model instance.""" + global _model_instance + + if _model_instance is None: + _model_instance = NATLaSModel( + api_token=api_token, + prefer_api=True, # Try API first + auto_load_local=auto_load_local, # Pre-load GGUF as fallback + **kwargs + ) + + return _model_instance + + +def unload_natlas_model(): + """Unload model.""" + global _model_instance + if _model_instance is not None: + _model_instance.unload_local_model() + _model_instance = None + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + +def translate_text(text: str, target_language: str) -> str: + return get_natlas_model().translate(text, target_language) + + +def translate_batch(texts: List[str], target_language: str) -> List[str]: + return get_natlas_model().translate_batch(texts, target_language) + + +def generate_text(prompt: str, max_tokens: int = 512) -> str: + return get_natlas_model().generate(prompt, max_tokens=max_tokens) diff --git a/models/yolo_model.py b/models/yolo_model.py new file mode 100644 index 0000000000000000000000000000000000000000..12476583ea16292d41151ff97795d117597fcae9 --- /dev/null +++ b/models/yolo_model.py @@ -0,0 +1,703 @@ +""" +FarmEyes YOLOv11 Model Integration +================================== +Handles loading and inference with YOLOv11 model for crop disease detection. +Optimized for Apple Silicon M1 Pro with MPS (Metal Performance Shaders) acceleration. + +Model: Custom trained YOLOv11 for 6 disease classes (no healthy classes) +Crops: Cassava, Cocoa, Tomato +Classes: + 0: Cassava Bacteria Blight + 1: Cassava Mosaic Virus + 2: Cocoa Monilia Disease + 3: Cocoa Phytophthora Disease + 4: Tomato Gray Mold Disease + 5: Tomato Wilt Disease +""" + +import os +import sys +from pathlib import Path +from typing import Optional, Dict, List, Tuple, Union +from dataclasses import dataclass +import logging + +# Add parent directory to path for imports +sys.path.append(str(Path(__file__).parent.parent)) + +import numpy as np +from PIL import Image + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# PREDICTION RESULT DATACLASS +# ============================================================================= + +@dataclass +class PredictionResult: + """ + Container for disease prediction results. + """ + class_index: int # Index of predicted class (0-5) + class_name: str # Human-readable class name + disease_key: str # Key for knowledge base lookup + confidence: float # Confidence score (0.0 - 1.0) + crop_type: str # Crop type (cassava, cocoa, tomato) + is_healthy: bool # Whether plant is healthy (always False in 6-class model) + bbox: Optional[List[float]] = None # Bounding box [x1, y1, x2, y2] if available + + def to_dict(self) -> Dict: + """Convert to dictionary for JSON serialization.""" + return { + "class_index": self.class_index, + "class_name": self.class_name, + "disease_key": self.disease_key, + "confidence": round(self.confidence, 4), + "confidence_percent": round(self.confidence * 100, 1), + "crop_type": self.crop_type, + "is_healthy": self.is_healthy, + "bbox": self.bbox + } + + def __repr__(self) -> str: + return f"PredictionResult({self.class_name}, conf={self.confidence:.2%}, crop={self.crop_type})" + + +# ============================================================================= +# YOLO MODEL CLASS +# ============================================================================= + +class YOLOModel: + """ + YOLOv11 Model wrapper for FarmEyes crop disease detection. + Uses Ultralytics library with MPS acceleration for Apple Silicon. + + 6-class model (all diseases, no healthy classes): + 0: Cassava Bacteria Blight + 1: Cassava Mosaic Virus + 2: Cocoa Monilia Disease + 3: Cocoa Phytophthora Disease + 4: Tomato Gray Mold Disease + 5: Tomato Wilt Disease + """ + + # Class mappings (must match your trained model - 6 classes) + CLASS_NAMES: List[str] = [ + "Cassava Bacteria Blight", # Index 0 + "Cassava Mosaic Virus", # Index 1 + "Cocoa Monilia Disease", # Index 2 + "Cocoa Phytophthora Disease", # Index 3 + "Tomato Gray Mold Disease", # Index 4 + "Tomato Wilt Disease" # Index 5 + ] + + # Class index to knowledge base key mapping (6 classes) + CLASS_TO_KEY: Dict[int, str] = { + 0: "cassava_bacterial_blight", + 1: "cassava_mosaic_virus", + 2: "cocoa_monilia_disease", + 3: "cocoa_phytophthora_disease", + 4: "tomato_gray_mold", + 5: "tomato_wilt_disease" + } + + # Class index to crop type mapping (6 classes) + CLASS_TO_CROP: Dict[int, str] = { + 0: "cassava", # Cassava Bacteria Blight + 1: "cassava", # Cassava Mosaic Virus + 2: "cocoa", # Cocoa Monilia Disease + 3: "cocoa", # Cocoa Phytophthora Disease + 4: "tomato", # Tomato Gray Mold Disease + 5: "tomato" # Tomato Wilt Disease + } + + # No healthy class indices in 6-class model (all classes are diseases) + HEALTHY_INDICES: List[int] = [] + + def __init__( + self, + model_path: Optional[str] = None, + confidence_threshold: float = 0.5, + iou_threshold: float = 0.45, + device: str = "mps", + input_size: int = 640 + ): + """ + Initialize YOLOv11 model. + + Args: + model_path: Path to trained YOLOv11 .pt weights file + confidence_threshold: Minimum confidence for detections + iou_threshold: IoU threshold for NMS + device: Compute device ('mps' for Apple Silicon, 'cuda', 'cpu') + input_size: Input image size for the model + """ + # Import config here to avoid circular imports + from config import yolo_config, MODELS_DIR + + self.model_path = model_path or str(yolo_config.model_path) + self.confidence_threshold = confidence_threshold + self.iou_threshold = iou_threshold + self.input_size = input_size + + # Determine best device + self.device = self._get_best_device(device) + + # Model instance (lazy loaded) + self._model = None + self._is_loaded = False + + logger.info(f"YOLOModel initialized:") + logger.info(f" Model path: {self.model_path}") + logger.info(f" Device: {self.device}") + logger.info(f" Confidence threshold: {self.confidence_threshold}") + logger.info(f" Input size: {self.input_size}") + logger.info(f" Number of classes: {len(self.CLASS_NAMES)}") + + # ========================================================================= + # DEVICE MANAGEMENT + # ========================================================================= + + def _get_best_device(self, preferred: str = "mps") -> str: + """ + Determine the best available compute device. + + Args: + preferred: Preferred device ('mps', 'cuda', 'cpu') + + Returns: + Best available device string + """ + import torch + + if preferred == "mps" and torch.backends.mps.is_available(): + logger.info("Using MPS (Metal Performance Shaders) for Apple Silicon") + return "mps" + elif preferred == "cuda" and torch.cuda.is_available(): + logger.info(f"Using CUDA: {torch.cuda.get_device_name(0)}") + return "cuda" + else: + logger.info("Using CPU for inference") + return "cpu" + + # ========================================================================= + # MODEL LOADING + # ========================================================================= + + def load_model(self) -> bool: + """ + Load the YOLOv11 model into memory. + + Returns: + True if model loaded successfully + """ + if self._is_loaded: + logger.info("Model already loaded") + return True + + try: + from ultralytics import YOLO + + # Check if model file exists + if not Path(self.model_path).exists(): + logger.warning(f"Model file not found at {self.model_path}") + logger.warning("Using placeholder - please provide trained model") + + # Create a placeholder with pretrained YOLOv11n for testing + # Replace this with your actual trained model + logger.info("Loading pretrained YOLOv11n as placeholder...") + self._model = YOLO("yolo11n.pt") # Downloads pretrained model + self._is_placeholder = True + else: + logger.info(f"Loading YOLOv11 model from {self.model_path}...") + self._model = YOLO(self.model_path) + self._is_placeholder = False + + # Move model to device + self._model.to(self.device) + + self._is_loaded = True + logger.info(f"✅ YOLOv11 model loaded successfully on {self.device}!") + + return True + + except ImportError: + logger.error("Ultralytics not installed!") + logger.error("Install with: pip install ultralytics") + raise ImportError("ultralytics package is required") + + except Exception as e: + logger.error(f"Failed to load model: {e}") + self._is_loaded = False + raise RuntimeError(f"Could not load YOLOv11 model: {e}") + + def unload_model(self): + """Unload model from memory.""" + if self._model is not None: + del self._model + self._model = None + self._is_loaded = False + + # Clear GPU cache + import torch + if self.device == "mps": + torch.mps.empty_cache() + elif self.device == "cuda": + torch.cuda.empty_cache() + + logger.info("Model unloaded from memory") + + @property + def is_loaded(self) -> bool: + """Check if model is loaded.""" + return self._is_loaded + + # ========================================================================= + # IMAGE PREPROCESSING + # ========================================================================= + + def preprocess_image( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> Image.Image: + """ + Preprocess image for inference. + + Args: + image: Input image (path, PIL Image, or numpy array) + + Returns: + PIL Image ready for inference + """ + # Handle different input types + if isinstance(image, (str, Path)): + image_path = Path(image) + if not image_path.exists(): + raise FileNotFoundError(f"Image not found: {image_path}") + pil_image = Image.open(image_path) + elif isinstance(image, np.ndarray): + pil_image = Image.fromarray(image) + elif isinstance(image, Image.Image): + pil_image = image + else: + raise TypeError(f"Unsupported image type: {type(image)}") + + # Convert to RGB if necessary + if pil_image.mode != "RGB": + pil_image = pil_image.convert("RGB") + + return pil_image + + def validate_image(self, image: Image.Image) -> Tuple[bool, str]: + """ + Validate image for inference. + + Args: + image: PIL Image to validate + + Returns: + Tuple of (is_valid, message) + """ + # Check image size + width, height = image.size + + if width < 32 or height < 32: + return False, "Image too small. Minimum size is 32x32 pixels." + + if width > 4096 or height > 4096: + return False, "Image too large. Maximum size is 4096x4096 pixels." + + return True, "Image is valid" + + # ========================================================================= + # INFERENCE + # ========================================================================= + + def predict( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> PredictionResult: + """ + Run disease detection on an image. + + Args: + image: Input image (path, PIL Image, or numpy array) + + Returns: + PredictionResult with disease information + """ + if not self._is_loaded: + self.load_model() + + # Preprocess image + pil_image = self.preprocess_image(image) + + # Validate image + is_valid, message = self.validate_image(pil_image) + if not is_valid: + logger.warning(f"Image validation failed: {message}") + return self._create_low_confidence_result() + + try: + # Run inference + results = self._model( + pil_image, + conf=self.confidence_threshold, + iou=self.iou_threshold, + imgsz=self.input_size, + device=self.device, + verbose=False + ) + + # Parse results + predictions = self._parse_results(results) + + if not predictions: + logger.info("No predictions above confidence threshold") + return self._create_low_confidence_result() + + # Return top prediction + return predictions[0] + + except Exception as e: + logger.error(f"Inference failed: {e}") + return self._create_low_confidence_result() + + def predict_with_visualization( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> Tuple[PredictionResult, Image.Image]: + """ + Run detection and return annotated image. + + Args: + image: Input image + + Returns: + Tuple of (PredictionResult, annotated PIL Image) + """ + if not self._is_loaded: + self.load_model() + + # Preprocess image + pil_image = self.preprocess_image(image) + + # Validate image + is_valid, message = self.validate_image(pil_image) + if not is_valid: + logger.warning(f"Image validation failed: {message}") + return self._create_low_confidence_result(), pil_image + + try: + # Run inference + results = self._model( + pil_image, + conf=self.confidence_threshold, + iou=self.iou_threshold, + imgsz=self.input_size, + device=self.device, + verbose=False + ) + + # Parse results + predictions = self._parse_results(results) + + # Get annotated image + annotated = results[0].plot() + annotated_pil = Image.fromarray(annotated[..., ::-1]) # BGR to RGB + + if not predictions: + return self._create_low_confidence_result(), annotated_pil + + return predictions[0], annotated_pil + + except Exception as e: + logger.error(f"Inference with visualization failed: {e}") + return self._create_low_confidence_result(), pil_image + + def _parse_results(self, results) -> List[PredictionResult]: + """ + Parse YOLO results into PredictionResult objects. + + Args: + results: YOLO inference results + + Returns: + List of PredictionResult objects sorted by confidence + """ + predictions = [] + + for result in results: + # Check if we have classification results (for classification model) + if hasattr(result, 'probs') and result.probs is not None: + probs = result.probs + + # Get top prediction + top_idx = int(probs.top1) + top_conf = float(probs.top1conf) + + # Handle placeholder model (pretrained YOLO) + if hasattr(self, '_is_placeholder') and self._is_placeholder: + # Map to our classes for demo purposes + top_idx = top_idx % len(self.CLASS_NAMES) + + if top_idx < len(self.CLASS_NAMES): + prediction = PredictionResult( + class_index=top_idx, + class_name=self.CLASS_NAMES[top_idx], + disease_key=self.CLASS_TO_KEY[top_idx], + confidence=top_conf, + crop_type=self.CLASS_TO_CROP[top_idx], + is_healthy=top_idx in self.HEALTHY_INDICES # Always False for 6-class model + ) + predictions.append(prediction) + + # Check for detection results (for detection model) + elif hasattr(result, 'boxes') and result.boxes is not None: + boxes = result.boxes + + for i in range(len(boxes)): + cls_idx = int(boxes.cls[i]) + conf = float(boxes.conf[i]) + bbox = boxes.xyxy[i].tolist() if boxes.xyxy is not None else None + + # Handle placeholder model + if hasattr(self, '_is_placeholder') and self._is_placeholder: + cls_idx = cls_idx % len(self.CLASS_NAMES) + + if cls_idx < len(self.CLASS_NAMES): + prediction = PredictionResult( + class_index=cls_idx, + class_name=self.CLASS_NAMES[cls_idx], + disease_key=self.CLASS_TO_KEY[cls_idx], + confidence=conf, + crop_type=self.CLASS_TO_CROP[cls_idx], + is_healthy=cls_idx in self.HEALTHY_INDICES, # Always False for 6-class model + bbox=bbox + ) + predictions.append(prediction) + + # Sort by confidence (highest first) + predictions.sort(key=lambda x: x.confidence, reverse=True) + + return predictions + + def _create_low_confidence_result(self) -> PredictionResult: + """Create a result indicating low confidence / no detection.""" + return PredictionResult( + class_index=-1, + class_name="Unknown", + disease_key="unknown", + confidence=0.0, + crop_type="unknown", + is_healthy=False + ) + + # ========================================================================= + # BATCH INFERENCE + # ========================================================================= + + def predict_batch( + self, + images: List[Union[str, Path, Image.Image, np.ndarray]] + ) -> List[PredictionResult]: + """ + Run detection on multiple images. + + Args: + images: List of input images + + Returns: + List of PredictionResult objects (one per image) + """ + if not self._is_loaded: + self.load_model() + + results = [] + for image in images: + try: + result = self.predict(image) + results.append(result) + except Exception as e: + logger.error(f"Failed to process image: {e}") + results.append(self._create_low_confidence_result()) + + return results + + # ========================================================================= + # UTILITY METHODS + # ========================================================================= + + def get_class_info(self, class_index: int) -> Dict: + """ + Get information about a class by index. + + Args: + class_index: Index of the class (0-5) + + Returns: + Dictionary with class information + """ + if class_index < 0 or class_index >= len(self.CLASS_NAMES): + return { + "class_index": class_index, + "class_name": "Unknown", + "disease_key": "unknown", + "crop_type": "unknown", + "is_healthy": False + } + + return { + "class_index": class_index, + "class_name": self.CLASS_NAMES[class_index], + "disease_key": self.CLASS_TO_KEY[class_index], + "crop_type": self.CLASS_TO_CROP[class_index], + "is_healthy": class_index in self.HEALTHY_INDICES # Always False for 6-class model + } + + def get_model_info(self) -> Dict: + """Get information about the loaded model.""" + info = { + "model_path": self.model_path, + "is_loaded": self._is_loaded, + "device": self.device, + "confidence_threshold": self.confidence_threshold, + "input_size": self.input_size, + "num_classes": len(self.CLASS_NAMES), + "classes": self.CLASS_NAMES + } + + if self._is_loaded and hasattr(self, '_is_placeholder'): + info["is_placeholder"] = self._is_placeholder + + return info + + +# ============================================================================= +# SINGLETON INSTANCE +# ============================================================================= + +_model_instance: Optional[YOLOModel] = None + + +def get_yolo_model() -> YOLOModel: + """ + Get the singleton YOLO model instance. + + Returns: + YOLOModel instance + """ + global _model_instance + + if _model_instance is None: + from config import yolo_config + + _model_instance = YOLOModel( + model_path=str(yolo_config.model_path), + confidence_threshold=yolo_config.confidence_threshold, + iou_threshold=yolo_config.iou_threshold, + device=yolo_config.device, + input_size=yolo_config.input_size + ) + + return _model_instance + + +def unload_yolo_model(): + """Unload the singleton YOLO model to free memory.""" + global _model_instance + + if _model_instance is not None: + _model_instance.unload_model() + _model_instance = None + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + +def detect_disease( + image: Union[str, Path, Image.Image, np.ndarray] +) -> PredictionResult: + """ + Convenience function to detect disease in an image. + + Args: + image: Input image (path, PIL Image, or numpy array) + + Returns: + PredictionResult with disease information + """ + model = get_yolo_model() + return model.predict(image) + + +def detect_disease_with_image( + image: Union[str, Path, Image.Image, np.ndarray] +) -> Tuple[PredictionResult, Image.Image]: + """ + Detect disease and return annotated image. + + Args: + image: Input image + + Returns: + Tuple of (PredictionResult, annotated Image) + """ + model = get_yolo_model() + return model.predict_with_visualization(image) + + +# ============================================================================= +# MAIN - Test the model +# ============================================================================= + +if __name__ == "__main__": + import torch + + print("=" * 60) + print("YOLOv11 Model Test (6-Class Disease Detection)") + print("=" * 60) + + # Check device + print("\n1. Checking compute device...") + print(f" PyTorch version: {torch.__version__}") + print(f" MPS available: {torch.backends.mps.is_available()}") + print(f" MPS built: {torch.backends.mps.is_built()}") + + # Initialize model + print("\n2. Initializing YOLOv11 model...") + model = YOLOModel() + + # Load model + print("\n3. Loading model...") + model.load_model() + + # Print model info + print("\n4. Model information:") + info = model.get_model_info() + for key, value in info.items(): + print(f" {key}: {value}") + + # Test with a sample image (if available) + print("\n5. Testing inference...") + print(" To test with an actual image, run:") + print(" >>> result = model.predict('path/to/your/image.jpg')") + print(" >>> print(result)") + + # Print class mappings + print("\n6. Class mappings (6 classes - all diseases):") + for idx, name in enumerate(model.CLASS_NAMES): + crop = model.CLASS_TO_CROP[idx] + key = model.CLASS_TO_KEY[idx] + print(f" {idx}: {name}") + print(f" Crop: {crop}") + print(f" Key: {key}") + + print("\n" + "=" * 60) + print("✅ YOLOv11 model test completed!") + print("=" * 60) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..4a40de5c41441a1a3e0ca8f7ca05f009506a7706 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,70 @@ +# ============================================================================= +# FarmEyes - Requirements for HuggingFace Spaces +# ============================================================================= +# Optimized for Docker deployment on HF Spaces free tier (CPU) +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Web Framework +# ----------------------------------------------------------------------------- +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +python-multipart>=0.0.6 + +# ----------------------------------------------------------------------------- +# AI/ML - Core +# ----------------------------------------------------------------------------- +torch>=2.0.0 +torchvision>=0.15.0 +ultralytics>=8.0.0 + +# ----------------------------------------------------------------------------- +# AI/ML - Transformers & HuggingFace +# ----------------------------------------------------------------------------- +transformers>=4.35.0 +huggingface-hub>=0.19.0 + +# ----------------------------------------------------------------------------- +# Audio Processing (Whisper for voice input) +# ----------------------------------------------------------------------------- +openai-whisper>=20231117 +soundfile>=0.12.0 + +# ----------------------------------------------------------------------------- +# Image Processing +# ----------------------------------------------------------------------------- +Pillow>=10.0.0 +opencv-python-headless>=4.8.0 + +# ----------------------------------------------------------------------------- +# HTTP & Networking +# ----------------------------------------------------------------------------- +requests>=2.31.0 +httpx>=0.25.0 + +# ----------------------------------------------------------------------------- +# Data Processing +# ----------------------------------------------------------------------------- +numpy>=1.24.0 +scipy>=1.11.0 +pydantic>=2.0.0 + +# ----------------------------------------------------------------------------- +# Utilities +# ----------------------------------------------------------------------------- +python-dotenv>=1.0.0 +tqdm>=4.66.0 + +# ----------------------------------------------------------------------------- +# NOTES: +# ----------------------------------------------------------------------------- +# 1. llama-cpp-python is installed separately in Dockerfile +# (requires compilation, better to install after other deps) +# +# 2. The N-ATLaS GGUF model (~4.92GB) is NOT included here +# It downloads automatically at runtime from: +# https://huggingface.co/tosinamuda/N-ATLaS-GGUF +# +# 3. For local development on Apple Silicon (M1/M2/M3), install: +# CMAKE_ARGS="-DLLAMA_METAL=on" pip install llama-cpp-python +# ============================================================================= diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a26c0444c4dcf162dca9029495b1d7440913f864 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,86 @@ +""" +FarmEyes Services Package +========================= +Service layer modules for the FarmEyes application. + +Services: +- session_manager: Session state and chat memory management +- chat_service: Contextual agricultural chatbot +- whisper_service: Speech-to-text for voice input +- disease_detector: Disease detection with knowledge base +- translator: N-ATLaS translation service +- diagnosis_generator: Complete diagnosis report generation +""" + +# Import services for easy access +from services.session_manager import ( + SessionManager, + UserSession, + DiagnosisContext, + ChatMessage, + get_session_manager, + create_session, + get_session, + get_or_create_session +) + +from services.chat_service import ( + ChatService, + get_chat_service, + chat, + get_welcome +) + +from services.whisper_service import ( + WhisperService, + AudioProcessor, + get_whisper_service, + transcribe_audio, + transcribe_bytes +) + +# These will be imported from existing files +# from services.disease_detector import ( +# DiseaseDetectorService, +# DetectionResult, +# get_disease_detector, +# detect_crop_disease +# ) + +# from services.translator import ( +# TranslatorService, +# get_translator, +# translate_text +# ) + +# from services.diagnosis_generator import ( +# DiagnosisGenerator, +# DiagnosisReport, +# get_diagnosis_generator, +# generate_diagnosis +# ) + +__all__ = [ + # Session management + "SessionManager", + "UserSession", + "DiagnosisContext", + "ChatMessage", + "get_session_manager", + "create_session", + "get_session", + "get_or_create_session", + + # Chat service + "ChatService", + "get_chat_service", + "chat", + "get_welcome", + + # Whisper service + "WhisperService", + "AudioProcessor", + "get_whisper_service", + "transcribe_audio", + "transcribe_bytes", +] diff --git a/services/__pycache__/__init__.cpython-310.pyc b/services/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d65840caa520fc0296f84c51a2b75a376846cbd6 Binary files /dev/null and b/services/__pycache__/__init__.cpython-310.pyc differ diff --git a/services/__pycache__/__init__.cpython-312.pyc b/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de2a0d22d7a61826a047ae3cec82e249f542e170 Binary files /dev/null and b/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/services/__pycache__/chat_service.cpython-310.pyc b/services/__pycache__/chat_service.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f5a03475a11ba0dd400449eb4ab21e8c52482ee9 Binary files /dev/null and b/services/__pycache__/chat_service.cpython-310.pyc differ diff --git a/services/__pycache__/chat_service.cpython-312.pyc b/services/__pycache__/chat_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a140f577b3ee288665ddf959e4ba3338c60cff2 Binary files /dev/null and b/services/__pycache__/chat_service.cpython-312.pyc differ diff --git a/services/__pycache__/diagnosis_generator.cpython-310.pyc b/services/__pycache__/diagnosis_generator.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75475380c80d600254c885cf35eeb04aa25e339c Binary files /dev/null and b/services/__pycache__/diagnosis_generator.cpython-310.pyc differ diff --git a/services/__pycache__/diagnosis_generator.cpython-312.pyc b/services/__pycache__/diagnosis_generator.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f896f9dcb514f4eb7e6e64e1e13e2924cd9cc47 Binary files /dev/null and b/services/__pycache__/diagnosis_generator.cpython-312.pyc differ diff --git a/services/__pycache__/disease_detector.cpython-310.pyc b/services/__pycache__/disease_detector.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa5868e599b0a3394e8202c852edc02e124b972c Binary files /dev/null and b/services/__pycache__/disease_detector.cpython-310.pyc differ diff --git a/services/__pycache__/disease_detector.cpython-312.pyc b/services/__pycache__/disease_detector.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f43a6e5d6575022ed14fc9281dac85729be12a61 Binary files /dev/null and b/services/__pycache__/disease_detector.cpython-312.pyc differ diff --git a/services/__pycache__/session_manager.cpython-310.pyc b/services/__pycache__/session_manager.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5247265e1bb7d334dff935425d78ce80954ed579 Binary files /dev/null and b/services/__pycache__/session_manager.cpython-310.pyc differ diff --git a/services/__pycache__/session_manager.cpython-312.pyc b/services/__pycache__/session_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cebda6e36f7c797a87f87e947e0c445edbe09e45 Binary files /dev/null and b/services/__pycache__/session_manager.cpython-312.pyc differ diff --git a/services/__pycache__/translator.cpython-310.pyc b/services/__pycache__/translator.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7cff0ef76a238442e66dc1fd9a812dbae535c668 Binary files /dev/null and b/services/__pycache__/translator.cpython-310.pyc differ diff --git a/services/__pycache__/translator.cpython-312.pyc b/services/__pycache__/translator.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6fef4e3d7fe7749d9cecb11d64a73391dee9dfa8 Binary files /dev/null and b/services/__pycache__/translator.cpython-312.pyc differ diff --git a/services/__pycache__/tts_service.cpython-310.pyc b/services/__pycache__/tts_service.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52ba9c7114bc7c7854be59a86e7017b8ca2454e5 Binary files /dev/null and b/services/__pycache__/tts_service.cpython-310.pyc differ diff --git a/services/__pycache__/whisper_service.cpython-310.pyc b/services/__pycache__/whisper_service.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..224ffdc34e707cb8d830ce2547659c57cf1e5eb5 Binary files /dev/null and b/services/__pycache__/whisper_service.cpython-310.pyc differ diff --git a/services/__pycache__/whisper_service.cpython-312.pyc b/services/__pycache__/whisper_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..271faa06277ed5687777b67cb1832faddbe70bc2 Binary files /dev/null and b/services/__pycache__/whisper_service.cpython-312.pyc differ diff --git a/services/chat_service.py b/services/chat_service.py new file mode 100644 index 0000000000000000000000000000000000000000..5800a0a4ed62d37048f1dcfa97e0891f8dc007e9 --- /dev/null +++ b/services/chat_service.py @@ -0,0 +1,272 @@ +""" +FarmEyes Chat Service +===================== +Contextual chat using N-ATLaS GGUF model. +""" + +import sys +import re +from pathlib import Path +from typing import Optional, Dict, List, Tuple +import logging + +sys.path.append(str(Path(__file__).parent.parent)) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# CHAT SERVICE +# ============================================================================= + +class ChatService: + """Chat service using N-ATLaS model.""" + + # Welcome messages per language + WELCOME_MESSAGES = { + "en": "Hello! I'm your FarmEyes assistant. I've analyzed your {crop} and detected **{disease}** with {confidence}% confidence. How can I help you understand or treat this condition?", + "ha": "Sannu! Ni ne mataimaki na FarmEyes. Na bincika {crop} ɗin ku kuma na gano **{disease}** da tabbaci {confidence}%. Yaya zan taimaka?", + "yo": "Pẹlẹ o! Mo jẹ́ olùrànlọ́wọ́ FarmEyes rẹ. Mo ti ṣàyẹ̀wò {crop} rẹ mo sì rí **{disease}** pẹ̀lú {confidence}%. Báwo ni mo ṣe lè ràn ọ́ lọ́wọ́?", + "ig": "Nnọọ! Abụ m onye enyemaka FarmEyes gị. Enyochala m {crop} gị ma chọpụta **{disease}** na {confidence}%. Kedu ka m ga-esi nyere gị aka?" + } + + # No diagnosis messages + NO_DIAGNOSIS_MESSAGES = { + "en": "Please analyze a crop image first before chatting. Upload an image on the Diagnosis page.", + "ha": "Da fatan za a fara bincika hoton amfanin gona kafin tattaunawa.", + "yo": "Jọ̀wọ́ ṣe àyẹ̀wò àwòrán ohun ọ̀gbìn kan kọ́kọ́.", + "ig": "Biko nyochaa foto ihe ọkụkụ mbụ tupu nkata." + } + + # Error messages + ERROR_MESSAGES = { + "en": "I'm having trouble responding right now. Please try again.", + "ha": "Ina da matsala wajen amsa yanzu. Da fatan za a sake gwadawa.", + "yo": "Mo ń ní ìṣòro láti dáhùn báyìí. Jọ̀wọ́ gbìyànjú lẹ́ẹ̀kan sí i.", + "ig": "Enwere m nsogbu ịza ugbu a. Biko nwaa ọzọ." + } + + def __init__(self, auto_load_model: bool = True): + self._natlas_model = None + self._session_manager = None + self._is_initialized = False + + if auto_load_model: + self._initialize() + + def _initialize(self): + """Initialize dependencies.""" + if self._is_initialized: + return + + try: + from models.natlas_model import get_natlas_model + self._natlas_model = get_natlas_model(auto_load_local=True) + + from services.session_manager import get_session_manager + self._session_manager = get_session_manager() + + self._is_initialized = True + logger.info("ChatService initialized") + + except Exception as e: + logger.error(f"ChatService init failed: {e}") + raise + + def ensure_initialized(self): + if not self._is_initialized: + self._initialize() + + def chat( + self, + session_id: str, + message: str, + diagnosis_context: Optional[Dict] = None, + language: str = "en" + ) -> Dict: + """Process chat message and return response.""" + try: + self.ensure_initialized() + + if not message or not message.strip(): + return { + "success": False, + "response": "Please enter a message.", + "error": "empty_message", + "language": language + } + + message = message.strip() + + # Get session + session = self._session_manager.get_session(session_id) + if not session: + session = self._session_manager.create_session(language) + session_id = session.session_id + + # Get diagnosis context + context = self._get_context(session, diagnosis_context) + + if not context or not context.get("image_analyzed"): + return { + "success": False, + "response": self.NO_DIAGNOSIS_MESSAGES.get(language, self.NO_DIAGNOSIS_MESSAGES["en"]), + "error": "no_diagnosis", + "language": language + } + + # Generate response using the model + logger.info(f"Generating chat response for: {message[:50]}...") + + response_text = self._natlas_model.chat_response( + message=message, + context=context, + language=language + ) + + if not response_text: + logger.warning("Model returned empty response") + return { + "success": False, + "response": self.ERROR_MESSAGES.get(language, self.ERROR_MESSAGES["en"]), + "error": "generation_failed", + "language": language + } + + # Store in history + try: + self._session_manager.add_chat_message(session_id, "user", message) + self._session_manager.add_chat_message(session_id, "assistant", response_text) + except Exception as e: + logger.warning(f"Failed to save chat history: {e}") + + return { + "success": True, + "response": response_text, + "session_id": session_id, + "language": language, + "context": { + "crop_type": context.get("crop_type"), + "disease_name": context.get("disease_name") + } + } + + except Exception as e: + logger.error(f"Chat error: {e}") + import traceback + traceback.print_exc() + return { + "success": False, + "response": self.ERROR_MESSAGES.get(language, self.ERROR_MESSAGES["en"]), + "error": str(e), + "language": language + } + + def get_welcome_message(self, session_id: str, language: str = "en") -> Dict: + """Get welcome message for chat page.""" + try: + self.ensure_initialized() + + session = self._session_manager.get_session(session_id) + if not session: + return { + "success": False, + "response": self.NO_DIAGNOSIS_MESSAGES.get(language, self.NO_DIAGNOSIS_MESSAGES["en"]), + "error": "no_diagnosis" + } + + context = self._get_context(session, None) + + if not context or not context.get("image_analyzed"): + return { + "success": False, + "response": self.NO_DIAGNOSIS_MESSAGES.get(language, self.NO_DIAGNOSIS_MESSAGES["en"]), + "error": "no_diagnosis" + } + + # Format welcome message + crop = context.get("crop_type", "crop").capitalize() + disease = context.get("disease_name", "disease") + confidence = context.get("confidence", 0) + if confidence <= 1: + confidence = int(confidence * 100) + + template = self.WELCOME_MESSAGES.get(language, self.WELCOME_MESSAGES["en"]) + welcome = template.format(crop=crop, disease=disease, confidence=confidence) + + return { + "success": True, + "response": welcome, + "session_id": session_id, + "language": language, + "context": context + } + + except Exception as e: + logger.error(f"Welcome message error: {e}") + return { + "success": False, + "response": str(e), + "error": "error" + } + + def _get_context(self, session, provided_context: Optional[Dict]) -> Optional[Dict]: + """Get diagnosis context.""" + if provided_context and provided_context.get("image_analyzed"): + return provided_context + + if session and hasattr(session, 'diagnosis') and session.diagnosis: + try: + if hasattr(session.diagnosis, 'is_valid') and session.diagnosis.is_valid(): + return session.diagnosis.to_dict() + elif hasattr(session.diagnosis, 'to_dict'): + ctx = session.diagnosis.to_dict() + if ctx.get("image_analyzed"): + return ctx + except: + pass + + return None + + def clear_history(self, session_id: str) -> bool: + """Clear chat history.""" + self.ensure_initialized() + try: + return self._session_manager.clear_chat_history(session_id) + except: + return False + + def get_history(self, session_id: str) -> List[Dict]: + """Get chat history.""" + self.ensure_initialized() + try: + messages = self._session_manager.get_chat_history(session_id) + return [msg.to_dict() if hasattr(msg, 'to_dict') else msg for msg in messages] + except: + return [] + + +# ============================================================================= +# SINGLETON +# ============================================================================= + +_chat_service: Optional[ChatService] = None + + +def get_chat_service() -> ChatService: + """Get singleton ChatService.""" + global _chat_service + if _chat_service is None: + _chat_service = ChatService(auto_load_model=True) + return _chat_service + + +def chat(session_id: str, message: str, language: str = "en", diagnosis_context: Optional[Dict] = None) -> Dict: + """Convenience function.""" + return get_chat_service().chat(session_id, message, diagnosis_context, language) + + +def get_welcome(session_id: str, language: str = "en") -> Dict: + """Convenience function.""" + return get_chat_service().get_welcome_message(session_id, language) diff --git a/services/diagnosis_generator.py b/services/diagnosis_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..28cd353abbd902874ff5ef5ee11f74793a41125d --- /dev/null +++ b/services/diagnosis_generator.py @@ -0,0 +1,771 @@ +""" +FarmEyes Diagnosis Generator Service +==================================== +Generates complete multilingual diagnosis reports by combining: +- Disease detection results (from YOLO model) +- Knowledge base information (symptoms, treatments, costs) +- N-ATLaS translations (Hausa, Yoruba, Igbo) + +Produces farmer-friendly reports with actionable treatment recommendations. +""" + +import sys +import json +from pathlib import Path +from typing import Optional, Dict, List, Union, Tuple +from dataclasses import dataclass, field +from datetime import datetime +import logging + +# Add parent directory to path for imports +sys.path.append(str(Path(__file__).parent.parent)) + +from PIL import Image +import numpy as np + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# DIAGNOSIS REPORT DATACLASS +# ============================================================================= + +@dataclass +class DiagnosisReport: + """ + Complete diagnosis report with all information translated to user's language. + """ + # Metadata + report_id: str = "" + timestamp: str = field(default_factory=lambda: datetime.now().isoformat()) + language: str = "en" + + # Detection summary + crop_type: str = "" + is_healthy: bool = False + confidence: float = 0.0 + confidence_level: str = "" # high, medium, low + + # Disease information (translated) + disease_name: str = "" + disease_name_scientific: str = "" + disease_category: str = "" + + # Severity (translated) + severity_level: str = "" + severity_scale: int = 0 + severity_description: str = "" + + # Summary message (translated) + summary_message: str = "" + + # Symptoms (translated) + symptoms: List[str] = field(default_factory=list) + + # How it spreads (translated) + transmission: List[str] = field(default_factory=list) + + # Yield impact + yield_loss_min: int = 0 + yield_loss_max: int = 0 + yield_loss_message: str = "" + + # Treatments (translated) + immediate_actions: List[Dict] = field(default_factory=list) + chemical_treatments: List[Dict] = field(default_factory=list) + organic_treatments: List[Dict] = field(default_factory=list) + traditional_treatments: List[Dict] = field(default_factory=list) + resistant_varieties: List[Dict] = field(default_factory=list) + + # Costs + treatment_cost_min: int = 0 + treatment_cost_max: int = 0 + cost_message: str = "" + + # Prevention (translated) + prevention_tips: List[str] = field(default_factory=list) + + # Health projection (translated) + health_projection: Dict = field(default_factory=dict) + current_projection: Dict = field(default_factory=dict) + + # Expert contact + expert_institution: str = "" + expert_location: str = "" + expert_services: str = "" + + # For healthy plants (translated) + healthy_message: str = "" + maintenance_tips: List[str] = field(default_factory=list) + expected_yield: Dict = field(default_factory=dict) + + # Original detection result (for reference) + raw_detection: Dict = field(default_factory=dict) + + def to_dict(self) -> Dict: + """Convert report to dictionary for JSON serialization.""" + return { + "metadata": { + "report_id": self.report_id, + "timestamp": self.timestamp, + "language": self.language + }, + "detection": { + "crop_type": self.crop_type, + "is_healthy": self.is_healthy, + "confidence": round(self.confidence, 4), + "confidence_percent": round(self.confidence * 100, 1), + "confidence_level": self.confidence_level + }, + "disease": { + "name": self.disease_name, + "scientific_name": self.disease_name_scientific, + "category": self.disease_category, + "severity": { + "level": self.severity_level, + "scale": self.severity_scale, + "description": self.severity_description + } + }, + "summary": self.summary_message, + "symptoms": self.symptoms, + "transmission": self.transmission, + "yield_impact": { + "min_percent": self.yield_loss_min, + "max_percent": self.yield_loss_max, + "message": self.yield_loss_message + }, + "treatments": { + "immediate_actions": self.immediate_actions, + "chemical": self.chemical_treatments, + "organic": self.organic_treatments, + "traditional": self.traditional_treatments, + "resistant_varieties": self.resistant_varieties + }, + "costs": { + "min_ngn": self.treatment_cost_min, + "max_ngn": self.treatment_cost_max, + "message": self.cost_message + }, + "prevention": self.prevention_tips, + "health_projection": self.health_projection, + "current_projection": self.current_projection, + "expert_contact": { + "institution": self.expert_institution, + "location": self.expert_location, + "services": self.expert_services + }, + "healthy_plant": { + "message": self.healthy_message, + "maintenance_tips": self.maintenance_tips, + "expected_yield": self.expected_yield + } + } + + def to_json(self, indent: int = 2) -> str: + """Convert report to JSON string.""" + return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) + + def get_short_summary(self) -> str: + """Get a brief one-line summary.""" + if self.is_healthy: + return f"✅ {self.healthy_message}" + else: + return f"⚠️ {self.disease_name} - {self.severity_level} severity ({self.confidence:.0%} confidence)" + + +# ============================================================================= +# DIAGNOSIS GENERATOR SERVICE +# ============================================================================= + +class DiagnosisGenerator: + """ + Generates complete multilingual diagnosis reports. + Combines disease detection, knowledge base, and translation services. + """ + + def __init__(self, auto_load_models: bool = False): + """ + Initialize the diagnosis generator. + + Args: + auto_load_models: Whether to load ML models immediately + """ + self._disease_detector = None + self._translator = None + self._report_counter = 0 + + # Load services + self._init_services(auto_load_models) + + logger.info("DiagnosisGenerator initialized") + + def _init_services(self, auto_load_models: bool) -> None: + """Initialize required services.""" + from services.disease_detector import get_disease_detector + from services.translator import get_translator + + self._disease_detector = get_disease_detector() + self._translator = get_translator() + + if auto_load_models: + self._disease_detector.ensure_model_loaded() + self._translator.ensure_model_loaded() + + def _generate_report_id(self) -> str: + """Generate a unique report ID.""" + self._report_counter += 1 + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + return f"FE-{timestamp}-{self._report_counter:04d}" + + # ========================================================================= + # MAIN GENERATION METHODS + # ========================================================================= + + def generate( + self, + image: Union[str, Path, Image.Image, np.ndarray], + language: str = "en" + ) -> DiagnosisReport: + """ + Generate a complete diagnosis report from an image. + + Args: + image: Input crop image (path, PIL Image, or numpy array) + language: Target language code (en, ha, yo, ig) + + Returns: + Complete DiagnosisReport in specified language + """ + # Ensure models are loaded + self._disease_detector.ensure_model_loaded() + + # Detect disease + logger.info("Running disease detection...") + detection_result = self._disease_detector.detect(image) + + # Generate report from detection result + report = self._build_report(detection_result, language) + + return report + + def generate_with_image( + self, + image: Union[str, Path, Image.Image, np.ndarray], + language: str = "en" + ) -> Tuple[DiagnosisReport, Image.Image]: + """ + Generate diagnosis report and return annotated image. + + Args: + image: Input crop image + language: Target language code + + Returns: + Tuple of (DiagnosisReport, annotated PIL Image) + """ + # Ensure models are loaded + self._disease_detector.ensure_model_loaded() + + # Detect disease with image + logger.info("Running disease detection with visualization...") + detection_result, annotated_image = self._disease_detector.detect_with_image(image) + + # Generate report + report = self._build_report(detection_result, language) + + return report, annotated_image + + def generate_from_detection( + self, + detection_result, + language: str = "en" + ) -> DiagnosisReport: + """ + Generate report from an existing detection result. + + Args: + detection_result: DetectionResult from disease detector + language: Target language code + + Returns: + Complete DiagnosisReport + """ + return self._build_report(detection_result, language) + + # ========================================================================= + # REPORT BUILDING + # ========================================================================= + + def _build_report( + self, + detection_result, + language: str + ) -> DiagnosisReport: + """ + Build a complete diagnosis report from detection result. + + Args: + detection_result: DetectionResult from disease detector + language: Target language code + + Returns: + Complete DiagnosisReport + """ + report = DiagnosisReport( + report_id=self._generate_report_id(), + language=language, + raw_detection=detection_result.to_dict() + ) + + # Basic detection info + report.crop_type = detection_result.crop_type + report.is_healthy = detection_result.is_healthy + report.confidence = detection_result.confidence + report.confidence_level = detection_result.get_confidence_level() + + if detection_result.is_healthy: + self._build_healthy_report(report, detection_result, language) + else: + self._build_disease_report(report, detection_result, language) + + return report + + def _build_healthy_report( + self, + report: DiagnosisReport, + detection_result, + language: str + ) -> None: + """Build report for healthy plant detection.""" + # Disease name (translated) + report.disease_name = self._translate( + detection_result.display_name or "Healthy Plant", + language + ) + + # Summary message + summary_en = f"Great news! Your {detection_result.crop_type} plant appears to be healthy. Continue with good farming practices to maintain plant health." + report.summary_message = self._translate(summary_en, language) + + # Healthy message + if detection_result.healthy_message: + report.healthy_message = self._translate( + detection_result.healthy_message, + language + ) + else: + report.healthy_message = report.summary_message + + # Maintenance tips + if detection_result.maintenance_tips: + report.maintenance_tips = self._translate_list( + detection_result.maintenance_tips[:6], + language + ) + else: + # Default tips + default_tips = [ + "Continue regular monitoring for early disease detection", + "Maintain proper watering and fertilization", + "Keep the field free of weeds", + "Practice crop rotation", + "Use disease-free planting materials" + ] + report.maintenance_tips = self._translate_list(default_tips, language) + + # Expected yield + report.expected_yield = detection_result.expected_yield + + def _build_disease_report( + self, + report: DiagnosisReport, + detection_result, + language: str + ) -> None: + """Build report for disease detection.""" + # Disease information + report.disease_name = self._translate( + detection_result.display_name, + language + ) + report.disease_name_scientific = detection_result.scientific_name + report.disease_category = detection_result.category + + # Severity + report.severity_level = self._translate( + detection_result.severity_level.replace("_", " ").title(), + language + ) + report.severity_scale = detection_result.severity_scale + report.severity_description = self._translate( + detection_result.severity_description, + language + ) + + # Summary message + summary_en = self._create_summary_message(detection_result) + report.summary_message = self._translate(summary_en, language) + + # Symptoms + if detection_result.symptoms: + report.symptoms = self._translate_list( + detection_result.symptoms[:6], + language + ) + + # Transmission + if detection_result.transmission: + report.transmission = self._translate_list( + detection_result.transmission[:5], + language + ) + + # Yield impact + report.yield_loss_min = detection_result.yield_loss_min + report.yield_loss_max = detection_result.yield_loss_max + yield_msg_en = f"This disease can cause {detection_result.yield_loss_min}% to {detection_result.yield_loss_max}% yield loss if not treated." + report.yield_loss_message = self._translate(yield_msg_en, language) + + # Treatments + self._build_treatment_sections(report, detection_result, language) + + # Costs + report.treatment_cost_min = detection_result.treatment_cost_min + report.treatment_cost_max = detection_result.treatment_cost_max + cost_msg_en = f"Estimated treatment cost: ₦{detection_result.treatment_cost_min:,} to ₦{detection_result.treatment_cost_max:,} per hectare." + report.cost_message = self._translate(cost_msg_en, language) + + # Prevention + if detection_result.prevention: + report.prevention_tips = self._translate_list( + detection_result.prevention[:6], + language + ) + + # Health projection + self._build_health_projection(report, detection_result, language) + + # Expert contact + expert = detection_result.expert_contact + if expert: + report.expert_institution = expert.get("institution", "") + report.expert_location = expert.get("location", "") + report.expert_services = expert.get("services", "") + + def _create_summary_message(self, detection_result) -> str: + """Create English summary message for disease detection.""" + severity = detection_result.severity_level.replace("_", " ") + confidence_pct = int(detection_result.confidence * 100) + + if detection_result.confidence >= 0.85: + confidence_text = "high confidence" + elif detection_result.confidence >= 0.60: + confidence_text = "moderate confidence" + else: + confidence_text = "low confidence" + + return ( + f"We detected {detection_result.display_name} in your {detection_result.crop_type} " + f"with {confidence_text} ({confidence_pct}%). " + f"This is a {severity} severity disease. " + f"Please follow the treatment recommendations below to protect your crop." + ) + + def _build_treatment_sections( + self, + report: DiagnosisReport, + detection_result, + language: str + ) -> None: + """Build all treatment sections of the report.""" + treatments = detection_result.treatments + + # Immediate actions (cultural practices) + cultural = treatments.get("cultural", []) + for t in cultural[:4]: + action = { + "action": self._translate(t.get("method", ""), language), + "description": self._translate(t.get("description", ""), language), + "effectiveness": t.get("effectiveness", ""), + "timing": t.get("timing", "") + } + report.immediate_actions.append(action) + + # Chemical treatments + chemical = treatments.get("chemical", []) + for t in chemical[:3]: + treatment = { + "product_name": t.get("product_name", ""), + "local_brands": t.get("local_brands", []), + "dosage": t.get("dosage", ""), + "frequency": t.get("frequency", ""), + "application_method": self._translate( + t.get("application_method", ""), + language + ), + "cost_min": t.get("cost_ngn_min", 0), + "cost_max": t.get("cost_ngn_max", 0), + "effectiveness": t.get("effectiveness", ""), + "safety_precautions": self._translate_list( + t.get("safety_precautions", [])[:3], + language + ) + } + report.chemical_treatments.append(treatment) + + # Biological/organic treatments + biological = treatments.get("biological", []) + for t in biological[:2]: + treatment = { + "method": self._translate(t.get("method", ""), language), + "description": self._translate(t.get("description", ""), language), + "effectiveness": t.get("effectiveness", ""), + "source": t.get("source", "") + } + report.organic_treatments.append(treatment) + + # Traditional treatments + traditional = treatments.get("traditional", []) + for t in traditional[:3]: + treatment = { + "method": self._translate(t.get("method", ""), language), + "description": self._translate(t.get("description", ""), language), + "cost": t.get("cost_ngn", 0), + "effectiveness": t.get("effectiveness", "") + } + report.traditional_treatments.append(treatment) + + # Resistant varieties + varieties = treatments.get("resistant_varieties", []) + for v in varieties[:3]: + variety = { + "name": v.get("variety_name", ""), + "resistance_level": v.get("resistance_level", ""), + "source": v.get("source", ""), + "cost": v.get("cost_ngn_per_bundle", 0), + "notes": self._translate(v.get("notes", ""), language) + } + report.resistant_varieties.append(variety) + + def _build_health_projection( + self, + report: DiagnosisReport, + detection_result, + language: str + ) -> None: + """Build health projection section.""" + projection = detection_result.health_projection + + if not projection: + return + + # Translate all projection stages + for stage, info in projection.items(): + if isinstance(info, dict): + report.health_projection[stage] = { + "recovery_chance_percent": info.get("recovery_chance_percent", 0), + "message": self._translate(info.get("message", ""), language) + } + + # Set current projection based on confidence + # Higher confidence often correlates with more visible/advanced symptoms + if detection_result.confidence >= 0.85: + # Clear symptoms suggest moderate to severe infection + stage = "moderate_infection" + elif detection_result.confidence >= 0.60: + # Some symptoms visible - likely early detection + stage = "early_detection" + else: + # Low confidence - could be very early + stage = "early_detection" + + if stage in report.health_projection: + report.current_projection = report.health_projection[stage] + + # ========================================================================= + # TRANSLATION HELPERS + # ========================================================================= + + def _translate(self, text: str, language: str) -> str: + """Translate text to target language.""" + if not text or language == "en": + return text + + try: + return self._translator.translate(text, language) + except Exception as e: + logger.warning(f"Translation failed: {e}") + return text + + def _translate_list(self, texts: List[str], language: str) -> List[str]: + """Translate a list of texts.""" + if not texts or language == "en": + return texts + + try: + return self._translator.translate_batch(texts, language) + except Exception as e: + logger.warning(f"Batch translation failed: {e}") + return texts + + # ========================================================================= + # UTILITY METHODS + # ========================================================================= + + def validate_image( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> Tuple[bool, str]: + """ + Validate an image before diagnosis. + + Args: + image: Input image + + Returns: + Tuple of (is_valid, message) + """ + return self._disease_detector.validate_image(image) + + def get_supported_languages(self) -> Dict[str, str]: + """Get supported languages for reports.""" + return self._translator.get_supported_languages() + + def get_supported_crops(self) -> List[str]: + """Get list of supported crops.""" + return ["cassava", "cocoa", "tomato"] + + def get_service_status(self) -> Dict: + """Get status of underlying services.""" + return { + "disease_detector": { + "model_loaded": self._disease_detector._yolo_model is not None and + self._disease_detector._yolo_model.is_loaded if self._disease_detector._yolo_model else False, + "knowledge_base_loaded": self._disease_detector._knowledge_base is not None + }, + "translator": { + "model_loaded": self._translator.is_model_loaded, + "cache_stats": self._translator.get_cache_stats() + } + } + + +# ============================================================================= +# SINGLETON INSTANCE +# ============================================================================= + +_generator_instance: Optional[DiagnosisGenerator] = None + + +def get_diagnosis_generator() -> DiagnosisGenerator: + """ + Get the singleton diagnosis generator instance. + + Returns: + DiagnosisGenerator instance + """ + global _generator_instance + + if _generator_instance is None: + _generator_instance = DiagnosisGenerator(auto_load_models=False) + + return _generator_instance + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + +def generate_diagnosis( + image: Union[str, Path, Image.Image, np.ndarray], + language: str = "en" +) -> DiagnosisReport: + """ + Generate a diagnosis report from an image. + + Args: + image: Input crop image + language: Target language code (en, ha, yo, ig) + + Returns: + Complete DiagnosisReport + """ + generator = get_diagnosis_generator() + return generator.generate(image, language) + + +def generate_diagnosis_with_image( + image: Union[str, Path, Image.Image, np.ndarray], + language: str = "en" +) -> Tuple[DiagnosisReport, Image.Image]: + """ + Generate diagnosis report with annotated image. + + Args: + image: Input crop image + language: Target language code + + Returns: + Tuple of (DiagnosisReport, annotated Image) + """ + generator = get_diagnosis_generator() + return generator.generate_with_image(image, language) + + +# ============================================================================= +# MAIN - Test the service +# ============================================================================= + +if __name__ == "__main__": + print("=" * 60) + print("Diagnosis Generator Service Test") + print("=" * 60) + + # Initialize generator + print("\n1. Initializing Diagnosis Generator...") + generator = DiagnosisGenerator(auto_load_models=False) + + # Check supported languages + print("\n2. Supported Languages:") + for code, name in generator.get_supported_languages().items(): + print(f" {code}: {name}") + + # Check supported crops + print("\n3. Supported Crops:") + for crop in generator.get_supported_crops(): + print(f" - {crop}") + + # Check service status + print("\n4. Service Status:") + status = generator.get_service_status() + print(f" Disease Detector - Knowledge Base Loaded: {status['disease_detector']['knowledge_base_loaded']}") + print(f" Disease Detector - Model Loaded: {status['disease_detector']['model_loaded']}") + print(f" Translator - Model Loaded: {status['translator']['model_loaded']}") + + # Test DiagnosisReport structure + print("\n5. Testing DiagnosisReport Structure...") + test_report = DiagnosisReport( + report_id="TEST-001", + language="en", + crop_type="cassava", + is_healthy=False, + confidence=0.92, + confidence_level="high", + disease_name="Cassava Mosaic Disease", + severity_level="Very High", + summary_message="Test summary message" + ) + print(f" Report ID: {test_report.report_id}") + print(f" Short Summary: {test_report.get_short_summary()}") + + print("\n6. To generate actual diagnosis (requires models):") + print(" >>> report = generator.generate('/path/to/image.jpg', 'ha')") + print(" >>> print(report.summary_message)") + print(" >>> print(report.to_json())") + + print("\n" + "=" * 60) + print("✅ Diagnosis Generator Service test completed!") + print("=" * 60) diff --git a/services/disease_detector.py b/services/disease_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..69fa21dc00cee1fe5205fb369e1f64d7600b2d77 --- /dev/null +++ b/services/disease_detector.py @@ -0,0 +1,710 @@ +""" +FarmEyes Disease Detector Service +================================= +Service layer that combines YOLOv11 disease detection with knowledge base +lookup to provide comprehensive disease information including symptoms, +treatments, costs, and prevention methods. + +This service acts as the bridge between: +- YOLOv11 model (disease detection) - 6 classes +- Knowledge base (disease information) +- N-ATLaS model (translation - handled by translator service) + +6-Class Model: + 0: Cassava Bacteria Blight + 1: Cassava Mosaic Virus + 2: Cocoa Monilia Disease + 3: Cocoa Phytophthora Disease + 4: Tomato Gray Mold Disease + 5: Tomato Wilt Disease +""" + +import sys +import json +from pathlib import Path +from typing import Optional, Dict, List, Tuple, Union +from dataclasses import dataclass, field +from datetime import datetime +import logging + +# Add parent directory to path for imports +sys.path.append(str(Path(__file__).parent.parent)) + +from PIL import Image +import numpy as np + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# DETECTION RESULT DATACLASS +# ============================================================================= + +@dataclass +class DetectionResult: + """ + Complete detection result with disease information from knowledge base. + """ + # Detection info + class_index: int + class_name: str + disease_key: str + confidence: float + crop_type: str + is_healthy: bool # Always False in 6-class model (no healthy classes) + + # Disease details from knowledge base + display_name: str = "" + scientific_name: str = "" + category: str = "" # bacterial, viral, fungal, oomycete + + # Severity + severity_level: str = "" + severity_scale: int = 0 + severity_description: str = "" + + # Symptoms + symptoms: List[str] = field(default_factory=list) + + # How it spreads + transmission: List[str] = field(default_factory=list) + + # Yield impact + yield_loss_min: int = 0 + yield_loss_max: int = 0 + yield_loss_description: str = "" + + # Treatments + treatments: Dict = field(default_factory=dict) + + # Costs + treatment_cost_min: int = 0 + treatment_cost_max: int = 0 + cost_unit: str = "per hectare" + + # Prevention + prevention: List[str] = field(default_factory=list) + + # Health projection + health_projection: Dict = field(default_factory=dict) + + # Expert contact + expert_contact: Dict = field(default_factory=dict) + + # For healthy plants (not used in 6-class model) + maintenance_tips: List[str] = field(default_factory=list) + expected_yield: Dict = field(default_factory=dict) + healthy_message: str = "" + + # Metadata + timestamp: str = field(default_factory=lambda: datetime.now().isoformat()) + + def to_dict(self) -> Dict: + """Convert to dictionary for JSON serialization.""" + return { + "detection": { + "class_index": self.class_index, + "class_name": self.class_name, + "disease_key": self.disease_key, + "confidence": round(self.confidence, 4), + "confidence_percent": round(self.confidence * 100, 1), + "crop_type": self.crop_type, + "is_healthy": self.is_healthy + }, + "disease_info": { + "display_name": self.display_name, + "scientific_name": self.scientific_name, + "category": self.category, + "severity": { + "level": self.severity_level, + "scale": self.severity_scale, + "description": self.severity_description + }, + "symptoms": self.symptoms, + "transmission": self.transmission, + "yield_loss": { + "min_percent": self.yield_loss_min, + "max_percent": self.yield_loss_max, + "description": self.yield_loss_description + } + }, + "treatments": self.treatments, + "costs": { + "min_ngn": self.treatment_cost_min, + "max_ngn": self.treatment_cost_max, + "unit": self.cost_unit + }, + "prevention": self.prevention, + "health_projection": self.health_projection, + "expert_contact": self.expert_contact, + "healthy_plant": { + "maintenance_tips": self.maintenance_tips, + "expected_yield": self.expected_yield, + "message": self.healthy_message + }, + "timestamp": self.timestamp + } + + def get_confidence_level(self) -> str: + """Get human-readable confidence level.""" + if self.confidence >= 0.85: + return "high" + elif self.confidence >= 0.60: + return "medium" + elif self.confidence >= 0.40: + return "low" + else: + return "very_low" + + def get_summary(self) -> str: + """Get a brief summary of the detection.""" + if self.is_healthy: + return f"Your {self.crop_type} plant appears healthy ({self.confidence:.0%} confidence)." + else: + return f"Detected {self.display_name} in your {self.crop_type} ({self.confidence:.0%} confidence). Severity: {self.severity_level}." + + +# ============================================================================= +# DISEASE DETECTOR SERVICE +# ============================================================================= + +class DiseaseDetectorService: + """ + Service for detecting crop diseases and retrieving comprehensive information. + Combines YOLO model predictions with knowledge base data. + + Supports 6 disease classes (no healthy classes): + - Cassava: Bacterial Blight, Mosaic Virus + - Cocoa: Monilia Disease, Phytophthora Disease + - Tomato: Gray Mold, Wilt Disease + """ + + def __init__( + self, + knowledge_base_path: Optional[str] = None, + auto_load_model: bool = False + ): + """ + Initialize the disease detector service. + + Args: + knowledge_base_path: Path to knowledge_base.json + auto_load_model: Whether to load YOLO model immediately + """ + from config import KNOWLEDGE_BASE_PATH + + self.knowledge_base_path = knowledge_base_path or str(KNOWLEDGE_BASE_PATH) + self._knowledge_base: Optional[Dict] = None + self._yolo_model = None + + # Load knowledge base + self._load_knowledge_base() + + # Optionally load YOLO model + if auto_load_model: + self._load_yolo_model() + + logger.info("DiseaseDetectorService initialized") + + # ========================================================================= + # LOADING METHODS + # ========================================================================= + + def _load_knowledge_base(self) -> None: + """Load the disease knowledge base from JSON file.""" + try: + kb_path = Path(self.knowledge_base_path) + + if not kb_path.exists(): + logger.error(f"Knowledge base not found at {kb_path}") + raise FileNotFoundError(f"Knowledge base not found: {kb_path}") + + with open(kb_path, 'r', encoding='utf-8') as f: + self._knowledge_base = json.load(f) + + # Validate structure + if "diseases" not in self._knowledge_base: + raise ValueError("Invalid knowledge base: missing 'diseases' key") + + disease_count = len(self._knowledge_base["diseases"]) + logger.info(f"Knowledge base loaded: {disease_count} diseases") + + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in knowledge base: {e}") + raise ValueError(f"Could not parse knowledge base: {e}") + except Exception as e: + logger.error(f"Failed to load knowledge base: {e}") + raise + + def _load_yolo_model(self) -> None: + """Load the YOLO model for inference.""" + from models.yolo_model import get_yolo_model + + self._yolo_model = get_yolo_model() + self._yolo_model.load_model() + logger.info("YOLO model loaded") + + def ensure_model_loaded(self) -> None: + """Ensure YOLO model is loaded before inference.""" + if self._yolo_model is None: + self._load_yolo_model() + elif not self._yolo_model.is_loaded: + self._yolo_model.load_model() + + # ========================================================================= + # KNOWLEDGE BASE ACCESS + # ========================================================================= + + def get_disease_info(self, disease_key: str) -> Optional[Dict]: + """ + Get disease information from knowledge base. + + Args: + disease_key: Key identifying the disease (e.g., 'cassava_mosaic_virus') + + Returns: + Disease information dictionary or None if not found + """ + if self._knowledge_base is None: + logger.warning("Knowledge base not loaded") + return None + + diseases = self._knowledge_base.get("diseases", {}) + return diseases.get(disease_key) + + def get_all_diseases(self) -> Dict: + """Get all diseases from knowledge base.""" + if self._knowledge_base is None: + return {} + return self._knowledge_base.get("diseases", {}) + + def get_diseases_by_crop(self, crop: str) -> Dict: + """ + Get all diseases for a specific crop. + + Args: + crop: Crop type ('cassava', 'cocoa', 'tomato') + + Returns: + Dictionary of diseases for the specified crop + """ + all_diseases = self.get_all_diseases() + return { + key: info for key, info in all_diseases.items() + if info.get("crop") == crop + } + + # ========================================================================= + # DETECTION METHODS + # ========================================================================= + + def detect( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> DetectionResult: + """ + Detect disease in an image and return comprehensive information. + + Args: + image: Input image (path, PIL Image, or numpy array) + + Returns: + DetectionResult with disease information + """ + self.ensure_model_loaded() + + # Get YOLO prediction + from models.yolo_model import PredictionResult + prediction = self._yolo_model.predict(image) + + # Build detection result with knowledge base info + return self._build_detection_result(prediction) + + def detect_with_image( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> Tuple[DetectionResult, Image.Image]: + """ + Detect disease and return annotated image. + + Args: + image: Input image + + Returns: + Tuple of (DetectionResult, annotated PIL Image) + """ + self.ensure_model_loaded() + + # Get YOLO prediction with visualization + prediction, annotated_image = self._yolo_model.predict_with_visualization(image) + + # Build detection result + result = self._build_detection_result(prediction) + + return result, annotated_image + + def _build_detection_result(self, prediction) -> DetectionResult: + """ + Build a DetectionResult from a YOLO prediction and knowledge base. + + Args: + prediction: PredictionResult from YOLO model + + Returns: + DetectionResult with complete disease information + """ + # Get disease info from knowledge base + disease_info = self.get_disease_info(prediction.disease_key) + + if disease_info is None: + # Return basic result without knowledge base info + return DetectionResult( + class_index=prediction.class_index, + class_name=prediction.class_name, + disease_key=prediction.disease_key, + confidence=prediction.confidence, + crop_type=prediction.crop_type, + is_healthy=prediction.is_healthy, + display_name=prediction.class_name + ) + + # Extract severity info + severity = disease_info.get("severity", {}) + + # Extract yield loss info + yield_loss = disease_info.get("yield_loss", {}) + + # Extract treatment costs + treatment_cost = disease_info.get("total_treatment_cost", {}) + + # Build complete result + result = DetectionResult( + # Basic detection info + class_index=prediction.class_index, + class_name=prediction.class_name, + disease_key=prediction.disease_key, + confidence=prediction.confidence, + crop_type=prediction.crop_type, + is_healthy=prediction.is_healthy, + + # Disease details + display_name=disease_info.get("display_name", prediction.class_name), + scientific_name=disease_info.get("scientific_name", ""), + category=disease_info.get("category", ""), + + # Severity + severity_level=severity.get("level", ""), + severity_scale=severity.get("scale", 0), + severity_description=severity.get("description", ""), + + # Symptoms and transmission + symptoms=disease_info.get("symptoms", []), + transmission=disease_info.get("how_it_spreads", []), + + # Yield impact + yield_loss_min=yield_loss.get("min_percent", 0), + yield_loss_max=yield_loss.get("max_percent", 0), + yield_loss_description=yield_loss.get("description", ""), + + # Treatments + treatments=disease_info.get("treatments", {}), + + # Costs + treatment_cost_min=treatment_cost.get("min_ngn", 0), + treatment_cost_max=treatment_cost.get("max_ngn", 0), + cost_unit=treatment_cost.get("per", "hectare"), + + # Prevention + prevention=disease_info.get("prevention", []), + + # Health projection + health_projection=disease_info.get("health_projection", {}), + + # Expert contact + expert_contact=disease_info.get("expert_contact", {}), + + # For healthy plants (not used in 6-class model) + maintenance_tips=disease_info.get("maintenance_tips", []), + expected_yield=disease_info.get("expected_yield", {}), + healthy_message=disease_info.get("message", "") + ) + + return result + + # ========================================================================= + # UTILITY METHODS + # ========================================================================= + + def get_treatment_summary( + self, + detection_result: DetectionResult, + include_traditional: bool = True + ) -> Dict: + """ + Get a summarized treatment plan from detection result. + + Args: + detection_result: The detection result + include_traditional: Whether to include traditional remedies + + Returns: + Summarized treatment dictionary + """ + treatments = detection_result.treatments + + summary = { + "immediate_actions": [], + "chemical_options": [], + "organic_options": [], + "traditional_options": [], + "resistant_varieties": [], + "estimated_cost": { + "min_ngn": detection_result.treatment_cost_min, + "max_ngn": detection_result.treatment_cost_max, + "unit": detection_result.cost_unit + } + } + + # Cultural practices (immediate actions) + cultural = treatments.get("cultural", []) + for t in cultural[:3]: # Top 3 + summary["immediate_actions"].append({ + "action": t.get("method", ""), + "description": t.get("description", ""), + "effectiveness": t.get("effectiveness", "") + }) + + # Chemical treatments + chemical = treatments.get("chemical", []) + for t in chemical[:2]: # Top 2 + summary["chemical_options"].append({ + "product": t.get("product_name", ""), + "brands": t.get("local_brands", []), + "dosage": t.get("dosage", ""), + "cost_min": t.get("cost_ngn_min", 0), + "cost_max": t.get("cost_ngn_max", 0) + }) + + # Biological/organic options + biological = treatments.get("biological", []) + for t in biological: + summary["organic_options"].append({ + "method": t.get("method", ""), + "description": t.get("description", ""), + "effectiveness": t.get("effectiveness", "") + }) + + # Traditional methods + if include_traditional: + traditional = treatments.get("traditional", []) + for t in traditional: + summary["traditional_options"].append({ + "method": t.get("method", ""), + "description": t.get("description", ""), + "cost": t.get("cost_ngn", 0) + }) + + # Resistant varieties + resistant = treatments.get("resistant_varieties", []) + for v in resistant[:3]: # Top 3 + summary["resistant_varieties"].append({ + "name": v.get("variety_name", ""), + "resistance": v.get("resistance_level", ""), + "source": v.get("source", ""), + "cost": v.get("cost_ngn_per_bundle", 0) + }) + + return summary + + def get_health_projection_for_stage( + self, + detection_result: DetectionResult, + infection_stage: str = "early_detection" + ) -> Dict: + """ + Get health projection for a specific infection stage. + + Args: + detection_result: The detection result + infection_stage: Stage of infection (early_detection, moderate_infection, severe_infection) + + Returns: + Health projection dictionary + """ + projections = detection_result.health_projection + + if infection_stage in projections: + return projections[infection_stage] + + return { + "recovery_chance_percent": 0, + "message": "Unable to determine health projection." + } + + def validate_image( + self, + image: Union[str, Path, Image.Image, np.ndarray] + ) -> Tuple[bool, str]: + """ + Validate an image before detection. + + Args: + image: Input image + + Returns: + Tuple of (is_valid, message) + """ + self.ensure_model_loaded() + + try: + # Use YOLO model's preprocessing and validation + pil_image = self._yolo_model.preprocess_image(image) + return self._yolo_model.validate_image(pil_image) + except Exception as e: + return False, str(e) + + +# ============================================================================= +# SINGLETON INSTANCE +# ============================================================================= + +_service_instance: Optional[DiseaseDetectorService] = None + + +def get_disease_detector() -> DiseaseDetectorService: + """ + Get the singleton disease detector service instance. + + Returns: + DiseaseDetectorService instance + """ + global _service_instance + + if _service_instance is None: + _service_instance = DiseaseDetectorService(auto_load_model=False) + + return _service_instance + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + +def detect_crop_disease( + image: Union[str, Path, Image.Image, np.ndarray] +) -> DetectionResult: + """ + Convenience function to detect disease in a crop image. + + Args: + image: Input image (path, PIL Image, or numpy array) + + Returns: + DetectionResult with complete disease information + """ + service = get_disease_detector() + return service.detect(image) + + +def detect_crop_disease_with_image( + image: Union[str, Path, Image.Image, np.ndarray] +) -> Tuple[DetectionResult, Image.Image]: + """ + Detect disease and return annotated image. + + Args: + image: Input image + + Returns: + Tuple of (DetectionResult, annotated Image) + """ + service = get_disease_detector() + return service.detect_with_image(image) + + +def get_disease_information(disease_key: str) -> Optional[Dict]: + """ + Get disease information from knowledge base. + + Args: + disease_key: Key identifying the disease + + Returns: + Disease information dictionary + """ + service = get_disease_detector() + return service.get_disease_info(disease_key) + + +# ============================================================================= +# MAIN - Test the service +# ============================================================================= + +if __name__ == "__main__": + print("=" * 60) + print("Disease Detector Service Test (6-Class Model)") + print("=" * 60) + + # Initialize service + print("\n1. Initializing Disease Detector Service...") + service = DiseaseDetectorService(auto_load_model=False) + + # Test knowledge base access + print("\n2. Testing knowledge base access...") + + # Get all diseases + diseases = service.get_all_diseases() + print(f" Total diseases in knowledge base: {len(diseases)}") + + # Get diseases by crop + for crop in ["cassava", "cocoa", "tomato"]: + crop_diseases = service.get_diseases_by_crop(crop) + print(f" {crop.capitalize()} diseases: {len(crop_diseases)}") + + # Test getting specific disease info (updated for 6-class model) + print("\n3. Testing disease info retrieval...") + test_keys = [ + "cassava_bacterial_blight", + "cassava_mosaic_virus", + "cocoa_monilia_disease", + "cocoa_phytophthora_disease", + "tomato_gray_mold", + "tomato_wilt_disease" + ] + + for key in test_keys: + info = service.get_disease_info(key) + if info: + print(f" ✓ {key}: {info.get('display_name', 'N/A')}") + else: + print(f" ✗ {key}: Not found") + + # Test with sample disease (without actual image) + print("\n4. Testing DetectionResult structure...") + + # Create a mock detection result + mock_result = DetectionResult( + class_index=1, + class_name="Cassava Mosaic Virus", + disease_key="cassava_mosaic_virus", + confidence=0.92, + crop_type="cassava", + is_healthy=False, + display_name="Cassava Mosaic Virus", + severity_level="very_high", + severity_scale=5 + ) + + print(f" Summary: {mock_result.get_summary()}") + print(f" Confidence level: {mock_result.get_confidence_level()}") + + print("\n5. To test with actual image detection, run:") + print(" >>> service.ensure_model_loaded()") + print(" >>> result = service.detect('path/to/image.jpg')") + print(" >>> print(result.to_dict())") + + print("\n" + "=" * 60) + print("✅ Disease Detector Service test completed!") + print("=" * 60) diff --git a/services/session_manager.py b/services/session_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..7202bc84939cab47a2977c0cd6a4206287f60b57 --- /dev/null +++ b/services/session_manager.py @@ -0,0 +1,860 @@ +""" +FarmEyes Session Manager +======================== +Manages user sessions for maintaining: +- Diagnosis context across chat interactions +- Chat history within a session +- Language preferences +- Session lifecycle (creation, access, cleanup) + +Thread-safe implementation for concurrent API requests. +Optimized for memory efficiency with automatic cleanup. +""" + +import uuid +import time +import threading +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field +from datetime import datetime +import logging +import json + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# CHAT MESSAGE DATACLASS +# ============================================================================= + +@dataclass +class ChatMessage: + """ + Represents a single chat message. + + Attributes: + role: 'user' or 'assistant' + content: Message text + timestamp: When message was created + language: Language code (en, ha, yo, ig) + """ + role: str # 'user' or 'assistant' + content: str + timestamp: str = field(default_factory=lambda: datetime.now().isoformat()) + language: str = "en" + + def to_dict(self) -> Dict: + """Convert to dictionary for JSON serialization.""" + return { + "role": self.role, + "content": self.content, + "timestamp": self.timestamp, + "language": self.language + } + + @classmethod + def from_dict(cls, data: Dict) -> 'ChatMessage': + """Create ChatMessage from dictionary.""" + return cls( + role=data.get("role", "user"), + content=data.get("content", ""), + timestamp=data.get("timestamp", datetime.now().isoformat()), + language=data.get("language", "en") + ) + + +# ============================================================================= +# DIAGNOSIS CONTEXT DATACLASS +# ============================================================================= + +@dataclass +class DiagnosisContext: + """ + Stores the current diagnosis context for chat. + + This is populated after a successful disease detection + and used to provide context for the chat assistant. + """ + # Detection info + disease_key: str = "" + disease_name: str = "" + crop_type: str = "" + confidence: float = 0.0 + severity_level: str = "" + + # Additional context + symptoms: List[str] = field(default_factory=list) + treatment_summary: str = "" + prevention_tips: List[str] = field(default_factory=list) + cost_estimate: str = "" + + # Metadata + timestamp: str = field(default_factory=lambda: datetime.now().isoformat()) + image_analyzed: bool = False + + def to_dict(self) -> Dict: + """Convert to dictionary for JSON serialization.""" + return { + "disease_key": self.disease_key, + "disease_name": self.disease_name, + "crop_type": self.crop_type, + "confidence": self.confidence, + "severity_level": self.severity_level, + "symptoms": self.symptoms, + "treatment_summary": self.treatment_summary, + "prevention_tips": self.prevention_tips, + "cost_estimate": self.cost_estimate, + "timestamp": self.timestamp, + "image_analyzed": self.image_analyzed + } + + @classmethod + def from_dict(cls, data: Dict) -> 'DiagnosisContext': + """Create DiagnosisContext from dictionary.""" + return cls( + disease_key=data.get("disease_key", ""), + disease_name=data.get("disease_name", ""), + crop_type=data.get("crop_type", ""), + confidence=data.get("confidence", 0.0), + severity_level=data.get("severity_level", ""), + symptoms=data.get("symptoms", []), + treatment_summary=data.get("treatment_summary", ""), + prevention_tips=data.get("prevention_tips", []), + cost_estimate=data.get("cost_estimate", ""), + timestamp=data.get("timestamp", datetime.now().isoformat()), + image_analyzed=data.get("image_analyzed", False) + ) + + @classmethod + def from_diagnosis_report(cls, report) -> 'DiagnosisContext': + """ + Create DiagnosisContext from a DiagnosisReport object. + + Args: + report: DiagnosisReport from diagnosis_generator + + Returns: + DiagnosisContext populated with report data + """ + # Extract treatment summary + treatment_parts = [] + if report.immediate_actions: + actions = [a.get("action", "") for a in report.immediate_actions[:2]] + treatment_parts.append(f"Immediate: {', '.join(actions)}") + if report.chemical_treatments: + chemicals = [c.get("product", "") for c in report.chemical_treatments[:1]] + treatment_parts.append(f"Chemical: {', '.join(chemicals)}") + + treatment_summary = ". ".join(treatment_parts) if treatment_parts else "Consult expert" + + # Extract cost estimate + if report.treatment_cost_min > 0 and report.treatment_cost_max > 0: + cost_estimate = f"₦{report.treatment_cost_min:,} - ₦{report.treatment_cost_max:,} per hectare" + else: + cost_estimate = "Cost varies by treatment" + + return cls( + disease_key=report.raw_detection.get("detection", {}).get("disease_key", ""), + disease_name=report.disease_name, + crop_type=report.crop_type, + confidence=report.confidence, + severity_level=report.severity_level, + symptoms=report.symptoms[:5] if report.symptoms else [], + treatment_summary=treatment_summary, + prevention_tips=report.prevention_tips[:3] if report.prevention_tips else [], + cost_estimate=cost_estimate, + image_analyzed=True + ) + + def get_context_string(self) -> str: + """ + Generate a context string for the chat assistant. + + Returns: + Formatted string with diagnosis context + """ + if not self.image_analyzed: + return "No diagnosis has been made yet." + + symptoms_str = ", ".join(self.symptoms[:3]) if self.symptoms else "Not specified" + + return f"""DIAGNOSIS CONTEXT: +- Crop: {self.crop_type.capitalize()} +- Disease: {self.disease_name} +- Confidence: {self.confidence * 100:.1f}% +- Severity: {self.severity_level} +- Key Symptoms: {symptoms_str} +- Treatment: {self.treatment_summary} +- Estimated Cost: {self.cost_estimate}""" + + def is_valid(self) -> bool: + """Check if diagnosis context has valid data.""" + return self.image_analyzed and self.disease_name and self.crop_type + + +# ============================================================================= +# USER SESSION DATACLASS +# ============================================================================= + +@dataclass +class UserSession: + """ + Represents a complete user session. + + Contains: + - Session identification + - Language preference + - Current diagnosis context + - Chat history + - Timestamps for lifecycle management + """ + # Session identification + session_id: str = field(default_factory=lambda: str(uuid.uuid4())) + + # User preferences + language: str = "en" + + # Diagnosis context + diagnosis: DiagnosisContext = field(default_factory=DiagnosisContext) + + # Chat history + chat_history: List[ChatMessage] = field(default_factory=list) + + # Timestamps + created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + last_accessed: float = field(default_factory=time.time) + + def to_dict(self) -> Dict: + """Convert session to dictionary for JSON serialization.""" + return { + "session_id": self.session_id, + "language": self.language, + "diagnosis": self.diagnosis.to_dict(), + "chat_history": [msg.to_dict() for msg in self.chat_history], + "created_at": self.created_at, + "last_accessed": self.last_accessed + } + + @classmethod + def from_dict(cls, data: Dict) -> 'UserSession': + """Create UserSession from dictionary.""" + session = cls( + session_id=data.get("session_id", str(uuid.uuid4())), + language=data.get("language", "en"), + created_at=data.get("created_at", datetime.now().isoformat()), + last_accessed=data.get("last_accessed", time.time()) + ) + + # Load diagnosis + if "diagnosis" in data: + session.diagnosis = DiagnosisContext.from_dict(data["diagnosis"]) + + # Load chat history + if "chat_history" in data: + session.chat_history = [ + ChatMessage.from_dict(msg) for msg in data["chat_history"] + ] + + return session + + def add_message(self, role: str, content: str) -> ChatMessage: + """ + Add a message to chat history. + + Args: + role: 'user' or 'assistant' + content: Message text + + Returns: + The created ChatMessage + """ + message = ChatMessage( + role=role, + content=content, + language=self.language + ) + self.chat_history.append(message) + self.touch() # Update last accessed + return message + + def get_chat_history_for_prompt(self, max_messages: int = 10) -> str: + """ + Get recent chat history formatted for LLM prompt. + + Args: + max_messages: Maximum number of recent messages to include + + Returns: + Formatted string of recent conversation + """ + if not self.chat_history: + return "" + + recent = self.chat_history[-max_messages:] + formatted = [] + + for msg in recent: + role_label = "Farmer" if msg.role == "user" else "FarmEyes" + formatted.append(f"{role_label}: {msg.content}") + + return "\n".join(formatted) + + def clear_chat_history(self): + """Clear all chat messages but keep diagnosis context.""" + self.chat_history = [] + self.touch() + + def clear_diagnosis(self): + """Clear diagnosis context and chat history.""" + self.diagnosis = DiagnosisContext() + self.chat_history = [] + self.touch() + + def touch(self): + """Update last accessed timestamp.""" + self.last_accessed = time.time() + + def is_expired(self, lifetime_seconds: int) -> bool: + """ + Check if session has expired. + + Args: + lifetime_seconds: Session lifetime in seconds + + Returns: + True if session has expired + """ + return (time.time() - self.last_accessed) > lifetime_seconds + + +# ============================================================================= +# SESSION MANAGER +# ============================================================================= + +class SessionManager: + """ + Thread-safe session manager for FarmEyes application. + + Handles: + - Session creation and retrieval + - Diagnosis context management + - Chat history management + - Automatic cleanup of expired sessions + + Usage: + manager = SessionManager() + session = manager.create_session("ha") # Create with Hausa + manager.update_diagnosis(session.session_id, diagnosis_report) + manager.add_chat_message(session.session_id, "user", "How to treat?") + """ + + def __init__( + self, + session_lifetime: int = 3600, + max_sessions: int = 1000, + max_chat_history: int = 50, + cleanup_interval: int = 300 + ): + """ + Initialize the session manager. + + Args: + session_lifetime: Session lifetime in seconds (default: 1 hour) + max_sessions: Maximum number of concurrent sessions + max_chat_history: Maximum messages per session + cleanup_interval: Interval for cleanup checks in seconds + """ + self._sessions: Dict[str, UserSession] = {} + self._lock = threading.RLock() # Reentrant lock for thread safety + + # Configuration + self.session_lifetime = session_lifetime + self.max_sessions = max_sessions + self.max_chat_history = max_chat_history + self.cleanup_interval = cleanup_interval + + # Cleanup tracking + self._last_cleanup = time.time() + + logger.info(f"SessionManager initialized: lifetime={session_lifetime}s, max={max_sessions}") + + # ========================================================================= + # SESSION LIFECYCLE + # ========================================================================= + + def create_session(self, language: str = "en") -> UserSession: + """ + Create a new user session. + + Args: + language: Initial language preference + + Returns: + New UserSession object + """ + with self._lock: + # Run cleanup if needed + self._maybe_cleanup() + + # Check session limit + if len(self._sessions) >= self.max_sessions: + self._force_cleanup() + + # Create new session + session = UserSession(language=language) + self._sessions[session.session_id] = session + + logger.info(f"Session created: {session.session_id[:8]}... (lang={language})") + return session + + def get_session(self, session_id: str) -> Optional[UserSession]: + """ + Retrieve a session by ID. + + Args: + session_id: The session ID to retrieve + + Returns: + UserSession if found and not expired, None otherwise + """ + with self._lock: + session = self._sessions.get(session_id) + + if session is None: + return None + + # Check if expired + if session.is_expired(self.session_lifetime): + self._remove_session(session_id) + return None + + # Update access time + session.touch() + return session + + def get_or_create_session( + self, + session_id: Optional[str] = None, + language: str = "en" + ) -> UserSession: + """ + Get existing session or create new one. + + Args: + session_id: Optional session ID to retrieve + language: Language for new session if created + + Returns: + Existing or new UserSession + """ + if session_id: + session = self.get_session(session_id) + if session: + return session + + return self.create_session(language) + + def delete_session(self, session_id: str) -> bool: + """ + Delete a session. + + Args: + session_id: The session ID to delete + + Returns: + True if session was deleted, False if not found + """ + with self._lock: + return self._remove_session(session_id) + + def _remove_session(self, session_id: str) -> bool: + """Internal method to remove a session.""" + if session_id in self._sessions: + del self._sessions[session_id] + logger.debug(f"Session removed: {session_id[:8]}...") + return True + return False + + # ========================================================================= + # LANGUAGE MANAGEMENT + # ========================================================================= + + def set_language(self, session_id: str, language: str) -> bool: + """ + Update session language preference. + + Args: + session_id: The session ID + language: New language code (en, ha, yo, ig) + + Returns: + True if updated, False if session not found + """ + session = self.get_session(session_id) + if session: + session.language = language + logger.debug(f"Session {session_id[:8]}... language set to {language}") + return True + return False + + def get_language(self, session_id: str) -> str: + """ + Get session language preference. + + Args: + session_id: The session ID + + Returns: + Language code or 'en' default + """ + session = self.get_session(session_id) + return session.language if session else "en" + + # ========================================================================= + # DIAGNOSIS MANAGEMENT + # ========================================================================= + + def update_diagnosis( + self, + session_id: str, + diagnosis_context: DiagnosisContext + ) -> bool: + """ + Update diagnosis context for a session. + + Args: + session_id: The session ID + diagnosis_context: New diagnosis context + + Returns: + True if updated, False if session not found + """ + session = self.get_session(session_id) + if session: + session.diagnosis = diagnosis_context + # Clear old chat history when new diagnosis is made + session.chat_history = [] + logger.info(f"Session {session_id[:8]}... diagnosis updated: {diagnosis_context.disease_name}") + return True + return False + + def update_diagnosis_from_report(self, session_id: str, report) -> bool: + """ + Update diagnosis from a DiagnosisReport object. + + Args: + session_id: The session ID + report: DiagnosisReport from diagnosis_generator + + Returns: + True if updated, False if session not found + """ + context = DiagnosisContext.from_diagnosis_report(report) + return self.update_diagnosis(session_id, context) + + def get_diagnosis(self, session_id: str) -> Optional[DiagnosisContext]: + """ + Get current diagnosis context for a session. + + Args: + session_id: The session ID + + Returns: + DiagnosisContext or None + """ + session = self.get_session(session_id) + return session.diagnosis if session else None + + def has_diagnosis(self, session_id: str) -> bool: + """ + Check if session has a valid diagnosis. + + Args: + session_id: The session ID + + Returns: + True if valid diagnosis exists + """ + session = self.get_session(session_id) + return session is not None and session.diagnosis.is_valid() + + def clear_diagnosis(self, session_id: str) -> bool: + """ + Clear diagnosis and chat history for a session. + + Args: + session_id: The session ID + + Returns: + True if cleared, False if session not found + """ + session = self.get_session(session_id) + if session: + session.clear_diagnosis() + logger.info(f"Session {session_id[:8]}... diagnosis cleared") + return True + return False + + # ========================================================================= + # CHAT MANAGEMENT + # ========================================================================= + + def add_chat_message( + self, + session_id: str, + role: str, + content: str + ) -> Optional[ChatMessage]: + """ + Add a message to session chat history. + + Args: + session_id: The session ID + role: 'user' or 'assistant' + content: Message text + + Returns: + Created ChatMessage or None if session not found + """ + session = self.get_session(session_id) + if not session: + return None + + # Enforce chat history limit + if len(session.chat_history) >= self.max_chat_history: + # Remove oldest messages (keep recent context) + session.chat_history = session.chat_history[-(self.max_chat_history - 1):] + + return session.add_message(role, content) + + def get_chat_history(self, session_id: str) -> List[ChatMessage]: + """ + Get chat history for a session. + + Args: + session_id: The session ID + + Returns: + List of ChatMessage objects + """ + session = self.get_session(session_id) + return session.chat_history if session else [] + + def get_chat_context(self, session_id: str, max_messages: int = 10) -> str: + """ + Get formatted chat context for LLM prompt. + + Args: + session_id: The session ID + max_messages: Maximum recent messages to include + + Returns: + Formatted conversation string + """ + session = self.get_session(session_id) + if not session: + return "" + return session.get_chat_history_for_prompt(max_messages) + + def clear_chat_history(self, session_id: str) -> bool: + """ + Clear chat history but keep diagnosis. + + Args: + session_id: The session ID + + Returns: + True if cleared, False if session not found + """ + session = self.get_session(session_id) + if session: + session.clear_chat_history() + return True + return False + + # ========================================================================= + # CLEANUP + # ========================================================================= + + def _maybe_cleanup(self): + """Run cleanup if enough time has passed since last cleanup.""" + if (time.time() - self._last_cleanup) > self.cleanup_interval: + self._cleanup_expired() + + def _cleanup_expired(self): + """Remove all expired sessions.""" + expired = [] + + for session_id, session in self._sessions.items(): + if session.is_expired(self.session_lifetime): + expired.append(session_id) + + for session_id in expired: + self._remove_session(session_id) + + self._last_cleanup = time.time() + + if expired: + logger.info(f"Cleaned up {len(expired)} expired sessions") + + def _force_cleanup(self): + """Force cleanup when session limit is reached.""" + # Remove oldest sessions until we're under 80% capacity + target_count = int(self.max_sessions * 0.8) + + # Sort by last accessed time + sorted_sessions = sorted( + self._sessions.items(), + key=lambda x: x[1].last_accessed + ) + + # Remove oldest + while len(self._sessions) > target_count and sorted_sessions: + session_id, _ = sorted_sessions.pop(0) + self._remove_session(session_id) + + logger.warning(f"Force cleanup: reduced sessions to {len(self._sessions)}") + + # ========================================================================= + # STATISTICS + # ========================================================================= + + def get_stats(self) -> Dict[str, Any]: + """ + Get session manager statistics. + + Returns: + Dictionary with stats + """ + with self._lock: + active_count = 0 + with_diagnosis = 0 + total_messages = 0 + + for session in self._sessions.values(): + if not session.is_expired(self.session_lifetime): + active_count += 1 + if session.diagnosis.is_valid(): + with_diagnosis += 1 + total_messages += len(session.chat_history) + + return { + "total_sessions": len(self._sessions), + "active_sessions": active_count, + "sessions_with_diagnosis": with_diagnosis, + "total_chat_messages": total_messages, + "max_sessions": self.max_sessions, + "session_lifetime_seconds": self.session_lifetime + } + + +# ============================================================================= +# SINGLETON INSTANCE +# ============================================================================= + +_session_manager: Optional[SessionManager] = None + + +def get_session_manager() -> SessionManager: + """ + Get the singleton SessionManager instance. + + Returns: + SessionManager instance + """ + global _session_manager + + if _session_manager is None: + # Import config here to avoid circular imports + try: + from config import session_config + _session_manager = SessionManager( + session_lifetime=session_config.session_lifetime, + max_sessions=session_config.max_sessions, + max_chat_history=session_config.max_chat_history, + cleanup_interval=session_config.cleanup_interval + ) + except ImportError: + # Use defaults if config not available + _session_manager = SessionManager() + + return _session_manager + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + +def create_session(language: str = "en") -> UserSession: + """Create a new session.""" + return get_session_manager().create_session(language) + + +def get_session(session_id: str) -> Optional[UserSession]: + """Get a session by ID.""" + return get_session_manager().get_session(session_id) + + +def get_or_create_session( + session_id: Optional[str] = None, + language: str = "en" +) -> UserSession: + """Get existing session or create new one.""" + return get_session_manager().get_or_create_session(session_id, language) + + +# ============================================================================= +# MAIN - Test the session manager +# ============================================================================= + +if __name__ == "__main__": + print("=" * 60) + print("Session Manager Test") + print("=" * 60) + + # Create manager + manager = SessionManager(session_lifetime=60) # 1 minute for testing + + # Test session creation + print("\n1. Creating sessions...") + session1 = manager.create_session("en") + session2 = manager.create_session("ha") + print(f" Session 1: {session1.session_id[:8]}... (en)") + print(f" Session 2: {session2.session_id[:8]}... (ha)") + + # Test diagnosis update + print("\n2. Updating diagnosis...") + diagnosis = DiagnosisContext( + disease_key="cassava_mosaic_virus", + disease_name="Cassava Mosaic Virus", + crop_type="cassava", + confidence=0.92, + severity_level="high", + symptoms=["Yellow patches", "Leaf curling"], + treatment_summary="Remove infected plants, use resistant varieties", + image_analyzed=True + ) + manager.update_diagnosis(session1.session_id, diagnosis) + print(f" Diagnosis set: {diagnosis.disease_name}") + + # Test chat messages + print("\n3. Adding chat messages...") + manager.add_chat_message(session1.session_id, "user", "How do I treat this?") + manager.add_chat_message(session1.session_id, "assistant", "First, remove infected plants...") + print(f" Messages added: {len(manager.get_chat_history(session1.session_id))}") + + # Test context retrieval + print("\n4. Getting context...") + print(f" Diagnosis context:\n{manager.get_diagnosis(session1.session_id).get_context_string()}") + + # Test stats + print("\n5. Manager stats:") + stats = manager.get_stats() + for key, value in stats.items(): + print(f" {key}: {value}") + + print("\n" + "=" * 60) + print("✅ Session Manager test completed!") + print("=" * 60) diff --git a/services/translator.py b/services/translator.py new file mode 100644 index 0000000000000000000000000000000000000000..0dc9a08ed56e8e1f21d1a79a60861af1c4fb3356 --- /dev/null +++ b/services/translator.py @@ -0,0 +1,115 @@ +""" +FarmEyes Translator Service – STABLE VERSION +=========================================== +This module exposes ALL symbols expected by services/__init__.py. + +Public API: +- TranslatorService +- TranslationCache +- get_translator +- translate_text +- translate_to_hausa / yoruba / igbo +- get_ui_text (proxy) +- SUPPORTED_LANGUAGES +- NATIVE_LANGUAGE_NAMES +""" + +from typing import List, Dict, Optional +import hashlib + +from models.natlas_model import ( + get_natlas_model, + NATIVE_LANGUAGE_NAMES, +) + +# ------------------------------------------------------------------ +# LANGUAGE CONSTANTS (PUBLIC) +# ------------------------------------------------------------------ + +SUPPORTED_LANGUAGES = { + "en": "English", + "ha": "Hausa", + "yo": "Yoruba", + "ig": "Igbo", +} + +# ------------------------------------------------------------------ +# TRANSLATION CACHE (EXPECTED BY services/__init__.py) +# ------------------------------------------------------------------ + +class TranslationCache: + def __init__(self, max_size: int = 1000): + self._cache: Dict[str, str] = {} + self._max_size = max_size + + def _key(self, text: str, lang: str) -> str: + h = hashlib.md5(text.encode()).hexdigest()[:16] + return f"{lang}:{h}" + + def get(self, text: str, lang: str) -> Optional[str]: + return self._cache.get(self._key(text, lang)) + + def set(self, text: str, lang: str, value: str): + if len(self._cache) >= self._max_size: + self._cache.pop(next(iter(self._cache))) + self._cache[self._key(text, lang)] = value + +# ------------------------------------------------------------------ +# TRANSLATOR SERVICE +# ------------------------------------------------------------------ + +class TranslatorService: + def __init__(self, use_cache: bool = True): + self.model = get_natlas_model() + self.cache = TranslationCache() if use_cache else None + + def translate(self, text: str, lang: str) -> str: + if lang == "en": + return text + + if self.cache: + cached = self.cache.get(text, lang) + if cached: + return cached + + result = self.model.translate(text, lang) + + if self.cache: + self.cache.set(text, lang, result) + + return result + + def translate_batch(self, texts: List[str], lang: str) -> List[str]: + return [self.translate(t, lang) for t in texts] + +# ------------------------------------------------------------------ +# SINGLETON +# ------------------------------------------------------------------ + +_service: Optional[TranslatorService] = None + +def get_translator() -> TranslatorService: + global _service + if _service is None: + _service = TranslatorService() + return _service + +# ------------------------------------------------------------------ +# CONVENIENCE FUNCTIONS (PUBLIC) +# ------------------------------------------------------------------ + +def translate_text(text: str, target_language: str) -> str: + return get_translator().translate(text, target_language) + +def translate_to_hausa(text: str) -> str: + return translate_text(text, "ha") + +def translate_to_yoruba(text: str) -> str: + return translate_text(text, "yo") + +def translate_to_igbo(text: str) -> str: + return translate_text(text, "ig") + +def get_ui_text(key_path: str, language: str = "en") -> str: + # UI text is already handled in app.py; keep proxy for compatibility + return key_path diff --git a/services/tts_service.py b/services/tts_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b35b7713240cb5af87c73a6fd1fd14daad131cc1 --- /dev/null +++ b/services/tts_service.py @@ -0,0 +1,345 @@ +""" +FarmEyes TTS Service +==================== +Text-to-Speech service using Meta MMS-TTS via HuggingFace Transformers (local). + +Supports: +- English (eng) +- Hausa (hau) +- Yoruba (yor) +- Igbo (ibo) + +Pipeline: N-ATLaS Response → TTS → Audio Playback + +NOTE: This uses LOCAL inference with transformers library. +The HuggingFace serverless API no longer reliably supports MMS-TTS models. +""" + +import os +import io +import base64 +import logging +import time +from typing import Optional, Dict +from pathlib import Path + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# CONFIGURATION +# ============================================================================= + +class TTSConfig: + """TTS Service Configuration""" + + # MMS-TTS Model IDs for each language + MODELS = { + "en": "facebook/mms-tts-eng", + "eng": "facebook/mms-tts-eng", + "ha": "facebook/mms-tts-hau", + "hau": "facebook/mms-tts-hau", + "yo": "facebook/mms-tts-yor", + "yor": "facebook/mms-tts-yor", + "ig": "facebook/mms-tts-ibo", + "ibo": "facebook/mms-tts-ibo", + } + + # Language display names + LANGUAGE_NAMES = { + "en": "English", + "ha": "Hausa", + "yo": "Yoruba", + "ig": "Igbo", + } + + # Request settings + MAX_TEXT_LENGTH = 500 # characters (shorter for local inference) + + +# ============================================================================= +# TTS SERVICE +# ============================================================================= + +class TTSService: + """ + Text-to-Speech service using Meta MMS-TTS with local transformers. + + Features: + - Supports English, Hausa, Yoruba, Igbo + - Uses local transformers library (no API needed) + - Returns base64 encoded audio + - Lazy loading of models + + Usage: + service = TTSService() + result = service.synthesize("Hello world", "en") + if result["success"]: + audio_base64 = result["audio_base64"] + """ + + def __init__(self): + """Initialize TTS service.""" + self.config = TTSConfig() + self.models = {} # Cache loaded models + self.tokenizers = {} # Cache tokenizers + self._transformers_available = None + logger.info("TTSService initialized (local transformers mode)") + + def _check_transformers(self) -> bool: + """Check if transformers library is available.""" + if self._transformers_available is None: + try: + import transformers + import torch + import scipy + import numpy + self._transformers_available = True + logger.info("Transformers library available") + except ImportError as e: + self._transformers_available = False + logger.error(f"Transformers not available: {e}") + return self._transformers_available + + def _load_model(self, language: str): + """ + Load model and tokenizer for a language. + Models are cached after first load. + """ + if language in self.models: + return self.models[language], self.tokenizers[language] + + model_id = self.config.MODELS.get(language.lower()) + if not model_id: + raise ValueError(f"Unsupported language: {language}") + + try: + from transformers import VitsModel, AutoTokenizer + import torch + + logger.info(f"Loading TTS model: {model_id}") + start_time = time.time() + + model = VitsModel.from_pretrained(model_id) + tokenizer = AutoTokenizer.from_pretrained(model_id) + + # Move to MPS if available (Apple Silicon) + if torch.backends.mps.is_available(): + model = model.to("mps") + logger.info("TTS model moved to MPS (Apple Silicon)") + + load_time = time.time() - start_time + logger.info(f"TTS model loaded in {load_time:.2f}s") + + # Cache the model + self.models[language] = model + self.tokenizers[language] = tokenizer + + return model, tokenizer + + except Exception as e: + logger.error(f"Failed to load TTS model: {e}") + raise + + def get_model_id(self, language: str) -> Optional[str]: + """Get the MMS-TTS model ID for a language.""" + return self.config.MODELS.get(language.lower()) + + def is_language_supported(self, language: str) -> bool: + """Check if a language is supported.""" + return language.lower() in self.config.MODELS + + def get_supported_languages(self) -> Dict[str, str]: + """Get dictionary of supported languages.""" + return self.config.LANGUAGE_NAMES.copy() + + def synthesize( + self, + text: str, + language: str = "en" + ) -> Dict: + """ + Synthesize speech from text using local transformers. + + Args: + text: Text to convert to speech + language: Language code (en, ha, yo, ig) + + Returns: + Dictionary with: + - success: bool + - audio_base64: str (base64 encoded audio) + - content_type: str (audio MIME type) + - duration: float (estimated duration in seconds) + - language: str + - error: str (if failed) + """ + start_time = time.time() + + # Check transformers availability + if not self._check_transformers(): + return { + "success": False, + "error": "Transformers library not installed. Run: pip install transformers torch scipy", + "language": language + } + + # Validate input + if not text or not text.strip(): + return { + "success": False, + "error": "No text provided", + "language": language + } + + # Normalize language code + lang_key = language.lower() + if lang_key not in self.config.MODELS: + return { + "success": False, + "error": f"Language '{language}' is not supported for TTS", + "language": language + } + + # Truncate if too long + text = text.strip() + if len(text) > self.config.MAX_TEXT_LENGTH: + logger.warning(f"Text truncated from {len(text)} to {self.config.MAX_TEXT_LENGTH} chars") + # Try to break at sentence boundary + truncated = text[:self.config.MAX_TEXT_LENGTH] + for sep in [". ", "! ", "? ", ", "]: + last_sep = truncated.rfind(sep) + if last_sep > self.config.MAX_TEXT_LENGTH // 2: + truncated = truncated[:last_sep + 1] + break + text = truncated.strip() + + try: + import torch + import scipy.io.wavfile + import numpy as np + + # Load model + model, tokenizer = self._load_model(lang_key) + + # Tokenize + inputs = tokenizer(text, return_tensors="pt") + + # Move inputs to same device as model + device = next(model.parameters()).device + inputs = {k: v.to(device) for k, v in inputs.items()} + + # Generate audio + logger.info(f"Generating TTS for: {text[:50]}...") + with torch.no_grad(): + output = model(**inputs).waveform + + # Move to CPU and convert to numpy + waveform = output.squeeze().cpu().numpy() + + # Get sample rate from model config + sample_rate = model.config.sampling_rate + + # Convert to 16-bit PCM + waveform_int16 = (waveform * 32767).astype(np.int16) + + # Write to WAV bytes + wav_buffer = io.BytesIO() + scipy.io.wavfile.write(wav_buffer, sample_rate, waveform_int16) + wav_buffer.seek(0) + + # Encode to base64 + audio_base64 = base64.b64encode(wav_buffer.read()).decode("utf-8") + + # Calculate duration + duration = len(waveform) / sample_rate + + processing_time = time.time() - start_time + logger.info(f"TTS success: {duration:.2f}s audio, {processing_time:.2f}s processing") + + return { + "success": True, + "audio_base64": audio_base64, + "content_type": "audio/wav", + "duration": duration, + "language": language, + "text_length": len(text), + "processing_time": processing_time + } + + except Exception as e: + logger.error(f"TTS synthesis failed: {e}") + return { + "success": False, + "error": str(e), + "language": language + } + + def unload_models(self): + """Unload all cached models to free memory.""" + self.models.clear() + self.tokenizers.clear() + logger.info("TTS models unloaded") + + +# ============================================================================= +# SINGLETON INSTANCE +# ============================================================================= + +_tts_service: Optional[TTSService] = None + + +def get_tts_service() -> TTSService: + """Get or create the TTS service singleton.""" + global _tts_service + + if _tts_service is None: + _tts_service = TTSService() + + return _tts_service + + +def synthesize_speech(text: str, language: str = "en") -> Dict: + """Convenience function to synthesize speech.""" + return get_tts_service().synthesize(text, language) + + +def unload_tts_models(): + """Unload TTS models to free memory.""" + global _tts_service + if _tts_service: + _tts_service.unload_models() + + +# ============================================================================= +# TEST +# ============================================================================= + +if __name__ == "__main__": + print("=" * 60) + print("TTS Service Test (Local Transformers)") + print("=" * 60) + + service = TTSService() + + # Test supported languages + print("\n1. Supported Languages:") + for code, name in service.get_supported_languages().items(): + model = service.get_model_id(code) + print(f" {code}: {name} -> {model}") + + # Test synthesis + print("\n2. Testing English TTS...") + result = service.synthesize("Hello, this is a test of the text to speech system.", "en") + if result["success"]: + print(f" ✅ Success! Audio duration: {result['duration']:.2f}s") + print(f" Processing time: {result['processing_time']:.2f}s") + print(f" Audio size: {len(result['audio_base64'])} chars (base64)") + else: + print(f" ❌ Failed: {result['error']}") + + print("\n" + "=" * 60) + print("Test complete!") + print("=" * 60) diff --git a/services/whisper_service.py b/services/whisper_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3d79c0763785c9d88a7e4c40172524675c2ded39 --- /dev/null +++ b/services/whisper_service.py @@ -0,0 +1,627 @@ +""" +FarmEyes Whisper Service +======================== +Speech-to-text service using OpenAI Whisper for voice input. + +Features: +- Supports multiple audio formats (wav, mp3, m4a, ogg, flac, webm) +- Optimized for Nigerian languages (Hausa, Yoruba, Igbo, English) +- Works offline after model download +- Automatic audio preprocessing +- Memory-efficient processing + +Pipeline: Voice → Whisper → Text → N-ATLaS → Response +""" + +import sys +import os +import io +import tempfile +from pathlib import Path +from typing import Optional, Dict, Tuple, Union +import logging +import time + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================================================= +# AUDIO UTILITIES +# ============================================================================= + +class AudioProcessor: + """ + Audio preprocessing utilities for Whisper. + + Handles: + - Format validation + - File size checks + - Temporary file management + """ + + # Whisper expects 16kHz sample rate + TARGET_SAMPLE_RATE = 16000 + + # Maximum audio duration (seconds) + MAX_DURATION = 30 + + # Supported formats + SUPPORTED_FORMATS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".webm"} + + @classmethod + def validate_file(cls, file_path: Union[str, Path]) -> Tuple[bool, str]: + """ + Validate audio file. + + Args: + file_path: Path to audio file + + Returns: + Tuple of (is_valid, message) + """ + path = Path(file_path) + + # Check existence + if not path.exists(): + return False, "Audio file not found" + + # Check format + if path.suffix.lower() not in cls.SUPPORTED_FORMATS: + return False, f"Unsupported format. Supported: {', '.join(cls.SUPPORTED_FORMATS)}" + + # Check file size (5MB max) + max_size = 5 * 1024 * 1024 + if path.stat().st_size > max_size: + return False, f"File too large. Maximum size: {max_size // (1024*1024)}MB" + + return True, "Valid" + + @classmethod + def validate_bytes(cls, audio_bytes: bytes, filename: str = "audio.wav") -> Tuple[bool, str]: + """ + Validate audio bytes. + + Args: + audio_bytes: Raw audio data + filename: Original filename for format detection + + Returns: + Tuple of (is_valid, message) + """ + if not audio_bytes: + return False, "No audio data provided" + + # Check size + max_size = 5 * 1024 * 1024 + if len(audio_bytes) > max_size: + return False, f"Audio too large. Maximum size: {max_size // (1024*1024)}MB" + + # Check format from filename + ext = Path(filename).suffix.lower() + if ext and ext not in cls.SUPPORTED_FORMATS: + return False, f"Unsupported format. Supported: {', '.join(cls.SUPPORTED_FORMATS)}" + + return True, "Valid" + + @classmethod + def save_temp_file(cls, audio_bytes: bytes, suffix: str = ".wav") -> str: + """ + Save audio bytes to temporary file. + + Args: + audio_bytes: Raw audio data + suffix: File extension + + Returns: + Path to temporary file + """ + fd, temp_path = tempfile.mkstemp(suffix=suffix) + + try: + with os.fdopen(fd, 'wb') as f: + f.write(audio_bytes) + return temp_path + except Exception as e: + os.unlink(temp_path) + raise e + + @classmethod + def cleanup_temp_file(cls, file_path: str): + """ + Clean up temporary file. + + Args: + file_path: Path to temporary file + """ + try: + if os.path.exists(file_path): + os.unlink(file_path) + except Exception as e: + logger.warning(f"Failed to cleanup temp file: {e}") + + +# ============================================================================= +# WHISPER SERVICE +# ============================================================================= + +class WhisperService: + """ + Speech-to-text service using OpenAI Whisper. + + Optimized for: + - Nigerian languages (Hausa, Yoruba, Igbo) + - Agricultural terminology + - Mobile/web audio recording quality + + Usage: + service = WhisperService() + result = service.transcribe(audio_bytes) + text = result["text"] + """ + + # Language hints for Whisper + LANGUAGE_HINTS = { + "en": "english", + "ha": "hausa", + "yo": "yoruba", + "ig": "igbo" + } + + # Model sizes + MODEL_SIZES = { + "tiny": "tiny", + "base": "base", + "small": "small", + "medium": "medium", + "large": "large" + } + + def __init__( + self, + model_size: str = "base", + device: str = "cpu", + download_root: Optional[str] = None + ): + """ + Initialize Whisper service. + + Args: + model_size: Whisper model size (tiny, base, small, medium, large) + device: Compute device (cpu, cuda) + download_root: Custom download directory for model + """ + self.model_size = model_size if model_size in self.MODEL_SIZES else "base" + self.device = device + self.download_root = download_root + + self._model = None + self._is_loaded = False + + logger.info(f"WhisperService initialized: model={self.model_size}, device={self.device}") + + # ========================================================================= + # MODEL MANAGEMENT + # ========================================================================= + + def load_model(self) -> bool: + """ + Load Whisper model into memory. + + Returns: + True if model loaded successfully + """ + if self._is_loaded: + return True + + try: + import whisper + + logger.info(f"Loading Whisper {self.model_size} model...") + start_time = time.time() + + # Load model + self._model = whisper.load_model( + self.model_size, + device=self.device, + download_root=self.download_root + ) + + elapsed = time.time() - start_time + self._is_loaded = True + + logger.info(f"✅ Whisper model loaded in {elapsed:.2f}s") + return True + + except ImportError: + logger.error("Whisper not installed! Run: pip install openai-whisper") + return False + except Exception as e: + logger.error(f"Failed to load Whisper model: {e}") + return False + + def unload_model(self): + """Unload model from memory.""" + if self._model is not None: + del self._model + self._model = None + self._is_loaded = False + logger.info("Whisper model unloaded") + + @property + def is_loaded(self) -> bool: + """Check if model is loaded.""" + return self._is_loaded + + def ensure_loaded(self) -> bool: + """Ensure model is loaded before use.""" + if not self._is_loaded: + return self.load_model() + return True + + # ========================================================================= + # TRANSCRIPTION + # ========================================================================= + + def transcribe( + self, + audio: Union[str, bytes, Path], + language_hint: Optional[str] = None, + task: str = "transcribe" + ) -> Dict: + """ + Transcribe audio to text. + + Args: + audio: Audio file path or bytes + language_hint: Optional language hint (en, ha, yo, ig) + task: "transcribe" or "translate" (translate to English) + + Returns: + Dict with transcription result: + { + "success": bool, + "text": str, + "language": str (detected), + "duration": float (seconds), + "confidence": float (0-1) + } + """ + if not self.ensure_loaded(): + return { + "success": False, + "text": "", + "error": "Model not loaded", + "language": None, + "duration": 0, + "confidence": 0 + } + + temp_file = None + + try: + # Handle input type + if isinstance(audio, bytes): + temp_file = AudioProcessor.save_temp_file(audio) + audio_path = temp_file + elif isinstance(audio, (str, Path)): + audio_path = str(audio) + else: + raise ValueError("Audio must be file path or bytes") + + # Validate file + is_valid, message = AudioProcessor.validate_file(audio_path) + if not is_valid: + return { + "success": False, + "text": "", + "error": message, + "language": None, + "duration": 0, + "confidence": 0 + } + + # Prepare transcription options + options = { + "task": task, + "fp16": False, # Use FP32 for CPU compatibility + } + + # Add language hint if provided + if language_hint and language_hint in self.LANGUAGE_HINTS: + options["language"] = self.LANGUAGE_HINTS[language_hint] + + # Transcribe + logger.info(f"Transcribing audio: {audio_path}") + start_time = time.time() + + result = self._model.transcribe(audio_path, **options) + + elapsed = time.time() - start_time + logger.info(f"Transcription completed in {elapsed:.2f}s") + + # Extract text + text = result.get("text", "").strip() + detected_language = result.get("language", "unknown") + + # Calculate rough confidence from segments + segments = result.get("segments", []) + if segments: + avg_confidence = sum( + s.get("no_speech_prob", 0) for s in segments + ) / len(segments) + confidence = 1.0 - avg_confidence + else: + confidence = 0.5 + + # Get audio duration + duration = 0 + if segments: + duration = segments[-1].get("end", 0) + + return { + "success": True, + "text": text, + "language": detected_language, + "duration": duration, + "confidence": min(1.0, max(0.0, confidence)), + "processing_time": elapsed + } + + except Exception as e: + logger.error(f"Transcription failed: {e}") + return { + "success": False, + "text": "", + "error": str(e), + "language": None, + "duration": 0, + "confidence": 0 + } + + finally: + if temp_file: + AudioProcessor.cleanup_temp_file(temp_file) + + def transcribe_bytes( + self, + audio_bytes: bytes, + filename: str = "audio.wav", + language_hint: Optional[str] = None + ) -> Dict: + """ + Transcribe audio from bytes. + + Args: + audio_bytes: Raw audio data + filename: Original filename (for format detection) + language_hint: Optional language hint + + Returns: + Transcription result dict + """ + is_valid, message = AudioProcessor.validate_bytes(audio_bytes, filename) + if not is_valid: + return { + "success": False, + "text": "", + "error": message, + "language": None, + "duration": 0, + "confidence": 0 + } + + ext = Path(filename).suffix.lower() or ".wav" + + temp_file = None + try: + temp_file = AudioProcessor.save_temp_file(audio_bytes, suffix=ext) + return self.transcribe(temp_file, language_hint=language_hint) + finally: + if temp_file: + AudioProcessor.cleanup_temp_file(temp_file) + + # ========================================================================= + # UTILITY METHODS + # ========================================================================= + + def detect_language(self, audio: Union[str, bytes, Path]) -> Dict: + """ + Detect language in audio without full transcription. + + Args: + audio: Audio file path or bytes + + Returns: + Dict with detected language info + """ + if not self.ensure_loaded(): + return { + "success": False, + "language": None, + "error": "Model not loaded" + } + + temp_file = None + + try: + if isinstance(audio, bytes): + temp_file = AudioProcessor.save_temp_file(audio) + audio_path = temp_file + else: + audio_path = str(audio) + + import whisper + + audio_data = whisper.load_audio(audio_path) + audio_data = whisper.pad_or_trim(audio_data) + mel = whisper.log_mel_spectrogram(audio_data).to(self._model.device) + _, probs = self._model.detect_language(mel) + + detected = max(probs, key=probs.get) + confidence = probs[detected] + + return { + "success": True, + "language": detected, + "confidence": confidence, + "all_probabilities": dict(sorted(probs.items(), key=lambda x: x[1], reverse=True)[:5]) + } + + except Exception as e: + logger.error(f"Language detection failed: {e}") + return { + "success": False, + "language": None, + "error": str(e) + } + + finally: + if temp_file: + AudioProcessor.cleanup_temp_file(temp_file) + + def get_model_info(self) -> Dict: + """Get information about loaded model.""" + info = { + "model_size": self.model_size, + "device": self.device, + "is_loaded": self._is_loaded, + "supported_formats": list(AudioProcessor.SUPPORTED_FORMATS), + "max_duration_seconds": AudioProcessor.MAX_DURATION + } + + if self._is_loaded and self._model is not None: + info["model_dims"] = { + "n_mels": self._model.dims.n_mels, + "n_audio_ctx": self._model.dims.n_audio_ctx, + "n_audio_state": self._model.dims.n_audio_state, + "n_text_ctx": self._model.dims.n_text_ctx, + "n_text_state": self._model.dims.n_text_state, + } + + return info + + +# ============================================================================= +# SINGLETON INSTANCE +# ============================================================================= + +_whisper_service: Optional[WhisperService] = None + + +def get_whisper_service() -> WhisperService: + """ + Get the singleton WhisperService instance. + + Returns: + WhisperService instance + """ + global _whisper_service + + if _whisper_service is None: + try: + from config import whisper_config + _whisper_service = WhisperService( + model_size=whisper_config.model_size, + device=whisper_config.device + ) + except ImportError: + _whisper_service = WhisperService() + + return _whisper_service + + +def unload_whisper_service(): + """Unload the Whisper service.""" + global _whisper_service + + if _whisper_service is not None: + _whisper_service.unload_model() + _whisper_service = None + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + +def transcribe_audio( + audio: Union[str, bytes, Path], + language_hint: Optional[str] = None +) -> Dict: + """ + Transcribe audio to text. + + Args: + audio: Audio file path or bytes + language_hint: Optional language hint + + Returns: + Transcription result dict + """ + return get_whisper_service().transcribe(audio, language_hint=language_hint) + + +def transcribe_bytes( + audio_bytes: bytes, + filename: str = "audio.wav", + language_hint: Optional[str] = None +) -> Dict: + """ + Transcribe audio bytes to text. + + Args: + audio_bytes: Raw audio data + filename: Original filename + language_hint: Optional language hint + + Returns: + Transcription result dict + """ + return get_whisper_service().transcribe_bytes( + audio_bytes, + filename=filename, + language_hint=language_hint + ) + + +# ============================================================================= +# MAIN - Test the service +# ============================================================================= + +if __name__ == "__main__": + print("=" * 60) + print("Whisper Service Test") + print("=" * 60) + + # Initialize service + print("\n1. Initializing WhisperService...") + service = WhisperService(model_size="base", device="cpu") + + # Check model info + print("\n2. Model info:") + info = service.get_model_info() + for key, value in info.items(): + print(f" {key}: {value}") + + # Test audio validation + print("\n3. Testing audio validation...") + test_cases = [ + ("test.wav", True), + ("test.mp3", True), + ("test.mp4", False), + ("test.txt", False), + ] + for filename, expected in test_cases: + _, ext = os.path.splitext(filename) + is_supported = ext.lower() in AudioProcessor.SUPPORTED_FORMATS + status = "✓" if is_supported == expected else "✗" + print(f" {status} {filename}: {'Supported' if is_supported else 'Not supported'}") + + print("\n4. Supported languages:") + for code, name in WhisperService.LANGUAGE_HINTS.items(): + print(f" {code}: {name}") + + print("\n" + "=" * 60) + print("✅ Whisper Service test completed!") + print("=" * 60) diff --git a/static/ui_translations.json b/static/ui_translations.json new file mode 100644 index 0000000000000000000000000000000000000000..ebbe730fbfdc1732a0d981764947250bb6de2319 --- /dev/null +++ b/static/ui_translations.json @@ -0,0 +1,66 @@ +{ + "_metadata": { + "version": "2.0.0", + "languages": ["en", "ha", "yo", "ig"] + }, + + "buttons": { + "continue": {"en": "Continue", "ha": "Ci gaba", "yo": "Tẹ̀síwájú", "ig": "Gaa n'ihu"}, + "analyze": {"en": "Analyze Crop", "ha": "Bincika Amfanin Gona", "yo": "Ṣe Àyẹ̀wò", "ig": "Nyochaa"}, + "new_scan": {"en": "+ New Scan", "ha": "+ Sabon Duba", "yo": "+ Àyẹ̀wò Tuntun", "ig": "+ Nyocha Ọhụụ"}, + "back": {"en": "Back", "ha": "Koma", "yo": "Padà", "ig": "Laghachi"}, + "chat": {"en": "Chat with Assistant", "ha": "Yi magana da Mataimaki", "yo": "Bá Olùrànlọ́wọ́ sọ̀rọ̀", "ig": "Soro n'aka Onye enyemaka"}, + "stop": {"en": "Stop", "ha": "Daina", "yo": "Dúró", "ig": "Kwụsị"} + }, + + "diagnosis": { + "upload_title": {"en": "Upload Crop Image", "ha": "Ɗora Hoton Amfanin Gona", "yo": "Gbé Àwòrán Sókè", "ig": "Bulite Foto"}, + "upload_desc": {"en": "Take a clear photo of the affected leaf or plant", "ha": "Ɗauki hoto mai kyau na ganyen da ya kamu", "yo": "Ya àwòrán tó ṣe kedere", "ig": "See foto doro anya"}, + "click_or_drag": {"en": "Click or drag image here", "ha": "Danna ko ja hoto nan", "yo": "Tẹ tàbí fà àwòrán síbí", "ig": "Pịa ma ọ bụ dọrọ foto ebe a"}, + "analyzing": {"en": "Analyzing your crop...", "ha": "Ana bincika amfanin gonar ku...", "yo": "A ń ṣe àyẹ̀wò...", "ig": "Anyị na-enyocha..."} + }, + + "results": { + "title": {"en": "Diagnosis Results", "ha": "Sakamakon Bincike", "yo": "Àbájáde Àyẹ̀wò", "ig": "Nsonaazụ Nyocha"}, + "confidence": {"en": "Confidence:", "ha": "Tabbaci:", "yo": "Ìgbẹ́kẹ̀lé:", "ig": "Ntụkwasị Obi:"}, + "transmission": {"en": "How It Spreads", "ha": "Yadda Yake Yaɗuwa", "yo": "Bí Ó Ṣe Ń Tàn", "ig": "Otu Ọ Si Agbasa"}, + "yield_impact": {"en": "Yield Impact", "ha": "Tasirin Amfanin Gona", "yo": "Ipa Lórí Èso", "ig": "Mmetụta Ọnụ Ego"}, + "recovery": {"en": "Recovery Chance", "ha": "Damar Murmurewa", "yo": "Àǹfààní Ìmúlàradà", "ig": "Ohere Ịlaghachi"} + }, + + "tabs": { + "symptoms": {"en": "Symptoms", "ha": "Alamomi", "yo": "Àmì Àrùn", "ig": "Ihe Ngosi"}, + "treatment": {"en": "Treatment", "ha": "Magani", "yo": "Ìtọ́jú", "ig": "Ọgwụgwọ"}, + "prevention": {"en": "Prevention", "ha": "Rigakafi", "yo": "Ìdènà", "ig": "Mgbochi"} + }, + + "treatment": { + "immediate": {"en": "Immediate Actions", "ha": "Matakai na Gaggawa", "yo": "Ìgbésẹ̀ Lẹ́sẹ̀kẹsẹ̀", "ig": "Ihe Ọsịịsọ"}, + "chemical": {"en": "Chemical Treatment", "ha": "Maganin Sinadari", "yo": "Ìtọ́jú Kẹ́míkà", "ig": "Ọgwụgwọ Kemịkalụ"}, + "cost": {"en": "Estimated Cost:", "ha": "Ƙiyasin Farashi:", "yo": "Iye Owó:", "ig": "Ego A Tụrụ Anya:"} + }, + + "chat": { + "discussing": {"en": "Discussing:", "ha": "Muna tattaunawa:", "yo": "A ń sọ̀rọ̀ nípa:", "ig": "Anyị na-atụ:"}, + "welcome": {"en": "Ask me anything about your diagnosis, treatments, or prevention tips.", "ha": "Tambaye ni komai game da binciken ku.", "yo": "Bi mi nípa àyẹ̀wò rẹ.", "ig": "Jụọ m ihe ọ bụla gbasara nyocha gị."}, + "placeholder": {"en": "Ask about your diagnosis...", "ha": "Tambaya game da binciken ku...", "yo": "Béèrè nípa àyẹ̀wò rẹ...", "ig": "Jụọ maka nyocha gị..."}, + "disclaimer": {"en": "FarmEyes provides guidance only. Consult experts for serious cases.", "ha": "FarmEyes yana ba da jagora kawai.", "yo": "FarmEyes pèsè ìtọ́sọ́nà nìkan.", "ig": "FarmEyes na-enye nduzi nọọ."} + }, + + "voice": { + "listening": {"en": "Listening...", "ha": "Ana saurara...", "yo": "A ń gbọ́...", "ig": "Anyị na-ege..."} + }, + + "severity_levels": { + "very_high": {"en": "Very High", "ha": "Mai Tsanani Sosai", "yo": "Ga Jù", "ig": "Dị Elu Nnọọ"}, + "high": {"en": "High", "ha": "Mai Tsanani", "yo": "Ga", "ig": "Dị Elu"}, + "medium": {"en": "Medium", "ha": "Matsakaici", "yo": "Àárín", "ig": "Etiti"}, + "low": {"en": "Low", "ha": "Ƙasa", "yo": "Kéré", "ig": "Dị Ala"} + }, + + "crops": { + "cassava": {"en": "Cassava", "ha": "Rogo", "yo": "Ẹ̀gẹ́", "ig": "Akpụ"}, + "cocoa": {"en": "Cocoa", "ha": "Koko", "yo": "Koko", "ig": "Koko"}, + "tomato": {"en": "Tomato", "ha": "Tumatir", "yo": "Tòmátì", "ig": "Tomato"} + } +} diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1901be90fef4fd3073ef35e1ca81970047afbf67 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,32 @@ +""" +FarmEyes Utilities Package +========================== +Utility modules for the FarmEyes application. + +Utilities: +- prompt_templates: N-ATLaS prompt templates for various tasks +""" + +from utils.prompt_templates import ( + SystemPrompts, + TranslationPrompts, + DiagnosisPrompts, + ConversationalPrompts, + get_system_prompt, + format_prompt_for_natlas, + get_language_name, + LANGUAGE_NAMES, + LANGUAGE_NATIVE_NAMES +) + +__all__ = [ + "SystemPrompts", + "TranslationPrompts", + "DiagnosisPrompts", + "ConversationalPrompts", + "get_system_prompt", + "format_prompt_for_natlas", + "get_language_name", + "LANGUAGE_NAMES", + "LANGUAGE_NATIVE_NAMES" +] diff --git a/utils/__pycache__/__init__.cpython-310.pyc b/utils/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10e76903f375a4929d984a0b564812c3a9d85960 Binary files /dev/null and b/utils/__pycache__/__init__.cpython-310.pyc differ diff --git a/utils/__pycache__/prompt_templates.cpython-310.pyc b/utils/__pycache__/prompt_templates.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c88105f49f3bb1689e98858fc8e0fd08a606f16 Binary files /dev/null and b/utils/__pycache__/prompt_templates.cpython-310.pyc differ diff --git a/utils/prompt_templates.py b/utils/prompt_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..fe0f999ae5bea5f5ad1db3eef8be2a8fca5ce512 --- /dev/null +++ b/utils/prompt_templates.py @@ -0,0 +1,686 @@ +""" +FarmEyes Prompt Templates +========================= +Contains all prompt templates for N-ATLaS language model interactions. +Includes templates for translation, disease diagnosis reports, and +multilingual communication in Hausa, Yoruba, Igbo, and English. + +N-ATLaS Model: tosinamuda/N-ATLaS-GGUF (8B parameters) +Supported Languages: English (en), Hausa (ha), Yoruba (yo), Igbo (ig) +""" + +from typing import Dict, Optional, List +from dataclasses import dataclass + + +# ============================================================================= +# LANGUAGE MAPPINGS +# ============================================================================= + +# Language code to full name mapping +LANGUAGE_NAMES: Dict[str, str] = { + "en": "English", + "ha": "Hausa", + "yo": "Yoruba", + "ig": "Igbo" +} + +# Language code to native name mapping (for display) +LANGUAGE_NATIVE_NAMES: Dict[str, str] = { + "en": "English", + "ha": "Hausa", + "yo": "Yorùbá", + "ig": "Asụsụ Igbo" +} + + +# ============================================================================= +# SYSTEM PROMPTS +# ============================================================================= + +@dataclass +class SystemPrompts: + """System prompts for different N-ATLaS tasks""" + + # General agricultural assistant system prompt + AGRICULTURAL_ASSISTANT: str = """You are FarmEyes, an AI agricultural assistant designed to help Nigerian farmers. +Your role is to provide clear, practical advice about crop diseases and treatments. +Always communicate in a respectful, helpful manner using simple language that farmers can understand. +When discussing costs, always use Nigerian Naira (₦). +Focus on actionable advice that farmers can implement with locally available resources.""" + + # Translation system prompt + TRANSLATOR: str = """You are a professional translator specializing in Nigerian languages. +Your task is to translate agricultural content accurately while maintaining clarity and cultural appropriateness. +Translate naturally - do not translate word-by-word. Ensure the meaning is preserved and easily understood by farmers. +Keep technical terms simple and use local equivalents where possible.""" + + # Disease diagnosis system prompt + DISEASE_DIAGNOSIS: str = """You are FarmEyes, an expert agricultural AI assistant helping Nigerian farmers identify and treat crop diseases. +Provide comprehensive but easy-to-understand diagnosis reports. +Include practical treatment options with local costs in Nigerian Naira (₦). +Mention both modern treatments and traditional methods where applicable. +Always prioritize the farmer's safety when recommending chemical treatments.""" + + # Conversational assistant system prompt + CONVERSATIONAL: str = """You are FarmEyes, a friendly AI farming assistant. +Help Nigerian farmers with their questions about crop diseases, treatments, and farming practices. +Respond in the same language the farmer uses. +Be patient, respectful, and provide practical advice. +If you don't know something, say so honestly and suggest consulting a local agricultural extension officer.""" + + +# ============================================================================= +# TRANSLATION PROMPTS +# ============================================================================= + +class TranslationPrompts: + """Prompts for translating content between languages""" + + @staticmethod + def translate_text(text: str, target_language: str) -> str: + """ + Generate a prompt to translate text to target language. + + Args: + text: The text to translate + target_language: Target language code (ha, yo, ig, en) + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + return f"""Translate the following text to {lang_name}. +Provide only the translation, no explanations or additional text. +Maintain the same tone and meaning. Use simple, clear language that farmers can understand. + +Text to translate: +{text} + +{lang_name} translation:""" + + @staticmethod + def translate_disease_name(disease_name: str, target_language: str) -> str: + """ + Generate a prompt to translate a disease name. + + Args: + disease_name: Name of the disease in English + target_language: Target language code + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + return f"""Translate this crop disease name to {lang_name}. +If there is a commonly used local name for this disease, use that. +If not, translate it descriptively so farmers understand what it is. +Provide only the translation. + +Disease name: {disease_name} + +{lang_name} name:""" + + @staticmethod + def translate_symptoms(symptoms: List[str], target_language: str) -> str: + """ + Generate a prompt to translate a list of symptoms. + + Args: + symptoms: List of symptom descriptions + target_language: Target language code + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + symptoms_text = "\n".join([f"- {s}" for s in symptoms]) + + return f"""Translate these crop disease symptoms to {lang_name}. +Keep each symptom as a separate line starting with a dash (-). +Use simple language that farmers can easily understand. + +Symptoms: +{symptoms_text} + +{lang_name} translation:""" + + @staticmethod + def translate_treatment(treatment: str, target_language: str) -> str: + """ + Generate a prompt to translate treatment instructions. + + Args: + treatment: Treatment instructions in English + target_language: Target language code + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + return f"""Translate these treatment instructions to {lang_name}. +Keep the instructions clear and easy to follow. +Maintain any measurements, dosages, and costs exactly as given. +Use simple language that farmers can understand. + +Treatment instructions: +{treatment} + +{lang_name} translation:""" + + @staticmethod + def translate_ui_text(text: str, target_language: str) -> str: + """ + Generate a prompt to translate UI text (buttons, labels, etc.). + + Args: + text: UI text to translate + target_language: Target language code + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + return f"""Translate this app interface text to {lang_name}. +Keep it concise and natural. This is for a button/label in a mobile app. +Provide only the translation. + +Text: {text} + +{lang_name}:""" + + @staticmethod + def batch_translate(texts: List[str], target_language: str) -> str: + """ + Generate a prompt to translate multiple texts at once. + + Args: + texts: List of texts to translate + target_language: Target language code + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + numbered_texts = "\n".join([f"{i+1}. {t}" for i, t in enumerate(texts)]) + + return f"""Translate each of the following texts to {lang_name}. +Keep the same numbering format. Provide only the translations. + +Texts to translate: +{numbered_texts} + +{lang_name} translations:""" + + +# ============================================================================= +# DISEASE DIAGNOSIS PROMPTS +# ============================================================================= + +class DiagnosisPrompts: + """Prompts for generating disease diagnosis reports""" + + @staticmethod + def generate_diagnosis_summary( + disease_name: str, + crop: str, + confidence: float, + severity: str, + target_language: str = "en" + ) -> str: + """ + Generate a prompt for creating a diagnosis summary. + + Args: + disease_name: Name of the detected disease + crop: Type of crop (cassava, cocoa, tomato) + confidence: Detection confidence (0.0 - 1.0) + severity: Severity level (low, medium, high, very_high) + target_language: Language for the summary + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + confidence_percent = int(confidence * 100) + + return f"""Generate a brief diagnosis summary in {lang_name} for a Nigerian farmer. + +Disease detected: {disease_name} +Crop: {crop} +Detection confidence: {confidence_percent}% +Severity: {severity} + +Write a 2-3 sentence summary that: +1. Tells the farmer what disease was found +2. Indicates how serious it is +3. Reassures them that treatment options are available + +Use simple, clear language. Be direct but compassionate. + +{lang_name} summary:""" + + @staticmethod + def generate_treatment_recommendation( + disease_name: str, + treatments: Dict, + target_language: str = "en" + ) -> str: + """ + Generate a prompt for treatment recommendations. + + Args: + disease_name: Name of the disease + treatments: Dictionary containing treatment information + target_language: Language for recommendations + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + # Format treatment info + treatment_text = f"Disease: {disease_name}\n\n" + + if "cultural" in treatments: + treatment_text += "Cultural practices:\n" + for t in treatments["cultural"][:3]: # Limit to top 3 + treatment_text += f"- {t.get('method', '')}: {t.get('description', '')}\n" + + if "chemical" in treatments: + treatment_text += "\nChemical treatments:\n" + for t in treatments["chemical"][:2]: # Limit to top 2 + treatment_text += f"- {t.get('product_name', '')}: {t.get('dosage', '')}\n" + if "cost_ngn_min" in t: + treatment_text += f" Cost: ₦{t['cost_ngn_min']:,} - ₦{t.get('cost_ngn_max', t['cost_ngn_min']):,}\n" + + if "traditional" in treatments: + treatment_text += "\nTraditional methods:\n" + for t in treatments["traditional"][:2]: + treatment_text += f"- {t.get('method', '')}: {t.get('description', '')}\n" + + return f"""Based on this treatment information, provide practical recommendations in {lang_name}. + +{treatment_text} + +Write treatment advice that: +1. Starts with the most important immediate action +2. Lists 3-4 specific treatment options +3. Includes estimated costs in Nigerian Naira (₦) +4. Mentions safety precautions for chemical treatments +5. Uses simple language a farmer can understand + +{lang_name} treatment recommendations:""" + + @staticmethod + def generate_prevention_advice( + disease_name: str, + prevention_tips: List[str], + target_language: str = "en" + ) -> str: + """ + Generate a prompt for prevention advice. + + Args: + disease_name: Name of the disease + prevention_tips: List of prevention methods + target_language: Language for advice + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + tips_text = "\n".join([f"- {tip}" for tip in prevention_tips[:6]]) + + return f"""Provide prevention advice in {lang_name} for this crop disease. + +Disease: {disease_name} + +Prevention methods: +{tips_text} + +Write practical prevention advice that: +1. Explains the most important prevention steps +2. Uses simple language farmers can understand +3. Focuses on actions they can take immediately +4. Mentions low-cost options first + +{lang_name} prevention advice:""" + + @staticmethod + def generate_health_projection( + disease_name: str, + infection_stage: str, + recovery_chance: int, + target_language: str = "en" + ) -> str: + """ + Generate a prompt for health projection message. + + Args: + disease_name: Name of the disease + infection_stage: Stage of infection (early, moderate, severe) + recovery_chance: Percentage chance of recovery + target_language: Language for the message + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + return f"""Generate a health projection message in {lang_name} for a farmer. + +Disease: {disease_name} +Infection stage: {infection_stage} +Recovery chance: {recovery_chance}% + +Write a message that: +1. Is honest but hopeful (if there's a reasonable chance) +2. Tells the farmer what to expect +3. Encourages immediate action +4. Uses simple, compassionate language + +{lang_name} message:""" + + @staticmethod + def generate_healthy_plant_message( + crop: str, + target_language: str = "en" + ) -> str: + """ + Generate a prompt for healthy plant detection message. + + Args: + crop: Type of crop + target_language: Language for message + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + return f"""Generate a positive message in {lang_name} for a farmer whose {crop} plant is healthy. + +Write a message that: +1. Congratulates them on having a healthy plant +2. Gives 2-3 tips to maintain plant health +3. Encourages regular monitoring +4. Is warm and encouraging + +{lang_name} message:""" + + +# ============================================================================= +# FULL DIAGNOSIS REPORT PROMPT +# ============================================================================= + +class ReportPrompts: + """Prompts for generating complete diagnosis reports""" + + @staticmethod + def generate_full_report( + disease_data: Dict, + confidence: float, + target_language: str = "en" + ) -> str: + """ + Generate a prompt for a complete diagnosis report. + + Args: + disease_data: Full disease information from knowledge base + confidence: Detection confidence (0.0 - 1.0) + target_language: Language for the report + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + confidence_percent = int(confidence * 100) + + # Extract key information + disease_name = disease_data.get("display_name", "Unknown Disease") + crop = disease_data.get("crop", "crop") + severity = disease_data.get("severity", {}).get("level", "unknown") + symptoms = disease_data.get("symptoms", [])[:5] + yield_loss = disease_data.get("yield_loss", {}) + + symptoms_text = "\n".join([f"- {s}" for s in symptoms]) + yield_text = f"{yield_loss.get('min_percent', 0)}% - {yield_loss.get('max_percent', 100)}%" + + return f"""Generate a complete diagnosis report in {lang_name} for a Nigerian farmer. + +DIAGNOSIS INFORMATION: +- Disease: {disease_name} +- Crop: {crop} +- Confidence: {confidence_percent}% +- Severity: {severity} +- Potential yield loss: {yield_text} + +Key symptoms: +{symptoms_text} + +Create a report with these sections: +1. DIAGNOSIS SUMMARY (2-3 sentences explaining what was found) +2. WHAT TO DO NOW (immediate actions, numbered list) +3. TREATMENT OPTIONS (brief mention of available treatments) +4. EXPECTED OUTCOME (what to expect with treatment) + +Guidelines: +- Use simple, clear {lang_name} +- Be direct but compassionate +- Focus on practical actions +- Keep it concise (under 300 words) + +{lang_name} Report:""" + + +# ============================================================================= +# CONVERSATIONAL PROMPTS +# ============================================================================= + +class ConversationalPrompts: + """Prompts for conversational interactions with farmers""" + + @staticmethod + def answer_farmer_question( + question: str, + context: Optional[str] = None, + target_language: str = "en" + ) -> str: + """ + Generate a prompt to answer a farmer's question. + + Args: + question: The farmer's question + context: Optional context (e.g., current diagnosis) + target_language: Language for response + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + context_section = "" + if context: + context_section = f"\nContext: {context}\n" + + return f"""You are FarmEyes, an AI farming assistant. Answer this farmer's question in {lang_name}. +{context_section} +Farmer's question: {question} + +Provide a helpful, practical answer that: +1. Directly addresses their question +2. Uses simple language +3. Gives actionable advice where possible +4. Is respectful and encouraging + +{lang_name} response:""" + + @staticmethod + def clarify_treatment( + treatment_name: str, + treatment_info: str, + target_language: str = "en" + ) -> str: + """ + Generate a prompt to explain a treatment in more detail. + + Args: + treatment_name: Name of the treatment + treatment_info: Treatment information + target_language: Language for explanation + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + return f"""Explain this treatment in simple {lang_name} that a farmer can easily understand. + +Treatment: {treatment_name} +Information: {treatment_info} + +Provide a clear explanation that: +1. Explains what this treatment does +2. How to apply it step by step +3. When to apply it +4. Any safety precautions +5. Where to buy it (agro-dealers) + +{lang_name} explanation:""" + + @staticmethod + def provide_encouragement( + situation: str, + target_language: str = "en" + ) -> str: + """ + Generate encouraging message for a farmer facing challenges. + + Args: + situation: Description of farmer's situation + target_language: Language for message + + Returns: + Formatted prompt string + """ + lang_name = LANGUAGE_NAMES.get(target_language, "English") + + return f"""Write an encouraging message in {lang_name} for a farmer in this situation: + +{situation} + +The message should: +1. Acknowledge their challenge +2. Offer hope and practical next steps +3. Be warm and supportive +4. Remind them that many farmers have overcome similar challenges + +{lang_name} message:""" + + +# ============================================================================= +# UTILITY FUNCTIONS +# ============================================================================= + +def get_system_prompt(task: str = "general") -> str: + """ + Get the appropriate system prompt for a task. + + Args: + task: Type of task (general, translation, diagnosis, conversation) + + Returns: + System prompt string + """ + prompts = SystemPrompts() + + task_mapping = { + "general": prompts.AGRICULTURAL_ASSISTANT, + "translation": prompts.TRANSLATOR, + "diagnosis": prompts.DISEASE_DIAGNOSIS, + "conversation": prompts.CONVERSATIONAL + } + + return task_mapping.get(task, prompts.AGRICULTURAL_ASSISTANT) + + +def format_prompt_for_natlas( + system_prompt: str, + user_prompt: str +) -> str: + """ + Format a prompt for N-ATLaS model using the expected chat format. + + Args: + system_prompt: The system instruction + user_prompt: The user's message/request + + Returns: + Formatted prompt string for N-ATLaS + """ + # N-ATLaS uses a chat format similar to Llama models + return f"""<|begin_of_text|><|start_header_id|>system<|end_header_id|> + +{system_prompt}<|eot_id|><|start_header_id|>user<|end_header_id|> + +{user_prompt}<|eot_id|><|start_header_id|>assistant<|end_header_id|> + +""" + + +def get_language_name(code: str, native: bool = False) -> str: + """ + Get language name from code. + + Args: + code: Language code (en, ha, yo, ig) + native: If True, return native name + + Returns: + Language name string + """ + if native: + return LANGUAGE_NATIVE_NAMES.get(code, "English") + return LANGUAGE_NAMES.get(code, "English") + + +# ============================================================================= +# EXAMPLE USAGE +# ============================================================================= + +if __name__ == "__main__": + # Example: Generate a translation prompt + print("=" * 60) + print("Example Translation Prompt (English to Hausa)") + print("=" * 60) + + text = "Your cassava plant has bacterial blight. Remove infected plants immediately." + prompt = TranslationPrompts.translate_text(text, "ha") + print(prompt) + + print("\n" + "=" * 60) + print("Example Diagnosis Summary Prompt (Yoruba)") + print("=" * 60) + + prompt = DiagnosisPrompts.generate_diagnosis_summary( + disease_name="Cassava Mosaic Disease", + crop="cassava", + confidence=0.92, + severity="high", + target_language="yo" + ) + print(prompt) + + print("\n" + "=" * 60) + print("Example Formatted N-ATLaS Prompt") + print("=" * 60) + + system = get_system_prompt("translation") + user = TranslationPrompts.translate_text("Good morning, farmer!", "ig") + formatted = format_prompt_for_natlas(system, user) + print(formatted)