Spaces:
Running
Running
gh-action-hf-sync commited on
Commit ·
cfbb235
0
Parent(s):
sync: backend from github@cc8365e9
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .space.yaml +9 -0
- Dockerfile +87 -0
- README.md +333 -0
- ai-backend/.gitignore +4 -0
- ai-backend/Dockerfile +45 -0
- ai-backend/app.py +1609 -0
- ai-backend/forecast_model.py +354 -0
- ai-backend/model/.gitkeep +0 -0
- ai-backend/model_utils.py +169 -0
- ai-backend/price-forecast/__init__.py +1 -0
- ai-backend/requirements.txt +34 -0
- ai-backend/src/__init__.py +0 -0
- ai-backend/src/error_handlers.py +132 -0
- ai-backend/src/logging_config.py +60 -0
- ai-backend/src/models/__init__.py +0 -0
- ai-backend/src/models/manager.py +393 -0
- ai-backend/src/utils/__init__.py +0 -0
- ai-backend/src/utils/retry_utils.py +165 -0
- ai-backend/tests/conftest.py +187 -0
- ai-backend/tests/test_api_integration.py +552 -0
- ai-backend/tests/test_endpoints.py +464 -0
- ai-backend/tests/test_models.py +328 -0
- backend/Dockerfile +55 -0
- backend/FIREBASE_FIRESTORE_SETUP.md +34 -0
- backend/contracts/AgroExchange.sol +383 -0
- backend/contracts/hardhat.config.js +35 -0
- backend/contracts/package-lock.json +0 -0
- backend/contracts/package.json +19 -0
- backend/contracts/scripts/deploy.js +40 -0
- backend/contracts/test/AgroExchange.test.js +325 -0
- backend/controllers/appointmentController.js +81 -0
- backend/controllers/authController.js +73 -0
- backend/controllers/blogRecommendationsController.js +29 -0
- backend/controllers/cropController.js +47 -0
- backend/controllers/cropRotationController.js +66 -0
- backend/controllers/detectHarvestReadinessController.js +92 -0
- backend/controllers/expertDetailsController.js +128 -0
- backend/controllers/farmerDetailsController.js +92 -0
- backend/controllers/farmingNewsController.js +17 -0
- backend/controllers/geoPestDiseaseHeatmapController.js +60 -0
- backend/controllers/getExpertsController.js +11 -0
- backend/controllers/getLoanEligibilityReportController.js +73 -0
- backend/controllers/irrigationController.js +37 -0
- backend/controllers/marketPredictionController.js +76 -0
- backend/controllers/notificationsController.js +88 -0
- backend/controllers/pestOutbreakController.js +62 -0
- backend/controllers/postController.js +95 -0
- backend/controllers/recommendationController.js +38 -0
- backend/controllers/recordController.js +76 -0
- backend/controllers/soilHealthController.js +74 -0
.space.yaml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AgroMind Backend
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 8000
|
| 5 |
+
emoji: 🌾
|
| 6 |
+
colorFrom: green
|
| 7 |
+
colorTo: blue
|
| 8 |
+
pinned: false
|
| 9 |
+
license: isc
|
Dockerfile
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Unified Dockerfile for Hugging Face Spaces deployment
|
| 2 |
+
# Runs BOTH the Node.js backend (port 7860) and the Python AI backend (port 5000)
|
| 3 |
+
# in a single container so ML model requests are proxied to localhost:5000.
|
| 4 |
+
|
| 5 |
+
FROM node:22-bookworm-slim
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Install Python 3, pip and build deps needed by both stacks
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 11 |
+
python3 \
|
| 12 |
+
python3-pip \
|
| 13 |
+
python3-sklearn \
|
| 14 |
+
build-essential \
|
| 15 |
+
pkg-config \
|
| 16 |
+
wget \
|
| 17 |
+
curl \
|
| 18 |
+
ca-certificates \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
# ── Node.js backend ──────────────────────────────────────────────────────────
|
| 22 |
+
COPY backend/package*.json ./
|
| 23 |
+
ENV NODE_ENV=production
|
| 24 |
+
RUN npm install --omit=dev
|
| 25 |
+
|
| 26 |
+
COPY backend/ ./
|
| 27 |
+
|
| 28 |
+
# ── Python AI backend ────────────────────────────────────────────────────────
|
| 29 |
+
COPY ai-backend/requirements.txt /ai-backend/requirements.txt
|
| 30 |
+
RUN pip3 install --no-cache-dir --break-system-packages -r /ai-backend/requirements.txt
|
| 31 |
+
|
| 32 |
+
COPY ai-backend/ /ai-backend/
|
| 33 |
+
|
| 34 |
+
# Point the Node backend at the co-located AI service
|
| 35 |
+
ENV AI_BACKEND_URL=http://localhost:5000
|
| 36 |
+
|
| 37 |
+
# ── Create non-root user (uid 1000) for HF Spaces ───────────────────────────
|
| 38 |
+
RUN set -ex && \
|
| 39 |
+
if ! getent group 1000 > /dev/null 2>&1; then \
|
| 40 |
+
groupadd -g 1000 nodejs; \
|
| 41 |
+
fi && \
|
| 42 |
+
GROUP_NAME=$(getent group 1000 | cut -d: -f1) && \
|
| 43 |
+
if ! getent passwd 1000 > /dev/null 2>&1; then \
|
| 44 |
+
useradd -m -u 1000 -g ${GROUP_NAME} appuser; \
|
| 45 |
+
fi && \
|
| 46 |
+
chown -R 1000:1000 /app /ai-backend
|
| 47 |
+
|
| 48 |
+
# ── Startup script ───────────────────────────────────────────────────────────
|
| 49 |
+
# Launches the Python AI backend in the background, then starts Node.js
|
| 50 |
+
COPY <<'EOF' /start.sh
|
| 51 |
+
#!/bin/sh
|
| 52 |
+
echo "[startup] Starting AI backend on port 5000..."
|
| 53 |
+
cd /ai-backend && gunicorn --bind 0.0.0.0:5000 --workers 2 --timeout 180 --preload app:app &
|
| 54 |
+
AI_PID=$!
|
| 55 |
+
|
| 56 |
+
# Wait for AI backend to be ready (up to 30 s)
|
| 57 |
+
READY=0
|
| 58 |
+
for i in $(seq 1 30); do
|
| 59 |
+
if wget -q --spider http://127.0.0.1:5000/health 2>/dev/null; then
|
| 60 |
+
echo "[startup] AI backend is ready."
|
| 61 |
+
READY=1
|
| 62 |
+
break
|
| 63 |
+
fi
|
| 64 |
+
# Exit if the AI backend process died during startup
|
| 65 |
+
if ! kill -0 $AI_PID 2>/dev/null; then
|
| 66 |
+
echo "[startup] AI backend process exited unexpectedly."
|
| 67 |
+
break
|
| 68 |
+
fi
|
| 69 |
+
sleep 1
|
| 70 |
+
done
|
| 71 |
+
if [ "$READY" -eq 0 ] && kill -0 $AI_PID 2>/dev/null; then
|
| 72 |
+
echo "[startup] AI backend health check timed out after 30s; proceeding anyway."
|
| 73 |
+
fi
|
| 74 |
+
|
| 75 |
+
echo "[startup] Starting Node.js backend on port 7860..."
|
| 76 |
+
cd /app && exec node server.js
|
| 77 |
+
EOF
|
| 78 |
+
RUN chmod +x /start.sh
|
| 79 |
+
|
| 80 |
+
USER 1000
|
| 81 |
+
|
| 82 |
+
EXPOSE 7860
|
| 83 |
+
|
| 84 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
| 85 |
+
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:7860/health || exit 1
|
| 86 |
+
|
| 87 |
+
CMD ["/start.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Agromind Backend
|
| 3 |
+
colorFrom: green
|
| 4 |
+
colorTo: blue
|
| 5 |
+
sdk: docker
|
| 6 |
+
app_port: 7860
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
# AgroMind: Where Farmers Meet AI & Technology for a Greener Future! 🌾
|
| 10 |
+
|
| 11 |
+

|
| 12 |
+
|
| 13 |
+
**AgroMind** is an innovative platform designed to empower farmers by connecting them with agricultural experts, AI-powered tools, and modern technology. Our goal is to make farming smarter, more efficient, and more sustainable.
|
| 14 |
+
|
| 15 |
+
## 🚀 Key Features
|
| 16 |
+
|
| 17 |
+
### Core Features
|
| 18 |
+
- **Expert Consultations** - Real-time video calls and chat with agricultural experts
|
| 19 |
+
- **AI-Powered Recommendations** - Crop, fertilizer, and yield predictions
|
| 20 |
+
- **Task Management** - Goal-based scheduling and tracking
|
| 21 |
+
- **Weather Alerts** - Real-time weather updates and recommendations
|
| 22 |
+
- **Revenue Tracking** - Income and expense management
|
| 23 |
+
|
| 24 |
+
### New Features (v2.0)
|
| 25 |
+
|
| 26 |
+
| Feature | Description |
|
| 27 |
+
|---------|-------------|
|
| 28 |
+
| **Value Chain Marketplace** | Connect farmers, processors, and buyers for oilseed by-products |
|
| 29 |
+
| **Hedging Platform** | Virtual hedging, price risk management, forward contracts |
|
| 30 |
+
| **Crop Economics** | Comparative crop analysis, govt schemes, profitability simulation |
|
| 31 |
+
| **Oil Palm Advisory** | Farmer profiling, ROI projections, gestation support tracking |
|
| 32 |
+
| **Yield Optimization** | AI-driven yield predictions with intervention suggestions |
|
| 33 |
+
| **Tariff Simulator** | Model impact of customs duty changes on prices |
|
| 34 |
+
| **Millets Marketplace** | Specialized marketplace with traceability and offline support |
|
| 35 |
+
| **CRM Machine Tracking** | Real-time tracking of crop residue management machines |
|
| 36 |
+
| **CROPIC** | AI-based crop damage assessment for insurance |
|
| 37 |
+
|
| 38 |
+
## 🏗️ Architecture
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 42 |
+
│ Frontend │
|
| 43 |
+
│ (React.js + Vite) │
|
| 44 |
+
└─────────────────────┬───────────────────────────────────────┘
|
| 45 |
+
│
|
| 46 |
+
┌───────────────┼───────────────┐
|
| 47 |
+
▼ ▼ ▼
|
| 48 |
+
┌──────────┐ ┌──────────┐ ┌──────────────┐
|
| 49 |
+
│ Backend │ │ AI Backend│ │ Smart Contracts│
|
| 50 |
+
│ Node.js │ │ Python │ │ Solidity │
|
| 51 |
+
└────┬─────┘ └────┬─────┘ └──────────────┘
|
| 52 |
+
│ │
|
| 53 |
+
└──────┬───────┘
|
| 54 |
+
▼
|
| 55 |
+
┌──────────┐
|
| 56 |
+
│ MongoDB │
|
| 57 |
+
│ Redis │
|
| 58 |
+
└──────────┘
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
## 📦 Quick Start
|
| 62 |
+
|
| 63 |
+
### Prerequisites
|
| 64 |
+
- Node.js 20+
|
| 65 |
+
- Python 3.11+
|
| 66 |
+
- Docker & Docker Compose (recommended)
|
| 67 |
+
- MongoDB
|
| 68 |
+
|
| 69 |
+
### Local Development with Docker
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# Clone repository
|
| 73 |
+
git clone https://github.com/Anamitra-Sarkar/AgroMind.git
|
| 74 |
+
cd AgroMind
|
| 75 |
+
|
| 76 |
+
# Copy environment file
|
| 77 |
+
cp .env.sample .env
|
| 78 |
+
|
| 79 |
+
# Start all services
|
| 80 |
+
docker-compose up -d
|
| 81 |
+
|
| 82 |
+
# Access applications
|
| 83 |
+
# Frontend: http://localhost:5173
|
| 84 |
+
# Backend: http://localhost:8000
|
| 85 |
+
# AI Backend: http://localhost:5000
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Manual Setup
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
# Backend
|
| 92 |
+
cd backend && npm install && npm run dev
|
| 93 |
+
|
| 94 |
+
# AI Backend
|
| 95 |
+
cd ai-backend && pip install -r requirements.txt && python app.py
|
| 96 |
+
|
| 97 |
+
# Frontend
|
| 98 |
+
cd frontend && npm install && npm run dev
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
## 🔧 Environment Variables
|
| 102 |
+
|
| 103 |
+
See [.env.sample](.env.sample) for all required environment variables.
|
| 104 |
+
|
| 105 |
+
### Key Variables
|
| 106 |
+
|
| 107 |
+
| Variable | Description |
|
| 108 |
+
|----------|-------------|
|
| 109 |
+
| `MONGO_URL` | MongoDB connection string |
|
| 110 |
+
| `JWT_KEY` | JWT signing key |
|
| 111 |
+
| `FRONTEND_URL` | Frontend URL for CORS |
|
| 112 |
+
| `AI_BACKEND_URL` | AI backend URL |
|
| 113 |
+
| `OPENWEATHER_API_KEY` | OpenWeather API key |
|
| 114 |
+
| `GEMINI_API_KEY` | Google Gemini AI key |
|
| 115 |
+
|
| 116 |
+
## 🛠️ Technology Stack
|
| 117 |
+
|
| 118 |
+
### Frontend
|
| 119 |
+
- React.js 18 + Vite
|
| 120 |
+
- TailwindCSS + Material-UI
|
| 121 |
+
- Chart.js + Recharts
|
| 122 |
+
- Socket.IO Client
|
| 123 |
+
- i18next (internationalization)
|
| 124 |
+
|
| 125 |
+
### Backend
|
| 126 |
+
- Node.js + Express.js
|
| 127 |
+
- MongoDB + Mongoose
|
| 128 |
+
- Redis (caching)
|
| 129 |
+
- Socket.IO
|
| 130 |
+
- JWT Authentication
|
| 131 |
+
|
| 132 |
+
### AI Backend
|
| 133 |
+
- Python + Flask
|
| 134 |
+
- PyTorch + scikit-learn
|
| 135 |
+
- LightGBM (price forecasting)
|
| 136 |
+
- ResNet (image classification)
|
| 137 |
+
|
| 138 |
+
### Infrastructure
|
| 139 |
+
- Docker + Docker Compose
|
| 140 |
+
- GitHub Actions CI/CD
|
| 141 |
+
- Vercel (frontend hosting)
|
| 142 |
+
- Hugging Face Spaces (backend hosting)
|
| 143 |
+
- Prometheus + Grafana (monitoring)
|
| 144 |
+
|
| 145 |
+
## 📚 API Documentation
|
| 146 |
+
|
| 147 |
+
### Backend API Endpoints
|
| 148 |
+
|
| 149 |
+
| Endpoint | Description |
|
| 150 |
+
|----------|-------------|
|
| 151 |
+
| `/api/auth/*` | Authentication |
|
| 152 |
+
| `/api/valuechain/*` | Marketplace |
|
| 153 |
+
| `/api/hedging/*` | Hedging platform |
|
| 154 |
+
| `/api/crop-economics/*` | Crop comparison |
|
| 155 |
+
| `/api/oilpalm/*` | Oil palm advisory |
|
| 156 |
+
| `/api/crm/*` | Machine tracking |
|
| 157 |
+
| `/api/millets/*` | Millets marketplace |
|
| 158 |
+
|
| 159 |
+
### AI Backend Endpoints
|
| 160 |
+
|
| 161 |
+
| Endpoint | Description |
|
| 162 |
+
|----------|-------------|
|
| 163 |
+
| `/ai/price-forecast` | Price predictions |
|
| 164 |
+
| `/ai/yield-predict` | Yield predictions |
|
| 165 |
+
| `/ai/tariff-simulate` | Tariff impact simulation |
|
| 166 |
+
| `/ai/cropic/analyze` | Crop damage analysis |
|
| 167 |
+
| `/crop_recommendation` | Crop recommendations |
|
| 168 |
+
| `/fertilizer_prediction` | Fertilizer suggestions |
|
| 169 |
+
|
| 170 |
+
📄 Full API documentation: [docs/postman_collection.json](docs/postman_collection.json)
|
| 171 |
+
|
| 172 |
+
## 🧪 Testing
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
# Backend tests
|
| 176 |
+
cd backend && npm test
|
| 177 |
+
|
| 178 |
+
# AI Backend tests
|
| 179 |
+
cd ai-backend && pytest tests/ -v
|
| 180 |
+
|
| 181 |
+
# Frontend tests
|
| 182 |
+
cd frontend && npm test
|
| 183 |
+
|
| 184 |
+
# E2E tests
|
| 185 |
+
cd frontend && npm run cypress:open
|
| 186 |
+
|
| 187 |
+
# Smart contract tests
|
| 188 |
+
cd backend/contracts && npm test
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
### AI Backend Testing Details
|
| 192 |
+
|
| 193 |
+
The AI backend now includes comprehensive test coverage with:
|
| 194 |
+
|
| 195 |
+
**Test Coverage:**
|
| 196 |
+
- Unit tests for model predictions (`tests/test_models.py`) - 15 passing, 5 skipped
|
| 197 |
+
- Integration tests for all API endpoints (`tests/test_api_integration.py`)
|
| 198 |
+
- Existing validation tests (`tests/test_endpoints.py`)
|
| 199 |
+
|
| 200 |
+
**Test Features:**
|
| 201 |
+
- Mocked models for fast, deterministic tests (no HF downloads)
|
| 202 |
+
- Retry logic testing with transient failures
|
| 203 |
+
- Error handling and exception logging validation
|
| 204 |
+
- Content-Type and JSON payload validation
|
| 205 |
+
- Model loading and caching tests
|
| 206 |
+
|
| 207 |
+
**Run with Coverage:**
|
| 208 |
+
```bash
|
| 209 |
+
cd ai-backend
|
| 210 |
+
pytest tests/ -v --cov=. --cov-report=html
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
For detailed testing documentation and sample payloads, see [docs/TESTING.md](docs/TESTING.md).
|
| 214 |
+
|
| 215 |
+
## 🚀 Deployment
|
| 216 |
+
|
| 217 |
+
### Frontend → Vercel
|
| 218 |
+
|
| 219 |
+
```bash
|
| 220 |
+
cd frontend
|
| 221 |
+
vercel --prod
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
### Hugging Face Spaces → Backend only
|
| 225 |
+
|
| 226 |
+
To push only the `backend` and `ai-backend` folders (avoid large frontend/binary files), use the helper script:
|
| 227 |
+
|
| 228 |
+
Example:
|
| 229 |
+
|
| 230 |
+
```bash
|
| 231 |
+
chmod +x scripts/push_to_hf.sh
|
| 232 |
+
./scripts/push_to_hf.sh https://huggingface.co/spaces/<username>/<repo>
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
This creates a temporary git repo containing only `backend` and `ai-backend` and force-pushes `main` to the provided remote.
|
| 236 |
+
|
| 237 |
+
### GitHub Action (recommended)
|
| 238 |
+
|
| 239 |
+
You can automate the push using the provided GitHub Action. It creates a temporary repo with only `backend` and `ai-backend` and pushes it to your Hugging Face Space.
|
| 240 |
+
|
| 241 |
+
1. Add a repository secret named `HF_TOKEN` containing a Hugging Face token with repo write access.
|
| 242 |
+
2. Run the workflow manually from the Actions tab and provide the `hf_repo` input (e.g. `username/Agromind-backend`).
|
| 243 |
+
|
| 244 |
+
Workflow options:
|
| 245 |
+
- **hf_branch**: target branch on the Hugging Face repo (default `main`).
|
| 246 |
+
- **force**: set to `true` to force-push the target branch (default `false`). Avoid force-push unless you intentionally want to overwrite history.
|
| 247 |
+
- **dry_run**: set to `true` to prepare the temporary repo and list files without pushing (default `false`).
|
| 248 |
+
|
| 249 |
+
Recommended safe flow:
|
| 250 |
+
1. Run with `dry_run=true` to verify what will be pushed.
|
| 251 |
+
2. Run with `force=false` to push to a branch without overwriting history. If you specifically need to replace the remote branch, set `force=true`.
|
| 252 |
+
|
| 253 |
+
The workflow file is `.github/workflows/auto-sync-to-hf.yml` — it also runs
|
| 254 |
+
automatically on every push to `main` that touches `backend/`, `ai-backend/`,
|
| 255 |
+
`Dockerfile`, `.space.yaml`, or `README.md`, so a manual run is only needed
|
| 256 |
+
for a dry run or to push to a non-default branch.
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
### Backend → Hugging Face Spaces
|
| 260 |
+
|
| 261 |
+
See [docs/deploy.md](docs/deploy.md) for detailed deployment instructions.
|
| 262 |
+
|
| 263 |
+
### Required GitHub Secrets
|
| 264 |
+
|
| 265 |
+
```
|
| 266 |
+
VERCEL_TOKEN
|
| 267 |
+
VERCEL_ORG_ID
|
| 268 |
+
VERCEL_PROJECT_ID
|
| 269 |
+
HF_TOKEN
|
| 270 |
+
HF_BACKEND_SPACE_ID
|
| 271 |
+
HF_AI_BACKEND_SPACE_ID
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
## 📁 Project Structure
|
| 275 |
+
|
| 276 |
+
```
|
| 277 |
+
AgroMind/
|
| 278 |
+
├── frontend/ # React frontend
|
| 279 |
+
├── backend/ # Node.js backend
|
| 280 |
+
│ ├── routes/ # API routes
|
| 281 |
+
│ ├── controllers/ # Route handlers
|
| 282 |
+
│ ├── models/ # MongoDB models
|
| 283 |
+
│ ├── middleware/ # Express middleware
|
| 284 |
+
│ ├── socket/ # Socket.IO handlers
|
| 285 |
+
│ └── contracts/ # Smart contracts
|
| 286 |
+
├── ai-backend/ # Python AI backend
|
| 287 |
+
│ ├── model/ # ML models
|
| 288 |
+
│ └── tests/ # Python tests
|
| 289 |
+
├── docs/ # Documentation
|
| 290 |
+
├── config/ # Configuration files
|
| 291 |
+
├── scripts/ # Utility scripts
|
| 292 |
+
└── docker-compose.yml # Local development
|
| 293 |
+
```
|
| 294 |
+
|
| 295 |
+
## 📖 Documentation
|
| 296 |
+
|
| 297 |
+
- [Architecture](docs/architecture.md) - System design and diagrams
|
| 298 |
+
- [Deployment](docs/deploy.md) - Deployment instructions
|
| 299 |
+
- [Security](docs/security.md) - Security checklist
|
| 300 |
+
- [ML Models](docs/models.md) - Model documentation
|
| 301 |
+
- [Local Setup](docs/run_locally.md) - Local development guide
|
| 302 |
+
|
| 303 |
+
## 🔐 Security
|
| 304 |
+
|
| 305 |
+
- JWT-based authentication
|
| 306 |
+
- Rate limiting and CORS
|
| 307 |
+
- Input validation and sanitization
|
| 308 |
+
- Encrypted data storage
|
| 309 |
+
- Regular dependency scanning
|
| 310 |
+
|
| 311 |
+
See [docs/security.md](docs/security.md) for the full security checklist.
|
| 312 |
+
|
| 313 |
+
## 🤝 Contributing
|
| 314 |
+
|
| 315 |
+
We welcome contributions! Please:
|
| 316 |
+
|
| 317 |
+
1. Fork the repository
|
| 318 |
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
| 319 |
+
3. Commit changes (`git commit -m 'Add amazing feature'`)
|
| 320 |
+
4. Push to branch (`git push origin feature/amazing-feature`)
|
| 321 |
+
5. Open a Pull Request
|
| 322 |
+
|
| 323 |
+
## 📄 License
|
| 324 |
+
|
| 325 |
+
This project is licensed under the ISC License.
|
| 326 |
+
|
| 327 |
+
## 📞 Support
|
| 328 |
+
|
| 329 |
+
For support, email support@agromind.app or join our community.
|
| 330 |
+
|
| 331 |
+
---
|
| 332 |
+
|
| 333 |
+
**AgroMind: Empowering farmers with technology for a greener, smarter future!** 🌱
|
ai-backend/.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv/
|
ai-backend/Dockerfile
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Backend Dockerfile
|
| 2 |
+
FROM python:3.11-slim as base
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install system dependencies
|
| 7 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
build-essential \
|
| 9 |
+
curl \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements first for caching
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
|
| 15 |
+
# Development stage
|
| 16 |
+
FROM base as development
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
RUN pip install --no-cache-dir pytest pytest-cov httpx black flake8 isort
|
| 19 |
+
COPY . .
|
| 20 |
+
EXPOSE 5000
|
| 21 |
+
CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=5000", "--reload"]
|
| 22 |
+
|
| 23 |
+
# Production stage
|
| 24 |
+
FROM base as production
|
| 25 |
+
|
| 26 |
+
# Install production dependencies
|
| 27 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 28 |
+
|
| 29 |
+
# Copy application code
|
| 30 |
+
COPY . .
|
| 31 |
+
|
| 32 |
+
# Create non-root user
|
| 33 |
+
RUN groupadd -r appuser && useradd -r -g appuser appuser && \
|
| 34 |
+
chown -R appuser:appuser /app
|
| 35 |
+
|
| 36 |
+
USER appuser
|
| 37 |
+
|
| 38 |
+
EXPOSE 5000
|
| 39 |
+
|
| 40 |
+
# Health check
|
| 41 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 42 |
+
CMD curl -f http://localhost:5000/health || exit 1
|
| 43 |
+
|
| 44 |
+
# Use gunicorn for production
|
| 45 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--timeout", "120", "app:app"]
|
ai-backend/app.py
ADDED
|
@@ -0,0 +1,1609 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""app.py
|
| 2 |
+
|
| 3 |
+
AI backend Flask app. All ML models are downloaded at runtime from
|
| 4 |
+
Hugging Face Hub — no local binary weights are required.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import io
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import pickle
|
| 11 |
+
import logging
|
| 12 |
+
import time as _time
|
| 13 |
+
|
| 14 |
+
import requests as _requests
|
| 15 |
+
|
| 16 |
+
from flask import Flask, request, jsonify
|
| 17 |
+
from flask_cors import CORS
|
| 18 |
+
from PIL import Image
|
| 19 |
+
import torch
|
| 20 |
+
import joblib
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
from huggingface_hub import hf_hub_download
|
| 24 |
+
|
| 25 |
+
from model_utils import load_model_from_hf, predict
|
| 26 |
+
|
| 27 |
+
# Import new infrastructure
|
| 28 |
+
from src.logging_config import setup_logging, log_exception
|
| 29 |
+
from src.error_handlers import register_error_handlers, validate_content_type, validate_json_payload
|
| 30 |
+
from src.models import manager as model_manager
|
| 31 |
+
from src.utils.retry_utils import retry_with_backoff, retry_model_inference
|
| 32 |
+
|
| 33 |
+
# Setup structured logging
|
| 34 |
+
logger = setup_logging(level=logging.INFO)
|
| 35 |
+
|
| 36 |
+
_start_time = _time.time()
|
| 37 |
+
|
| 38 |
+
_HF_INFERENCE_RETRY_DELAYS = (1, 2, 4) # seconds between retries (3 attempts total)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _call_hf_inference_api(api_url: str, headers: dict, data: bytes, timeout: int = 60) -> "_requests.Response":
|
| 42 |
+
"""POST to a Hugging Face Inference API endpoint with retries on network/DNS errors.
|
| 43 |
+
|
| 44 |
+
On persistent failure a :class:`requests.exceptions.RequestException` is
|
| 45 |
+
raised so callers can fall back to the local model path.
|
| 46 |
+
"""
|
| 47 |
+
last_exc: "_requests.exceptions.RequestException | None" = None
|
| 48 |
+
for attempt, retry_delay in enumerate((*_HF_INFERENCE_RETRY_DELAYS, None)):
|
| 49 |
+
try:
|
| 50 |
+
return _requests.post(api_url, headers=headers, data=data, timeout=timeout)
|
| 51 |
+
except _requests.exceptions.RequestException as exc:
|
| 52 |
+
last_exc = exc
|
| 53 |
+
if retry_delay is not None:
|
| 54 |
+
logger.warning(
|
| 55 |
+
"HF Inference API network error attempt %d/%d url=%s error=%s. "
|
| 56 |
+
"Retrying in %ds.",
|
| 57 |
+
attempt + 1, len(_HF_INFERENCE_RETRY_DELAYS) + 1, api_url, exc, retry_delay,
|
| 58 |
+
)
|
| 59 |
+
_time.sleep(retry_delay)
|
| 60 |
+
else:
|
| 61 |
+
logger.error(
|
| 62 |
+
"HF Inference API failed after %d attempts url=%s error=%s. "
|
| 63 |
+
"Verify HF_TOKEN and network/DNS access from this Space.",
|
| 64 |
+
len(_HF_INFERENCE_RETRY_DELAYS) + 1, api_url, exc,
|
| 65 |
+
)
|
| 66 |
+
raise _requests.exceptions.RequestException(
|
| 67 |
+
f"Network/DNS failure after {len(_HF_INFERENCE_RETRY_DELAYS) + 1} attempts: {last_exc}"
|
| 68 |
+
) from last_exc
|
| 69 |
+
|
| 70 |
+
app = Flask(__name__)
|
| 71 |
+
CORS(app)
|
| 72 |
+
|
| 73 |
+
# Register centralized error handlers
|
| 74 |
+
register_error_handlers(app)
|
| 75 |
+
|
| 76 |
+
# ── HF repo IDs (override via env vars if needed) ──────────────────────────
|
| 77 |
+
HF_REPO_CROP = os.environ.get(
|
| 78 |
+
"HF_REPO_CROP", "Arko007/agromind-crop-recommendation"
|
| 79 |
+
)
|
| 80 |
+
HF_REPO_FERTILIZER = os.environ.get(
|
| 81 |
+
"HF_REPO_FERTILIZER", "Arko007/agromind-fertilizer-prediction"
|
| 82 |
+
)
|
| 83 |
+
HF_REPO_LOAN = os.environ.get(
|
| 84 |
+
"HF_REPO_LOAN", "Arko007/agromind-loan-prediction"
|
| 85 |
+
)
|
| 86 |
+
HF_REPO_HARVEST = os.environ.get(
|
| 87 |
+
"HF_REPO_HARVEST", "Arko007/harvest-readiness-yolo11m"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
device = model_manager.get_device()
|
| 91 |
+
|
| 92 |
+
# ── Models are lazy-loaded on first request directly from HF Hub ──────────
|
| 93 |
+
# Repos used:
|
| 94 |
+
# crop: Arko007/agromind-crop-recommendation
|
| 95 |
+
# disease: Arko007/nfnet-f1-plant-disease
|
| 96 |
+
# fertilizer: Arko007/agromind-fertilizer-prediction
|
| 97 |
+
# loan: Arko007/agromind-loan-prediction
|
| 98 |
+
try:
|
| 99 |
+
model_manager.initialize_models(load_all=False)
|
| 100 |
+
except Exception as e:
|
| 101 |
+
log_exception(logger, e, "Error during model manager init")
|
| 102 |
+
|
| 103 |
+
logger.info("AI backend startup complete. Ready to serve requests.")
|
| 104 |
+
|
| 105 |
+
# Mapping for crop types
|
| 106 |
+
crop_dict = {
|
| 107 |
+
1: "Rice", 2: "Maize", 3: "Jute", 4: "Cotton", 5: "Coconut", 6: "Papaya", 7: "Orange",
|
| 108 |
+
8: "Apple", 9: "Muskmelon", 10: "Watermelon", 11: "Grapes", 12: "Mango", 13: "Banana",
|
| 109 |
+
14: "Pomegranate", 15: "Lentil", 16: "Blackgram", 17: "Mungbean", 18: "Mothbeans",
|
| 110 |
+
19: "Pigeonpeas", 20: "Kidneybeans", 21: "Chickpea", 22: "Coffee"
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# Mapping for soil and crop types (fertilizer prediction)
|
| 114 |
+
soil_mapping = {
|
| 115 |
+
"Black": 0,
|
| 116 |
+
"Clayey": 1,
|
| 117 |
+
"Loamy": 2,
|
| 118 |
+
"Red": 3,
|
| 119 |
+
"Sandy": 4
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
crop_mapping = {
|
| 123 |
+
"Barley": 0,
|
| 124 |
+
"Cotton": 1,
|
| 125 |
+
"Ground Nuts": 2,
|
| 126 |
+
"Maize": 3,
|
| 127 |
+
"Millets": 4,
|
| 128 |
+
"Oil Seeds": 5,
|
| 129 |
+
"Paddy": 6,
|
| 130 |
+
"Pulses": 7,
|
| 131 |
+
"Sugarcane": 8,
|
| 132 |
+
"Tobacco": 9,
|
| 133 |
+
"Wheat": 10
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@app.route("/")
|
| 138 |
+
def index():
|
| 139 |
+
return jsonify({"message": "Welcome to the AI Backend API"})
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@app.route('/health')
|
| 143 |
+
def health():
|
| 144 |
+
# Get model status from model manager
|
| 145 |
+
model_status = model_manager.get_model_status()
|
| 146 |
+
|
| 147 |
+
return jsonify({
|
| 148 |
+
"status": "ok",
|
| 149 |
+
"version": "1.0.0",
|
| 150 |
+
"uptime": int(_time.time() - _start_time),
|
| 151 |
+
"models_loaded": model_status
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@app.route("/predict_disease", methods=["POST"])
|
| 156 |
+
def predict_route():
|
| 157 |
+
# Lazy-load disease model from Arko007/nfnet-f1-plant-disease on first call
|
| 158 |
+
model = model_manager.get_model('disease_model', auto_load=True)
|
| 159 |
+
labels = model_manager.get_model('disease_labels', auto_load=True) or []
|
| 160 |
+
remedies = model_manager.get_model('disease_remedies', auto_load=True) or {}
|
| 161 |
+
if model is None:
|
| 162 |
+
return jsonify({"error": "Disease model unavailable — HF Hub download may have failed"}), 503
|
| 163 |
+
if "file" not in request.files:
|
| 164 |
+
return jsonify({"error": "no file part"}), 400
|
| 165 |
+
file = request.files["file"]
|
| 166 |
+
if file.filename == "":
|
| 167 |
+
return jsonify({"error": "empty filename"}), 400
|
| 168 |
+
try:
|
| 169 |
+
img_bytes = file.read()
|
| 170 |
+
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
| 171 |
+
|
| 172 |
+
# Determine crop filter from filename if present
|
| 173 |
+
filename_lower = file.filename.lower()
|
| 174 |
+
crop_filter = None
|
| 175 |
+
crop_mapping = {
|
| 176 |
+
"wheat": "Wheat__",
|
| 177 |
+
"potato": "Potato__",
|
| 178 |
+
"rice": "Rice__",
|
| 179 |
+
"corn": "Corn__",
|
| 180 |
+
"apple": "Apple__",
|
| 181 |
+
"cassava": "Cassava__",
|
| 182 |
+
"cherry": "Cherry__",
|
| 183 |
+
"chili": "Chili__",
|
| 184 |
+
"chilli": "Chili__",
|
| 185 |
+
"coffee": "Coffee__",
|
| 186 |
+
"cucumber": "Cucumber__",
|
| 187 |
+
"guava": "Gauva__",
|
| 188 |
+
"gauva": "Gauva__",
|
| 189 |
+
"grape": "Grape__",
|
| 190 |
+
"jamun": "Jamun__",
|
| 191 |
+
"lemon": "Lemon__",
|
| 192 |
+
"mango": "Mango__",
|
| 193 |
+
"peach": "Peach__",
|
| 194 |
+
"pepper": "Pepper_bell__",
|
| 195 |
+
"bell": "Pepper_bell__",
|
| 196 |
+
"pomegranate": "Pomegranate__",
|
| 197 |
+
"soybean": "Soybean__",
|
| 198 |
+
"soy": "Soybean__",
|
| 199 |
+
"strawberry": "Strawberry",
|
| 200 |
+
"sugarcane": "Sugarcane__",
|
| 201 |
+
"tea": "Tea__",
|
| 202 |
+
"tomato": "Tomato__"
|
| 203 |
+
}
|
| 204 |
+
for kw, prefix in crop_mapping.items():
|
| 205 |
+
if kw in filename_lower:
|
| 206 |
+
crop_filter = [lbl for lbl in labels if lbl.startswith(prefix)]
|
| 207 |
+
logger.info("Filtering classes for prefix '%s' based on filename '%s'", prefix, file.filename)
|
| 208 |
+
break
|
| 209 |
+
|
| 210 |
+
top_label, confidence, topk = predict(model, pil_img, labels, device, topk=5, crop_filter=crop_filter)
|
| 211 |
+
|
| 212 |
+
# Try to find remedies in a robust way to handle label-format differences
|
| 213 |
+
def find_remedy(label, remedies_dict):
|
| 214 |
+
if not remedies_dict:
|
| 215 |
+
return None
|
| 216 |
+
# direct match
|
| 217 |
+
if label in remedies_dict:
|
| 218 |
+
return remedies_dict[label]
|
| 219 |
+
# try common normalization variants
|
| 220 |
+
variants = set()
|
| 221 |
+
variants.add(label.replace('__', '___'))
|
| 222 |
+
variants.add(label.replace('___', '__'))
|
| 223 |
+
variants.add(label.replace('(', '').replace(')', ''))
|
| 224 |
+
variants.add(label.replace(' ', '_'))
|
| 225 |
+
variants.add(label.replace('-', '_'))
|
| 226 |
+
variants.add(label.lower())
|
| 227 |
+
variants.add(label.replace('__', ' ').lower())
|
| 228 |
+
for v in variants:
|
| 229 |
+
if v in remedies_dict:
|
| 230 |
+
return remedies_dict[v]
|
| 231 |
+
# try case-insensitive match
|
| 232 |
+
for k in remedies_dict.keys():
|
| 233 |
+
if k.lower() == label.lower():
|
| 234 |
+
return remedies_dict[k]
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
remedy = find_remedy(top_label, remedies)
|
| 238 |
+
response = {
|
| 239 |
+
"label": top_label,
|
| 240 |
+
"confidence": confidence,
|
| 241 |
+
"remedies": remedy,
|
| 242 |
+
"topk": [{"label": l, "confidence": float(c)} for l, c in topk]
|
| 243 |
+
}
|
| 244 |
+
return jsonify(response)
|
| 245 |
+
except Exception as e:
|
| 246 |
+
return jsonify({"error": str(e)}), 500
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@app.route("/crop_recommendation", methods=["POST"])
|
| 250 |
+
def crop_recommendation():
|
| 251 |
+
"""Crop recommendation endpoint with robust error handling and logging."""
|
| 252 |
+
request_start = _time.time()
|
| 253 |
+
|
| 254 |
+
# Validate Content-Type
|
| 255 |
+
is_valid, error_response = validate_content_type(request)
|
| 256 |
+
if not is_valid:
|
| 257 |
+
return jsonify(error_response), error_response['status']
|
| 258 |
+
|
| 259 |
+
# Lazy-load from Arko007/agromind-crop-recommendation on first call (cached after)
|
| 260 |
+
crop_predict_model = model_manager.get_model('crop_model', auto_load=True)
|
| 261 |
+
crop_predict_sc = model_manager.get_model('crop_standard_scaler', auto_load=True)
|
| 262 |
+
crop_predict_ms = model_manager.get_model('crop_minmax_scaler', auto_load=True)
|
| 263 |
+
# Check model availability
|
| 264 |
+
if crop_predict_model is None or crop_predict_sc is None or crop_predict_ms is None:
|
| 265 |
+
logger.error("Crop recommendation: Model not loaded")
|
| 266 |
+
return jsonify({
|
| 267 |
+
"error": "Crop prediction model not loaded",
|
| 268 |
+
"message": "Service temporarily unavailable. Please try again later."
|
| 269 |
+
}), 500
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
+
# Validate JSON payload
|
| 273 |
+
required_fields = ['N', 'P', 'K', 'temperature', 'humidity', 'ph', 'rainfall']
|
| 274 |
+
is_valid, result = validate_json_payload(request, required_fields)
|
| 275 |
+
if not is_valid:
|
| 276 |
+
return jsonify(result), result['status']
|
| 277 |
+
|
| 278 |
+
data = result
|
| 279 |
+
logger.info(f"Crop recommendation request: N={data.get('N')}, P={data.get('P')}, K={data.get('K')}")
|
| 280 |
+
|
| 281 |
+
# Extract and validate features
|
| 282 |
+
try:
|
| 283 |
+
N = float(data['N'])
|
| 284 |
+
P = float(data['P'])
|
| 285 |
+
K = float(data['K'])
|
| 286 |
+
temp = float(data['temperature'])
|
| 287 |
+
humidity = float(data['humidity'])
|
| 288 |
+
ph = float(data['ph'])
|
| 289 |
+
rainfall = float(data['rainfall'])
|
| 290 |
+
except (ValueError, TypeError) as e:
|
| 291 |
+
logger.warning(f"Invalid data type in crop recommendation: {e}")
|
| 292 |
+
return jsonify({
|
| 293 |
+
"error": "Invalid data type",
|
| 294 |
+
"message": f"All numeric fields must be valid numbers: {str(e)}"
|
| 295 |
+
}), 400
|
| 296 |
+
|
| 297 |
+
# Validate ranges
|
| 298 |
+
if not (0 <= N <= 100 and 0 <= P <= 100 and 0 <= K <= 100):
|
| 299 |
+
return jsonify({"error": "N, P, K values must be between 0 and 100"}), 400
|
| 300 |
+
if not (-10 <= temp <= 50):
|
| 301 |
+
return jsonify({"error": "Temperature must be between -10 and 50°C"}), 400
|
| 302 |
+
if not (0 <= humidity <= 100):
|
| 303 |
+
return jsonify({"error": "Humidity must be between 0 and 100%"}), 400
|
| 304 |
+
if not (0 <= ph <= 14):
|
| 305 |
+
return jsonify({"error": "pH must be between 0 and 14"}), 400
|
| 306 |
+
if not (0 <= rainfall <= 500):
|
| 307 |
+
return jsonify({"error": "Rainfall must be between 0 and 500mm"}), 400
|
| 308 |
+
|
| 309 |
+
# Prepare features for prediction
|
| 310 |
+
feature_list = [N, P, K, temp, humidity, ph, rainfall]
|
| 311 |
+
single_pred = np.array(feature_list).reshape(1, -1)
|
| 312 |
+
|
| 313 |
+
# Make prediction with retry wrapper
|
| 314 |
+
@retry_model_inference(max_attempts=2)
|
| 315 |
+
def make_prediction():
|
| 316 |
+
scaled_features = crop_predict_ms.transform(single_pred)
|
| 317 |
+
final_features = crop_predict_sc.transform(scaled_features)
|
| 318 |
+
return crop_predict_model.predict(final_features)
|
| 319 |
+
|
| 320 |
+
prediction = make_prediction()
|
| 321 |
+
|
| 322 |
+
# Get crop name
|
| 323 |
+
if prediction[0] in crop_dict:
|
| 324 |
+
crop = crop_dict[prediction[0]]
|
| 325 |
+
result = f"{crop} is the best crop to be cultivated right there."
|
| 326 |
+
|
| 327 |
+
elapsed_ms = int((_time.time() - request_start) * 1000)
|
| 328 |
+
logger.info(f"Crop recommendation successful: {crop} (took {elapsed_ms}ms)")
|
| 329 |
+
|
| 330 |
+
return jsonify({
|
| 331 |
+
"success": True,
|
| 332 |
+
"crop": crop,
|
| 333 |
+
"message": result,
|
| 334 |
+
"prediction_id": int(prediction[0]),
|
| 335 |
+
"input_data": {
|
| 336 |
+
"nitrogen": N,
|
| 337 |
+
"phosphorus": P,
|
| 338 |
+
"potassium": K,
|
| 339 |
+
"temperature": temp,
|
| 340 |
+
"humidity": humidity,
|
| 341 |
+
"ph": ph,
|
| 342 |
+
"rainfall": rainfall
|
| 343 |
+
}
|
| 344 |
+
}), 200
|
| 345 |
+
else:
|
| 346 |
+
logger.warning(f"Crop recommendation: Unknown prediction ID {prediction[0]}")
|
| 347 |
+
return jsonify({
|
| 348 |
+
"success": False,
|
| 349 |
+
"message": "Could not determine the best crop with the provided data."
|
| 350 |
+
}), 200
|
| 351 |
+
|
| 352 |
+
except Exception as e:
|
| 353 |
+
elapsed_ms = int((_time.time() - request_start) * 1000)
|
| 354 |
+
log_exception(logger, e, f"Crop recommendation failed after {elapsed_ms}ms")
|
| 355 |
+
return jsonify({
|
| 356 |
+
"error": "Internal server error",
|
| 357 |
+
"message": "An unexpected error occurred during prediction. Please try again later."
|
| 358 |
+
}), 500
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
@app.route('/fertilizer_prediction', methods=['POST'])
|
| 362 |
+
def fertilizer_prediction():
|
| 363 |
+
# Lazy-load from Arko007/agromind-fertilizer-prediction on first call
|
| 364 |
+
classifier_model = model_manager.get_model('fertilizer_classifier', auto_load=True)
|
| 365 |
+
label_encoder = model_manager.get_model('fertilizer_label_encoder', auto_load=True)
|
| 366 |
+
if classifier_model is None or label_encoder is None:
|
| 367 |
+
return jsonify({"error": "Fertilizer prediction model not loaded"}), 503
|
| 368 |
+
try:
|
| 369 |
+
# Get JSON data from request
|
| 370 |
+
data = request.get_json()
|
| 371 |
+
|
| 372 |
+
# Validate required fields
|
| 373 |
+
required_fields = ['temperature', 'humidity', 'moisture', 'soil_type', 'crop_type', 'nitrogen', 'potassium', 'phosphorus']
|
| 374 |
+
for field in required_fields:
|
| 375 |
+
if field not in data:
|
| 376 |
+
return jsonify({
|
| 377 |
+
"error": f"Missing required field: {field}"
|
| 378 |
+
}), 400
|
| 379 |
+
|
| 380 |
+
# Extract features
|
| 381 |
+
temp = int(data['temperature'])
|
| 382 |
+
humi = int(data['humidity'])
|
| 383 |
+
mois = int(data['moisture'])
|
| 384 |
+
soil_type = data['soil_type']
|
| 385 |
+
crop_type = data['crop_type']
|
| 386 |
+
nitro = int(data['nitrogen'])
|
| 387 |
+
pota = int(data['potassium'])
|
| 388 |
+
phosp = int(data['phosphorus'])
|
| 389 |
+
|
| 390 |
+
# Validate soil type
|
| 391 |
+
if soil_type not in soil_mapping:
|
| 392 |
+
return jsonify({
|
| 393 |
+
"error": f"Invalid soil_type. Must be one of: {list(soil_mapping.keys())}"
|
| 394 |
+
}), 400
|
| 395 |
+
|
| 396 |
+
# Validate crop type
|
| 397 |
+
if crop_type not in crop_mapping:
|
| 398 |
+
return jsonify({
|
| 399 |
+
"error": f"Invalid crop_type. Must be one of: {list(crop_mapping.keys())}"
|
| 400 |
+
}), 400
|
| 401 |
+
|
| 402 |
+
# Validate ranges
|
| 403 |
+
if not (0 <= temp <= 100):
|
| 404 |
+
return jsonify({"error": "Temperature must be between 0 and 100"}), 400
|
| 405 |
+
if not (0 <= humi <= 100):
|
| 406 |
+
return jsonify({"error": "Humidity must be between 0 and 100"}), 400
|
| 407 |
+
if not (0 <= mois <= 100):
|
| 408 |
+
return jsonify({"error": "Moisture must be between 0 and 100"}), 400
|
| 409 |
+
if not (0 <= nitro <= 100):
|
| 410 |
+
return jsonify({"error": "Nitrogen must be between 0 and 100"}), 400
|
| 411 |
+
if not (0 <= pota <= 100):
|
| 412 |
+
return jsonify({"error": "Potassium must be between 0 and 100"}), 400
|
| 413 |
+
if not (0 <= phosp <= 100):
|
| 414 |
+
return jsonify({"error": "Phosphorus must be between 0 and 100"}), 400
|
| 415 |
+
|
| 416 |
+
# Convert categorical inputs to numerical values
|
| 417 |
+
soil_encoded = soil_mapping[soil_type]
|
| 418 |
+
crop_encoded = crop_mapping[crop_type]
|
| 419 |
+
|
| 420 |
+
# Prepare input for prediction
|
| 421 |
+
input_data = [temp, humi, mois, soil_encoded, crop_encoded, nitro, pota, phosp]
|
| 422 |
+
input_array = np.array(input_data).reshape(1, -1)
|
| 423 |
+
|
| 424 |
+
# Make prediction
|
| 425 |
+
result_index = classifier_model.predict(input_array)
|
| 426 |
+
result_label = label_encoder.inverse_transform(result_index)
|
| 427 |
+
|
| 428 |
+
return jsonify({
|
| 429 |
+
"success": True,
|
| 430 |
+
"fertilizer": result_label[0],
|
| 431 |
+
"message": f"Predicted fertilizer is {result_label[0]}",
|
| 432 |
+
"input_data": {
|
| 433 |
+
"temperature": temp,
|
| 434 |
+
"humidity": humi,
|
| 435 |
+
"moisture": mois,
|
| 436 |
+
"soil_type": soil_type,
|
| 437 |
+
"crop_type": crop_type,
|
| 438 |
+
"nitrogen": nitro,
|
| 439 |
+
"potassium": pota,
|
| 440 |
+
"phosphorus": phosp
|
| 441 |
+
}
|
| 442 |
+
}), 200
|
| 443 |
+
|
| 444 |
+
except ValueError as e:
|
| 445 |
+
return jsonify({
|
| 446 |
+
"error": f"Invalid data type: {str(e)}"
|
| 447 |
+
}), 400
|
| 448 |
+
except Exception as e:
|
| 449 |
+
return jsonify({
|
| 450 |
+
"error": f"An error occurred: {str(e)}"
|
| 451 |
+
}), 500
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
@app.route('/loan_prediction', methods=['POST'])
|
| 455 |
+
def loan_prediction():
|
| 456 |
+
# Lazy-load from Arko007/agromind-loan-prediction on first call
|
| 457 |
+
price_model = model_manager.get_model('loan_price_model', auto_load=True)
|
| 458 |
+
approval_model = model_manager.get_model('loan_approval_model', auto_load=True)
|
| 459 |
+
if price_model is None or approval_model is None:
|
| 460 |
+
return jsonify({"error": "Loan prediction model not loaded"}), 503
|
| 461 |
+
try:
|
| 462 |
+
data = request.get_json()
|
| 463 |
+
|
| 464 |
+
# Validate required fields
|
| 465 |
+
required_fields = ['area', 'land_contour', 'distance_from_road', 'soil_type', 'income', 'loan_request']
|
| 466 |
+
for field in required_fields:
|
| 467 |
+
if field not in data:
|
| 468 |
+
return jsonify({'error': f'Missing required field: {field}'}), 400
|
| 469 |
+
|
| 470 |
+
# Extract features
|
| 471 |
+
area = float(data['area'])
|
| 472 |
+
land_contour = data['land_contour']
|
| 473 |
+
distance_from_road = float(data['distance_from_road'])
|
| 474 |
+
soil_type = data['soil_type']
|
| 475 |
+
income = float(data['income'])
|
| 476 |
+
loan_request = float(data['loan_request'])
|
| 477 |
+
|
| 478 |
+
# Prepare input for prediction
|
| 479 |
+
input_data_price = pd.DataFrame({
|
| 480 |
+
'area': [area],
|
| 481 |
+
'distance_from_road': [distance_from_road],
|
| 482 |
+
'income': [income],
|
| 483 |
+
'land_contour_hilly': [1 if land_contour == 'hilly' else 0],
|
| 484 |
+
'land_contour_sloping': [1 if land_contour == 'sloping' else 0],
|
| 485 |
+
'soil_type_clay': [1 if soil_type == 'clay' else 0],
|
| 486 |
+
'soil_type_sandy': [1 if soil_type == 'sandy' else 0],
|
| 487 |
+
'soil_type_silty': [1 if soil_type == 'silty' else 0]
|
| 488 |
+
})
|
| 489 |
+
|
| 490 |
+
# Add missing columns with value 0
|
| 491 |
+
for column in price_model.feature_names_in_:
|
| 492 |
+
if column not in input_data_price.columns:
|
| 493 |
+
input_data_price[column] = 0
|
| 494 |
+
|
| 495 |
+
# Ensure column order matches training data
|
| 496 |
+
input_data_price = input_data_price[price_model.feature_names_in_]
|
| 497 |
+
|
| 498 |
+
# Predict farm price
|
| 499 |
+
predicted_price = float(price_model.predict(input_data_price)[0])
|
| 500 |
+
|
| 501 |
+
# Determine loan value
|
| 502 |
+
loan_value = predicted_price if predicted_price <= 500000 else predicted_price * 0.85
|
| 503 |
+
|
| 504 |
+
# Calculate loan approval probability
|
| 505 |
+
if loan_request <= loan_value:
|
| 506 |
+
approval_probability = 1.0
|
| 507 |
+
else:
|
| 508 |
+
diff_ratio = (loan_request - loan_value) / loan_value
|
| 509 |
+
approval_probability = float(np.exp(-5 * diff_ratio))
|
| 510 |
+
|
| 511 |
+
# Return prediction results
|
| 512 |
+
return jsonify({
|
| 513 |
+
'success': True,
|
| 514 |
+
'predicted_price': round(predicted_price, 2),
|
| 515 |
+
'loan_value': round(loan_value, 2),
|
| 516 |
+
'approval_probability': round(approval_probability * 100, 2),
|
| 517 |
+
'loan_request': loan_request,
|
| 518 |
+
'recommendation': 'Approved' if approval_probability >= 0.5 else 'Denied'
|
| 519 |
+
}), 200
|
| 520 |
+
|
| 521 |
+
except ValueError as e:
|
| 522 |
+
return jsonify({
|
| 523 |
+
"error": f"Invalid data type: {str(e)}"
|
| 524 |
+
}), 400
|
| 525 |
+
except Exception as e:
|
| 526 |
+
return jsonify({
|
| 527 |
+
"error": f"An error occurred: {str(e)}"
|
| 528 |
+
}), 500
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# =============================================================================
|
| 532 |
+
# Price Forecasting Endpoint
|
| 533 |
+
# =============================================================================
|
| 534 |
+
|
| 535 |
+
# Import price forecast module
|
| 536 |
+
try:
|
| 537 |
+
import sys
|
| 538 |
+
sys.path.insert(0, os.path.dirname(__file__))
|
| 539 |
+
from forecast_model import forecast_prices
|
| 540 |
+
FORECAST_AVAILABLE = True
|
| 541 |
+
except ImportError as e:
|
| 542 |
+
print(f"Price forecast module not available: {e}")
|
| 543 |
+
FORECAST_AVAILABLE = False
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
@app.route('/ai/price-forecast', methods=['POST'])
|
| 547 |
+
def price_forecast():
|
| 548 |
+
"""
|
| 549 |
+
Price forecasting endpoint for commodities
|
| 550 |
+
|
| 551 |
+
Request body:
|
| 552 |
+
{
|
| 553 |
+
"historical_prices": [{"date": "2024-01-01", "price": 100, "volume": 1000}, ...],
|
| 554 |
+
"location": {"lat": 19.0, "lng": 73.0, "state": "Maharashtra"},
|
| 555 |
+
"commodity_type": "groundnut",
|
| 556 |
+
"global_indices": {"crude_oil": 80, "soybean": 1200, "usd_inr": 83},
|
| 557 |
+
"forecast_days": 30
|
| 558 |
+
}
|
| 559 |
+
"""
|
| 560 |
+
if not FORECAST_AVAILABLE:
|
| 561 |
+
return jsonify({
|
| 562 |
+
"success": False,
|
| 563 |
+
"error": "Price forecast module not available"
|
| 564 |
+
}), 503
|
| 565 |
+
|
| 566 |
+
try:
|
| 567 |
+
data = request.get_json()
|
| 568 |
+
|
| 569 |
+
if not data:
|
| 570 |
+
return jsonify({
|
| 571 |
+
"success": False,
|
| 572 |
+
"error": "No data provided"
|
| 573 |
+
}), 400
|
| 574 |
+
|
| 575 |
+
# Validate required fields
|
| 576 |
+
historical_prices = data.get('historical_prices', [])
|
| 577 |
+
if not historical_prices or len(historical_prices) < 5:
|
| 578 |
+
return jsonify({
|
| 579 |
+
"success": False,
|
| 580 |
+
"error": "Need at least 5 historical price points"
|
| 581 |
+
}), 400
|
| 582 |
+
|
| 583 |
+
# Extract optional parameters
|
| 584 |
+
location = data.get('location')
|
| 585 |
+
commodity_type = data.get('commodity_type', 'oilseed')
|
| 586 |
+
global_indices = data.get('global_indices')
|
| 587 |
+
forecast_days = data.get('forecast_days', 30)
|
| 588 |
+
|
| 589 |
+
# Validate forecast_days
|
| 590 |
+
if forecast_days not in [7, 30, 90]:
|
| 591 |
+
forecast_days = 30
|
| 592 |
+
|
| 593 |
+
# Generate forecast
|
| 594 |
+
result = forecast_prices(
|
| 595 |
+
historical_prices=historical_prices,
|
| 596 |
+
location=location,
|
| 597 |
+
commodity_type=commodity_type,
|
| 598 |
+
global_indices=global_indices,
|
| 599 |
+
forecast_days=forecast_days
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
if result.get('success'):
|
| 603 |
+
return jsonify(result), 200
|
| 604 |
+
else:
|
| 605 |
+
return jsonify(result), 400
|
| 606 |
+
|
| 607 |
+
except ValueError as e:
|
| 608 |
+
return jsonify({
|
| 609 |
+
"success": False,
|
| 610 |
+
"error": f"Invalid data: {str(e)}"
|
| 611 |
+
}), 400
|
| 612 |
+
except Exception as e:
|
| 613 |
+
return jsonify({
|
| 614 |
+
"success": False,
|
| 615 |
+
"error": f"Forecast error: {str(e)}"
|
| 616 |
+
}), 500
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
# =============================================================================
|
| 620 |
+
# Yield Prediction Endpoint
|
| 621 |
+
# =============================================================================
|
| 622 |
+
|
| 623 |
+
@app.route('/ai/yield-predict', methods=['POST'])
|
| 624 |
+
def yield_prediction():
|
| 625 |
+
"""
|
| 626 |
+
Yield prediction endpoint
|
| 627 |
+
|
| 628 |
+
Request body:
|
| 629 |
+
{
|
| 630 |
+
"crop_type": "groundnut",
|
| 631 |
+
"location": {"lat": 19.0, "lng": 73.0, "state": "Maharashtra"},
|
| 632 |
+
"soil_data": {"nitrogen": 50, "phosphorus": 30, "potassium": 40, "ph": 6.5},
|
| 633 |
+
"weather_data": {"rainfall": 800, "temperature": 28, "humidity": 65},
|
| 634 |
+
"area_hectares": 5
|
| 635 |
+
}
|
| 636 |
+
"""
|
| 637 |
+
try:
|
| 638 |
+
data = request.get_json()
|
| 639 |
+
|
| 640 |
+
if not data:
|
| 641 |
+
return jsonify({
|
| 642 |
+
"success": False,
|
| 643 |
+
"error": "No data provided"
|
| 644 |
+
}), 400
|
| 645 |
+
|
| 646 |
+
crop_type = data.get('crop_type', 'groundnut')
|
| 647 |
+
location = data.get('location', {})
|
| 648 |
+
soil_data = data.get('soil_data', {})
|
| 649 |
+
weather_data = data.get('weather_data', {})
|
| 650 |
+
area_hectares = data.get('area_hectares', 1)
|
| 651 |
+
|
| 652 |
+
# Simple yield estimation based on factors
|
| 653 |
+
# In production, this would use a trained ML model
|
| 654 |
+
|
| 655 |
+
# Base yield per hectare (kg/ha) by crop
|
| 656 |
+
base_yields = {
|
| 657 |
+
'groundnut': 1800,
|
| 658 |
+
'sunflower': 1200,
|
| 659 |
+
'soybean': 2000,
|
| 660 |
+
'mustard': 1100,
|
| 661 |
+
'sesame': 600,
|
| 662 |
+
'castor': 1500,
|
| 663 |
+
'linseed': 800
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
base_yield = base_yields.get(crop_type.lower(), 1500)
|
| 667 |
+
|
| 668 |
+
# Adjust for soil quality
|
| 669 |
+
soil_factor = 1.0
|
| 670 |
+
if soil_data:
|
| 671 |
+
n = soil_data.get('nitrogen', 50)
|
| 672 |
+
p = soil_data.get('phosphorus', 30)
|
| 673 |
+
k = soil_data.get('potassium', 40)
|
| 674 |
+
ph = soil_data.get('ph', 6.5)
|
| 675 |
+
|
| 676 |
+
# Optimal ranges adjustment
|
| 677 |
+
if 40 <= n <= 60 and 25 <= p <= 40 and 30 <= k <= 50:
|
| 678 |
+
soil_factor = 1.1
|
| 679 |
+
elif n < 20 or p < 15 or k < 20:
|
| 680 |
+
soil_factor = 0.8
|
| 681 |
+
|
| 682 |
+
# pH adjustment
|
| 683 |
+
if 6.0 <= ph <= 7.5:
|
| 684 |
+
soil_factor *= 1.05
|
| 685 |
+
elif ph < 5.5 or ph > 8.0:
|
| 686 |
+
soil_factor *= 0.85
|
| 687 |
+
|
| 688 |
+
# Adjust for weather
|
| 689 |
+
weather_factor = 1.0
|
| 690 |
+
if weather_data:
|
| 691 |
+
rainfall = weather_data.get('rainfall', 700)
|
| 692 |
+
temp = weather_data.get('temperature', 28)
|
| 693 |
+
|
| 694 |
+
# Rainfall adjustment
|
| 695 |
+
if 600 <= rainfall <= 1000:
|
| 696 |
+
weather_factor = 1.1
|
| 697 |
+
elif rainfall < 400 or rainfall > 1500:
|
| 698 |
+
weather_factor = 0.75
|
| 699 |
+
|
| 700 |
+
# Temperature adjustment
|
| 701 |
+
if 25 <= temp <= 32:
|
| 702 |
+
weather_factor *= 1.05
|
| 703 |
+
elif temp < 20 or temp > 38:
|
| 704 |
+
weather_factor *= 0.85
|
| 705 |
+
|
| 706 |
+
# Calculate predicted yield
|
| 707 |
+
predicted_yield_per_ha = base_yield * soil_factor * weather_factor
|
| 708 |
+
total_yield = predicted_yield_per_ha * area_hectares
|
| 709 |
+
|
| 710 |
+
# Calculate confidence based on data completeness
|
| 711 |
+
confidence = 0.7
|
| 712 |
+
if soil_data:
|
| 713 |
+
confidence += 0.1
|
| 714 |
+
if weather_data:
|
| 715 |
+
confidence += 0.1
|
| 716 |
+
if location:
|
| 717 |
+
confidence += 0.05
|
| 718 |
+
|
| 719 |
+
# Generate recommendations
|
| 720 |
+
interventions = []
|
| 721 |
+
if soil_factor < 1.0:
|
| 722 |
+
interventions.append({
|
| 723 |
+
"type": "fertilization",
|
| 724 |
+
"priority": "high",
|
| 725 |
+
"recommendation": "Apply balanced NPK fertilizer to improve soil nutrient levels"
|
| 726 |
+
})
|
| 727 |
+
if weather_factor < 1.0:
|
| 728 |
+
interventions.append({
|
| 729 |
+
"type": "irrigation",
|
| 730 |
+
"priority": "medium",
|
| 731 |
+
"recommendation": "Consider supplemental irrigation during dry spells"
|
| 732 |
+
})
|
| 733 |
+
|
| 734 |
+
return jsonify({
|
| 735 |
+
"success": True,
|
| 736 |
+
"data": {
|
| 737 |
+
"crop_type": crop_type,
|
| 738 |
+
"area_hectares": area_hectares,
|
| 739 |
+
"predicted_yield_kg_per_ha": round(predicted_yield_per_ha, 2),
|
| 740 |
+
"total_predicted_yield_kg": round(total_yield, 2),
|
| 741 |
+
"confidence": round(confidence, 2),
|
| 742 |
+
"factors": {
|
| 743 |
+
"soil_factor": round(soil_factor, 2),
|
| 744 |
+
"weather_factor": round(weather_factor, 2)
|
| 745 |
+
},
|
| 746 |
+
"interventions": interventions,
|
| 747 |
+
"feature_importance": {
|
| 748 |
+
"soil_nutrients": 0.35,
|
| 749 |
+
"rainfall": 0.25,
|
| 750 |
+
"temperature": 0.15,
|
| 751 |
+
"location": 0.15,
|
| 752 |
+
"crop_variety": 0.10
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
+
}), 200
|
| 756 |
+
|
| 757 |
+
except Exception as e:
|
| 758 |
+
return jsonify({
|
| 759 |
+
"success": False,
|
| 760 |
+
"error": f"Prediction error: {str(e)}"
|
| 761 |
+
}), 500
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
# =============================================================================
|
| 765 |
+
# Tariff Impact Simulation Endpoint
|
| 766 |
+
# =============================================================================
|
| 767 |
+
|
| 768 |
+
@app.route('/ai/tariff-simulate', methods=['POST'])
|
| 769 |
+
def tariff_simulation():
|
| 770 |
+
"""
|
| 771 |
+
Simulate impact of customs duty changes on imports and prices
|
| 772 |
+
|
| 773 |
+
Request body:
|
| 774 |
+
{
|
| 775 |
+
"tariff_pct": 35,
|
| 776 |
+
"period": "6_months",
|
| 777 |
+
"global_price_shock": 0
|
| 778 |
+
}
|
| 779 |
+
"""
|
| 780 |
+
try:
|
| 781 |
+
data = request.get_json() or {}
|
| 782 |
+
|
| 783 |
+
tariff_pct = data.get('tariff_pct', 35)
|
| 784 |
+
period = data.get('period', '6_months')
|
| 785 |
+
global_price_shock = data.get('global_price_shock', 0) # % change in global prices
|
| 786 |
+
|
| 787 |
+
# Base parameters (simplified model)
|
| 788 |
+
base_import_volume = 15000000 # 15 million tonnes
|
| 789 |
+
base_domestic_price = 120 # INR per kg
|
| 790 |
+
base_farmer_price = 95
|
| 791 |
+
base_consumer_price = 145
|
| 792 |
+
|
| 793 |
+
# Elasticities (simplified)
|
| 794 |
+
import_elasticity = -0.8 # How much imports change with price
|
| 795 |
+
domestic_price_elasticity = 0.3 # How domestic price changes with reduced imports
|
| 796 |
+
pass_through_farmer = 0.6 # How much of price change reaches farmers
|
| 797 |
+
pass_through_consumer = 0.8 # How much reaches consumers
|
| 798 |
+
|
| 799 |
+
# Current tariff baseline
|
| 800 |
+
current_tariff = 35
|
| 801 |
+
tariff_change = tariff_pct - current_tariff
|
| 802 |
+
|
| 803 |
+
# Calculate impacts
|
| 804 |
+
# Higher tariff -> lower imports -> higher domestic prices
|
| 805 |
+
|
| 806 |
+
# Import volume change
|
| 807 |
+
effective_price_change = (tariff_change / 100) + (global_price_shock / 100)
|
| 808 |
+
import_volume_change = effective_price_change * import_elasticity * 100
|
| 809 |
+
new_import_volume = base_import_volume * (1 + import_volume_change / 100)
|
| 810 |
+
new_import_volume = max(new_import_volume, 0)
|
| 811 |
+
|
| 812 |
+
# Domestic price change
|
| 813 |
+
supply_reduction = (base_import_volume - new_import_volume) / base_import_volume
|
| 814 |
+
domestic_price_change = supply_reduction * domestic_price_elasticity * 100
|
| 815 |
+
new_domestic_price = base_domestic_price * (1 + domestic_price_change / 100)
|
| 816 |
+
|
| 817 |
+
# Farmer and consumer prices
|
| 818 |
+
farmer_price_change = domestic_price_change * pass_through_farmer
|
| 819 |
+
consumer_price_change = domestic_price_change * pass_through_consumer
|
| 820 |
+
|
| 821 |
+
new_farmer_price = base_farmer_price * (1 + farmer_price_change / 100)
|
| 822 |
+
new_consumer_price = base_consumer_price * (1 + consumer_price_change / 100)
|
| 823 |
+
|
| 824 |
+
# Sensitivity analysis
|
| 825 |
+
sensitivity_table = []
|
| 826 |
+
for sensitivity_tariff in [25, 30, 35, 40, 45, 50]:
|
| 827 |
+
sens_change = sensitivity_tariff - current_tariff
|
| 828 |
+
sens_import_change = (sens_change / 100) * import_elasticity * 100
|
| 829 |
+
sens_import_vol = base_import_volume * (1 + sens_import_change / 100)
|
| 830 |
+
sens_supply_red = (base_import_volume - sens_import_vol) / base_import_volume
|
| 831 |
+
sens_price_change = sens_supply_red * domestic_price_elasticity * 100
|
| 832 |
+
|
| 833 |
+
sensitivity_table.append({
|
| 834 |
+
"tariff_pct": sensitivity_tariff,
|
| 835 |
+
"import_volume_mt": round(sens_import_vol / 1000000, 2),
|
| 836 |
+
"domestic_price_inr": round(base_domestic_price * (1 + sens_price_change / 100), 2),
|
| 837 |
+
"farmer_price_inr": round(base_farmer_price * (1 + sens_price_change * pass_through_farmer / 100), 2)
|
| 838 |
+
})
|
| 839 |
+
|
| 840 |
+
return jsonify({
|
| 841 |
+
"success": True,
|
| 842 |
+
"data": {
|
| 843 |
+
"scenario": {
|
| 844 |
+
"tariff_pct": tariff_pct,
|
| 845 |
+
"period": period,
|
| 846 |
+
"global_price_shock_pct": global_price_shock
|
| 847 |
+
},
|
| 848 |
+
"baseline": {
|
| 849 |
+
"import_volume_mt": round(base_import_volume / 1000000, 2),
|
| 850 |
+
"domestic_price_inr_kg": base_domestic_price,
|
| 851 |
+
"farmer_price_inr_kg": base_farmer_price,
|
| 852 |
+
"consumer_price_inr_kg": base_consumer_price
|
| 853 |
+
},
|
| 854 |
+
"predicted": {
|
| 855 |
+
"import_volume_mt": round(new_import_volume / 1000000, 2),
|
| 856 |
+
"import_change_pct": round(import_volume_change, 2),
|
| 857 |
+
"domestic_price_inr_kg": round(new_domestic_price, 2),
|
| 858 |
+
"domestic_price_change_pct": round(domestic_price_change, 2),
|
| 859 |
+
"farmer_price_inr_kg": round(new_farmer_price, 2),
|
| 860 |
+
"farmer_price_change_pct": round(farmer_price_change, 2),
|
| 861 |
+
"consumer_price_inr_kg": round(new_consumer_price, 2),
|
| 862 |
+
"consumer_price_change_pct": round(consumer_price_change, 2)
|
| 863 |
+
},
|
| 864 |
+
"sensitivity_analysis": sensitivity_table,
|
| 865 |
+
"model_assumptions": {
|
| 866 |
+
"import_elasticity": import_elasticity,
|
| 867 |
+
"domestic_price_elasticity": domestic_price_elasticity,
|
| 868 |
+
"pass_through_farmer": pass_through_farmer,
|
| 869 |
+
"pass_through_consumer": pass_through_consumer
|
| 870 |
+
}
|
| 871 |
+
}
|
| 872 |
+
}), 200
|
| 873 |
+
|
| 874 |
+
except Exception as e:
|
| 875 |
+
return jsonify({
|
| 876 |
+
"success": False,
|
| 877 |
+
"error": f"Simulation error: {str(e)}"
|
| 878 |
+
}), 500
|
| 879 |
+
|
| 880 |
+
|
| 881 |
+
# =============================================================================
|
| 882 |
+
# CROPIC - Crop Image Analysis Endpoint
|
| 883 |
+
# =============================================================================
|
| 884 |
+
|
| 885 |
+
@app.route('/ai/cropic/analyze', methods=['POST'])
|
| 886 |
+
def cropic_analyze():
|
| 887 |
+
"""
|
| 888 |
+
Analyze crop damage from image for insurance purposes
|
| 889 |
+
|
| 890 |
+
Request: multipart/form-data with:
|
| 891 |
+
- file: image file
|
| 892 |
+
- metadata: JSON string with {lat, lng, crop_type, stage}
|
| 893 |
+
"""
|
| 894 |
+
try:
|
| 895 |
+
if 'file' not in request.files:
|
| 896 |
+
return jsonify({
|
| 897 |
+
"success": False,
|
| 898 |
+
"error": "No image file provided"
|
| 899 |
+
}), 400
|
| 900 |
+
|
| 901 |
+
file = request.files['file']
|
| 902 |
+
if file.filename == '':
|
| 903 |
+
return jsonify({
|
| 904 |
+
"success": False,
|
| 905 |
+
"error": "Empty filename"
|
| 906 |
+
}), 400
|
| 907 |
+
|
| 908 |
+
# Get metadata
|
| 909 |
+
metadata = {}
|
| 910 |
+
if 'metadata' in request.form:
|
| 911 |
+
try:
|
| 912 |
+
metadata = json.loads(request.form['metadata'])
|
| 913 |
+
except:
|
| 914 |
+
pass
|
| 915 |
+
|
| 916 |
+
# Read and validate image
|
| 917 |
+
img_bytes = file.read()
|
| 918 |
+
|
| 919 |
+
# Load disease model if available (lazy)
|
| 920 |
+
model = model_manager.get_model('disease_model', auto_load=False)
|
| 921 |
+
labels = model_manager.get_model('disease_labels', auto_load=False) or []
|
| 922 |
+
|
| 923 |
+
# Check file size (max 10MB)
|
| 924 |
+
if len(img_bytes) > 10 * 1024 * 1024:
|
| 925 |
+
return jsonify({
|
| 926 |
+
"success": False,
|
| 927 |
+
"error": "Image too large. Maximum size is 10MB"
|
| 928 |
+
}), 400
|
| 929 |
+
|
| 930 |
+
try:
|
| 931 |
+
pil_img = Image.open(io.BytesIO(img_bytes))
|
| 932 |
+
pil_img = pil_img.convert('RGB')
|
| 933 |
+
|
| 934 |
+
# Check image dimensions
|
| 935 |
+
width, height = pil_img.size
|
| 936 |
+
if width < 100 or height < 100:
|
| 937 |
+
return jsonify({
|
| 938 |
+
"success": False,
|
| 939 |
+
"error": "Image too small. Minimum dimensions: 100x100"
|
| 940 |
+
}), 400
|
| 941 |
+
|
| 942 |
+
except Exception as e:
|
| 943 |
+
return jsonify({
|
| 944 |
+
"success": False,
|
| 945 |
+
"error": f"Invalid image format: {str(e)}"
|
| 946 |
+
}), 400
|
| 947 |
+
|
| 948 |
+
# Use existing disease model for classification if available
|
| 949 |
+
crop_type = metadata.get('crop_type', 'unknown')
|
| 950 |
+
stage = metadata.get('stage', 'vegetative')
|
| 951 |
+
|
| 952 |
+
# If disease model is loaded, use it for damage classification
|
| 953 |
+
damage_type = "unknown"
|
| 954 |
+
damage_percentage = 0
|
| 955 |
+
confidence = 0.5
|
| 956 |
+
|
| 957 |
+
if model is not None:
|
| 958 |
+
try:
|
| 959 |
+
top_label, conf, topk = predict(model, pil_img, labels, device, topk=5)
|
| 960 |
+
|
| 961 |
+
# Map disease labels to damage types
|
| 962 |
+
damage_mapping = {
|
| 963 |
+
'healthy': ('none', 0),
|
| 964 |
+
'bacterial': ('bacterial_infection', 40),
|
| 965 |
+
'fungal': ('fungal_disease', 35),
|
| 966 |
+
'viral': ('viral_infection', 45),
|
| 967 |
+
'pest': ('pest_damage', 30),
|
| 968 |
+
'nutrient': ('nutrient_deficiency', 25),
|
| 969 |
+
'drought': ('drought_stress', 50),
|
| 970 |
+
'flood': ('waterlogging', 60)
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
# Simple matching
|
| 974 |
+
for key, (dtype, dpct) in damage_mapping.items():
|
| 975 |
+
if key in top_label.lower():
|
| 976 |
+
damage_type = dtype
|
| 977 |
+
damage_percentage = dpct
|
| 978 |
+
break
|
| 979 |
+
|
| 980 |
+
if 'healthy' in top_label.lower():
|
| 981 |
+
damage_type = 'none'
|
| 982 |
+
damage_percentage = 0
|
| 983 |
+
else:
|
| 984 |
+
# Estimate damage from confidence
|
| 985 |
+
damage_percentage = int(conf * 50) # Scale to reasonable range
|
| 986 |
+
if damage_type == 'unknown':
|
| 987 |
+
damage_type = 'unclassified_damage'
|
| 988 |
+
|
| 989 |
+
confidence = float(conf)
|
| 990 |
+
|
| 991 |
+
except Exception as e:
|
| 992 |
+
print(f"Classification error: {e}")
|
| 993 |
+
# Fallback to random estimation for demo
|
| 994 |
+
damage_type = "unclassified_damage"
|
| 995 |
+
damage_percentage = np.random.randint(10, 50)
|
| 996 |
+
confidence = 0.6
|
| 997 |
+
else:
|
| 998 |
+
# No model - provide simulated response
|
| 999 |
+
damage_types = ['none', 'pest_damage', 'disease', 'drought_stress', 'flood_damage']
|
| 1000 |
+
damage_type = np.random.choice(damage_types, p=[0.3, 0.2, 0.25, 0.15, 0.1])
|
| 1001 |
+
damage_percentage = 0 if damage_type == 'none' else np.random.randint(10, 60)
|
| 1002 |
+
confidence = np.random.uniform(0.6, 0.9)
|
| 1003 |
+
|
| 1004 |
+
return jsonify({
|
| 1005 |
+
"success": True,
|
| 1006 |
+
"data": {
|
| 1007 |
+
"crop_type": crop_type,
|
| 1008 |
+
"stage": stage,
|
| 1009 |
+
"damage_type": damage_type,
|
| 1010 |
+
"damage_percentage": damage_percentage,
|
| 1011 |
+
"confidence": round(confidence, 2),
|
| 1012 |
+
"image_quality": {
|
| 1013 |
+
"dimensions": f"{width}x{height}",
|
| 1014 |
+
"format": pil_img.format or "JPEG",
|
| 1015 |
+
"is_valid": True
|
| 1016 |
+
},
|
| 1017 |
+
"location": {
|
| 1018 |
+
"lat": metadata.get('lat'),
|
| 1019 |
+
"lng": metadata.get('lng')
|
| 1020 |
+
},
|
| 1021 |
+
"recommendations": _get_damage_recommendations(damage_type, damage_percentage)
|
| 1022 |
+
}
|
| 1023 |
+
}), 200
|
| 1024 |
+
|
| 1025 |
+
except Exception as e:
|
| 1026 |
+
return jsonify({
|
| 1027 |
+
"success": False,
|
| 1028 |
+
"error": f"Analysis error: {str(e)}"
|
| 1029 |
+
}), 500
|
| 1030 |
+
|
| 1031 |
+
|
| 1032 |
+
def _get_damage_recommendations(damage_type: str, damage_pct: int) -> list:
|
| 1033 |
+
"""Get recommendations based on damage type and severity"""
|
| 1034 |
+
recommendations = []
|
| 1035 |
+
|
| 1036 |
+
if damage_type == 'none':
|
| 1037 |
+
recommendations.append({
|
| 1038 |
+
"action": "monitoring",
|
| 1039 |
+
"description": "Continue regular monitoring. Crop appears healthy."
|
| 1040 |
+
})
|
| 1041 |
+
elif damage_type == 'pest_damage':
|
| 1042 |
+
recommendations.append({
|
| 1043 |
+
"action": "pesticide_application",
|
| 1044 |
+
"description": "Apply appropriate pesticide. Consult local agriculture office for specific recommendations."
|
| 1045 |
+
})
|
| 1046 |
+
elif damage_type in ['disease', 'bacterial_infection', 'fungal_disease']:
|
| 1047 |
+
recommendations.append({
|
| 1048 |
+
"action": "fungicide_treatment",
|
| 1049 |
+
"description": "Apply fungicide treatment. Remove and destroy affected plant parts."
|
| 1050 |
+
})
|
| 1051 |
+
elif damage_type == 'drought_stress':
|
| 1052 |
+
recommendations.append({
|
| 1053 |
+
"action": "irrigation",
|
| 1054 |
+
"description": "Increase irrigation frequency. Apply mulch to retain soil moisture."
|
| 1055 |
+
})
|
| 1056 |
+
elif damage_type in ['flood_damage', 'waterlogging']:
|
| 1057 |
+
recommendations.append({
|
| 1058 |
+
"action": "drainage",
|
| 1059 |
+
"description": "Improve field drainage. Allow soil to dry before resuming irrigation."
|
| 1060 |
+
})
|
| 1061 |
+
|
| 1062 |
+
if damage_pct >= 50:
|
| 1063 |
+
recommendations.append({
|
| 1064 |
+
"action": "insurance_claim",
|
| 1065 |
+
"description": "Damage exceeds 50%. Consider filing an insurance claim.",
|
| 1066 |
+
"priority": "high"
|
| 1067 |
+
})
|
| 1068 |
+
|
| 1069 |
+
return recommendations
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
# ── Harvest Readiness Detection ─────────────────────────────────────────────
|
| 1073 |
+
_harvest_model = None
|
| 1074 |
+
|
| 1075 |
+
def _load_harvest_model():
|
| 1076 |
+
"""Lazy-load the harvest readiness YOLO classification model from HF Hub."""
|
| 1077 |
+
global _harvest_model
|
| 1078 |
+
if _harvest_model is not None:
|
| 1079 |
+
return _harvest_model
|
| 1080 |
+
try:
|
| 1081 |
+
from ultralytics import YOLO
|
| 1082 |
+
model_path = hf_hub_download(
|
| 1083 |
+
repo_id=HF_REPO_HARVEST, filename="best.pt"
|
| 1084 |
+
)
|
| 1085 |
+
_harvest_model = YOLO(model_path)
|
| 1086 |
+
logger.info("Harvest readiness YOLO model loaded from HF Hub")
|
| 1087 |
+
except Exception as e:
|
| 1088 |
+
log_exception(logger, e, "Failed to load harvest readiness model")
|
| 1089 |
+
_harvest_model = None
|
| 1090 |
+
return _harvest_model
|
| 1091 |
+
|
| 1092 |
+
|
| 1093 |
+
@app.route("/harvest_readiness", methods=["POST"])
|
| 1094 |
+
def harvest_readiness():
|
| 1095 |
+
"""Detect harvest readiness from a crop image using YOLO11m-cls."""
|
| 1096 |
+
model = _load_harvest_model()
|
| 1097 |
+
if model is None:
|
| 1098 |
+
return jsonify({"error": "Harvest readiness model unavailable"}), 503
|
| 1099 |
+
|
| 1100 |
+
if "file" not in request.files:
|
| 1101 |
+
return jsonify({"error": "no file part"}), 400
|
| 1102 |
+
file = request.files["file"]
|
| 1103 |
+
if file.filename == "":
|
| 1104 |
+
return jsonify({"error": "empty filename"}), 400
|
| 1105 |
+
|
| 1106 |
+
try:
|
| 1107 |
+
img_bytes = file.read()
|
| 1108 |
+
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
| 1109 |
+
|
| 1110 |
+
results = model.predict(pil_img, imgsz=224, verbose=False)
|
| 1111 |
+
result = results[0]
|
| 1112 |
+
|
| 1113 |
+
# Classification results: result.probs contains probabilities
|
| 1114 |
+
probs = result.probs
|
| 1115 |
+
top_class_idx = int(probs.top1)
|
| 1116 |
+
top_confidence = float(probs.top1conf)
|
| 1117 |
+
class_name = result.names[top_class_idx] if result.names else str(top_class_idx)
|
| 1118 |
+
|
| 1119 |
+
# Determine readiness from class name using keyword matching
|
| 1120 |
+
# The YOLO model classifies into categories like "Ready", "Unripe", "Overripe", etc.
|
| 1121 |
+
ready_keywords = {"ready", "ripe", "mature", "harvest", "overripe"}
|
| 1122 |
+
unready_keywords = {"unready", "unripe", "immature", "green", "growing"}
|
| 1123 |
+
class_lower = class_name.lower()
|
| 1124 |
+
class_tokens = set(class_lower.replace("_", " ").replace("-", " ").split())
|
| 1125 |
+
is_ready = bool(class_tokens & ready_keywords) and not bool(class_tokens & unready_keywords)
|
| 1126 |
+
|
| 1127 |
+
# Estimate maturity percentage from confidence and class
|
| 1128 |
+
if is_ready:
|
| 1129 |
+
maturity = max(80, int(top_confidence * 100))
|
| 1130 |
+
days_left = 0
|
| 1131 |
+
else:
|
| 1132 |
+
maturity = max(10, min(70, int(top_confidence * 60)))
|
| 1133 |
+
days_left = max(1, int((100 - maturity) * 0.5))
|
| 1134 |
+
|
| 1135 |
+
# Build top-k predictions
|
| 1136 |
+
topk_indices = probs.top5 if hasattr(probs, 'top5') else [top_class_idx]
|
| 1137 |
+
topk_confs = probs.top5conf.tolist() if hasattr(probs, 'top5conf') else [top_confidence]
|
| 1138 |
+
topk = [
|
| 1139 |
+
{"label": result.names.get(int(idx), str(idx)), "confidence": round(float(c), 4)}
|
| 1140 |
+
for idx, c in zip(topk_indices, topk_confs)
|
| 1141 |
+
]
|
| 1142 |
+
|
| 1143 |
+
return jsonify({
|
| 1144 |
+
"ready": "Yes" if is_ready else "No",
|
| 1145 |
+
"maturity": maturity,
|
| 1146 |
+
"days_left": days_left,
|
| 1147 |
+
"note": f"Classified as '{class_name}' with {top_confidence:.1%} confidence.",
|
| 1148 |
+
"class": class_name,
|
| 1149 |
+
"confidence": round(top_confidence, 4),
|
| 1150 |
+
"topk": topk,
|
| 1151 |
+
})
|
| 1152 |
+
except Exception as e:
|
| 1153 |
+
logger.error(f"Harvest readiness prediction error: {e}")
|
| 1154 |
+
return jsonify({"error": str(e)}), 500
|
| 1155 |
+
|
| 1156 |
+
|
| 1157 |
+
# ── Saffron Authenticity Classifier ──────────────────────────────────────────
|
| 1158 |
+
HF_SAFFRON_REPO = os.environ.get("HF_REPO_SAFFRON", "Arko007/saffron-verify-pretrained")
|
| 1159 |
+
HF_SAFFRON_API = f"https://api-inference.huggingface.co/models/{HF_SAFFRON_REPO}"
|
| 1160 |
+
SAFFRON_CLASSES = ["mogra", "lacha", "adulterated"]
|
| 1161 |
+
|
| 1162 |
+
|
| 1163 |
+
@app.route("/saffron_classify", methods=["POST"])
|
| 1164 |
+
def saffron_classify():
|
| 1165 |
+
"""Classify saffron purity from an uploaded image."""
|
| 1166 |
+
if "file" not in request.files:
|
| 1167 |
+
return jsonify({"error": "no file part"}), 400
|
| 1168 |
+
file = request.files["file"]
|
| 1169 |
+
if file.filename == "":
|
| 1170 |
+
return jsonify({"error": "empty filename"}), 400
|
| 1171 |
+
|
| 1172 |
+
try:
|
| 1173 |
+
img_bytes = file.read()
|
| 1174 |
+
# Validate it is a real image
|
| 1175 |
+
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
| 1176 |
+
|
| 1177 |
+
# Try HF Inference API first
|
| 1178 |
+
hf_token = os.environ.get("HF_TOKEN", "")
|
| 1179 |
+
headers = {}
|
| 1180 |
+
if hf_token:
|
| 1181 |
+
headers["Authorization"] = f"Bearer {hf_token}"
|
| 1182 |
+
|
| 1183 |
+
try:
|
| 1184 |
+
# Re-encode as JPEG for the API
|
| 1185 |
+
buf = io.BytesIO()
|
| 1186 |
+
pil_img.save(buf, format="JPEG")
|
| 1187 |
+
resp = _call_hf_inference_api(
|
| 1188 |
+
HF_SAFFRON_API,
|
| 1189 |
+
headers=headers,
|
| 1190 |
+
data=buf.getvalue(),
|
| 1191 |
+
)
|
| 1192 |
+
if resp.status_code == 200:
|
| 1193 |
+
results = resp.json()
|
| 1194 |
+
if isinstance(results, list) and len(results) > 0:
|
| 1195 |
+
top = results[0]
|
| 1196 |
+
return jsonify({
|
| 1197 |
+
"model": "saffron-verify-pretrained",
|
| 1198 |
+
"prediction": top.get("label", "unknown"),
|
| 1199 |
+
"confidence": round(top.get("score", 0.0), 4),
|
| 1200 |
+
"all_predictions": [
|
| 1201 |
+
{"label": r.get("label", ""), "confidence": round(r.get("score", 0.0), 4)}
|
| 1202 |
+
for r in results
|
| 1203 |
+
],
|
| 1204 |
+
"timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
| 1205 |
+
})
|
| 1206 |
+
logger.warning(f"Saffron HF API returned status {resp.status_code}: {resp.text[:200]}")
|
| 1207 |
+
except Exception as api_err:
|
| 1208 |
+
logger.warning(f"Saffron HF Inference API failed: {api_err}")
|
| 1209 |
+
|
| 1210 |
+
# Fallback: try loading model locally via timm
|
| 1211 |
+
try:
|
| 1212 |
+
import timm
|
| 1213 |
+
import torch.nn as nn
|
| 1214 |
+
from torchvision import transforms
|
| 1215 |
+
|
| 1216 |
+
class _SaffronModel(nn.Module):
|
| 1217 |
+
def __init__(self):
|
| 1218 |
+
super().__init__()
|
| 1219 |
+
self.backbone = timm.create_model(
|
| 1220 |
+
"convnext_base", pretrained=False,
|
| 1221 |
+
num_classes=0, drop_rate=0.3, drop_path_rate=0.2,
|
| 1222 |
+
)
|
| 1223 |
+
feat_dim = self.backbone.num_features
|
| 1224 |
+
self.head = nn.Sequential(
|
| 1225 |
+
nn.LayerNorm(feat_dim),
|
| 1226 |
+
nn.Dropout(p=0.3),
|
| 1227 |
+
nn.Linear(feat_dim, 512),
|
| 1228 |
+
nn.GELU(),
|
| 1229 |
+
nn.Dropout(p=0.15),
|
| 1230 |
+
nn.Linear(512, 3),
|
| 1231 |
+
)
|
| 1232 |
+
|
| 1233 |
+
def forward(self, x):
|
| 1234 |
+
return self.head(self.backbone(x))
|
| 1235 |
+
|
| 1236 |
+
ckpt_path = hf_hub_download(repo_id=HF_SAFFRON_REPO, filename="best_model.pth")
|
| 1237 |
+
model_s = _SaffronModel()
|
| 1238 |
+
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 1239 |
+
model_s.load_state_dict(ckpt.get("model_state", ckpt.get("model_state_dict", ckpt)))
|
| 1240 |
+
model_s.eval()
|
| 1241 |
+
|
| 1242 |
+
transform = transforms.Compose([
|
| 1243 |
+
transforms.Resize(512),
|
| 1244 |
+
transforms.CenterCrop(512),
|
| 1245 |
+
transforms.ToTensor(),
|
| 1246 |
+
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
| 1247 |
+
])
|
| 1248 |
+
tensor = transform(pil_img).unsqueeze(0)
|
| 1249 |
+
with torch.no_grad():
|
| 1250 |
+
logits = model_s(tensor)
|
| 1251 |
+
probs = torch.softmax(logits, dim=1)[0]
|
| 1252 |
+
pred_idx = probs.argmax().item()
|
| 1253 |
+
|
| 1254 |
+
all_preds = [
|
| 1255 |
+
{"label": SAFFRON_CLASSES[i], "confidence": round(probs[i].item(), 4)}
|
| 1256 |
+
for i in range(len(SAFFRON_CLASSES))
|
| 1257 |
+
]
|
| 1258 |
+
all_preds.sort(key=lambda x: x["confidence"], reverse=True)
|
| 1259 |
+
|
| 1260 |
+
return jsonify({
|
| 1261 |
+
"model": "saffron-verify-pretrained",
|
| 1262 |
+
"prediction": SAFFRON_CLASSES[pred_idx],
|
| 1263 |
+
"confidence": round(probs[pred_idx].item(), 4),
|
| 1264 |
+
"all_predictions": all_preds,
|
| 1265 |
+
"timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
| 1266 |
+
})
|
| 1267 |
+
except Exception as local_err:
|
| 1268 |
+
logger.warning(f"Saffron local model also failed: {local_err}")
|
| 1269 |
+
|
| 1270 |
+
return jsonify({"error": "Saffron model unavailable via API and local fallback"}), 503
|
| 1271 |
+
except Exception as e:
|
| 1272 |
+
logger.error(f"Saffron classification error: {e}")
|
| 1273 |
+
return jsonify({"error": str(e)}), 500
|
| 1274 |
+
|
| 1275 |
+
|
| 1276 |
+
# ── Walnut Defect Classifier ────────────────────────────────────────────────
|
| 1277 |
+
HF_WALNUT_DEFECT_REPO = os.environ.get("HF_REPO_WALNUT_DEFECT", "Arko007/walnut-defect-classifier")
|
| 1278 |
+
HF_WALNUT_DEFECT_API = f"https://api-inference.huggingface.co/models/{HF_WALNUT_DEFECT_REPO}"
|
| 1279 |
+
WALNUT_DEFECT_CLASSES = ["Healthy", "Black Spot", "Shriveled", "Damaged"]
|
| 1280 |
+
|
| 1281 |
+
|
| 1282 |
+
@app.route("/walnut_defect_classify", methods=["POST"])
|
| 1283 |
+
def walnut_defect_classify():
|
| 1284 |
+
"""Classify walnut shell defects from an uploaded image."""
|
| 1285 |
+
if "file" not in request.files:
|
| 1286 |
+
return jsonify({"error": "no file part"}), 400
|
| 1287 |
+
file = request.files["file"]
|
| 1288 |
+
if file.filename == "":
|
| 1289 |
+
return jsonify({"error": "empty filename"}), 400
|
| 1290 |
+
|
| 1291 |
+
try:
|
| 1292 |
+
img_bytes = file.read()
|
| 1293 |
+
pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
| 1294 |
+
|
| 1295 |
+
hf_token = os.environ.get("HF_TOKEN", "")
|
| 1296 |
+
headers = {}
|
| 1297 |
+
if hf_token:
|
| 1298 |
+
headers["Authorization"] = f"Bearer {hf_token}"
|
| 1299 |
+
|
| 1300 |
+
try:
|
| 1301 |
+
buf = io.BytesIO()
|
| 1302 |
+
pil_img.save(buf, format="JPEG")
|
| 1303 |
+
resp = _call_hf_inference_api(
|
| 1304 |
+
HF_WALNUT_DEFECT_API,
|
| 1305 |
+
headers=headers,
|
| 1306 |
+
data=buf.getvalue(),
|
| 1307 |
+
)
|
| 1308 |
+
if resp.status_code == 200:
|
| 1309 |
+
results = resp.json()
|
| 1310 |
+
if isinstance(results, list) and len(results) > 0:
|
| 1311 |
+
top = results[0]
|
| 1312 |
+
return jsonify({
|
| 1313 |
+
"model": "walnut-defect-classifier",
|
| 1314 |
+
"prediction": top.get("label", "unknown"),
|
| 1315 |
+
"confidence": round(top.get("score", 0.0), 4),
|
| 1316 |
+
"all_predictions": [
|
| 1317 |
+
{"label": r.get("label", ""), "confidence": round(r.get("score", 0.0), 4)}
|
| 1318 |
+
for r in results
|
| 1319 |
+
],
|
| 1320 |
+
"timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
| 1321 |
+
})
|
| 1322 |
+
logger.warning(f"Walnut defect HF API returned status {resp.status_code}: {resp.text[:200]}")
|
| 1323 |
+
except Exception as api_err:
|
| 1324 |
+
logger.warning(f"Walnut defect HF Inference API failed: {api_err}")
|
| 1325 |
+
|
| 1326 |
+
# Fallback: load model locally via timm
|
| 1327 |
+
try:
|
| 1328 |
+
import timm
|
| 1329 |
+
ckpt_path = hf_hub_download(repo_id=HF_WALNUT_DEFECT_REPO, filename="best_model.pth")
|
| 1330 |
+
model_w = timm.create_model("efficientnet_b3", pretrained=False, num_classes=4, drop_rate=0.4)
|
| 1331 |
+
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 1332 |
+
state = {k.replace("module.", ""): v for k, v in ckpt.get("model_state_dict", ckpt).items()}
|
| 1333 |
+
model_w.load_state_dict(state)
|
| 1334 |
+
model_w.eval()
|
| 1335 |
+
|
| 1336 |
+
from torchvision import transforms
|
| 1337 |
+
transform = transforms.Compose([
|
| 1338 |
+
transforms.Resize((512, 512)),
|
| 1339 |
+
transforms.ToTensor(),
|
| 1340 |
+
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
| 1341 |
+
])
|
| 1342 |
+
tensor = transform(pil_img).unsqueeze(0)
|
| 1343 |
+
with torch.no_grad():
|
| 1344 |
+
probs = torch.softmax(model_w(tensor), dim=1)[0]
|
| 1345 |
+
pred_idx = probs.argmax().item()
|
| 1346 |
+
|
| 1347 |
+
all_preds = [
|
| 1348 |
+
{"label": WALNUT_DEFECT_CLASSES[i], "confidence": round(probs[i].item(), 4)}
|
| 1349 |
+
for i in range(len(WALNUT_DEFECT_CLASSES))
|
| 1350 |
+
]
|
| 1351 |
+
all_preds.sort(key=lambda x: x["confidence"], reverse=True)
|
| 1352 |
+
|
| 1353 |
+
return jsonify({
|
| 1354 |
+
"model": "walnut-defect-classifier",
|
| 1355 |
+
"prediction": WALNUT_DEFECT_CLASSES[pred_idx],
|
| 1356 |
+
"confidence": round(probs[pred_idx].item(), 4),
|
| 1357 |
+
"all_predictions": all_preds,
|
| 1358 |
+
"timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
| 1359 |
+
})
|
| 1360 |
+
except Exception as local_err:
|
| 1361 |
+
logger.warning(f"Walnut defect local model also failed: {local_err}")
|
| 1362 |
+
|
| 1363 |
+
return jsonify({"error": "Walnut defect model unavailable via API and local fallback"}), 503
|
| 1364 |
+
except Exception as e:
|
| 1365 |
+
logger.error(f"Walnut defect classification error: {e}")
|
| 1366 |
+
return jsonify({"error": str(e)}), 500
|
| 1367 |
+
|
| 1368 |
+
|
| 1369 |
+
# ── Walnut Rancidity Predictor ──────────────────────────────────────────────
|
| 1370 |
+
|
| 1371 |
+
@app.route("/walnut_rancidity_predict", methods=["POST"])
|
| 1372 |
+
def walnut_rancidity_predict():
|
| 1373 |
+
"""Predict walnut rancidity and remaining shelf life from storage conditions.
|
| 1374 |
+
|
| 1375 |
+
Uses Arrhenius-based lipid oxidation kinetics (the same chemistry model
|
| 1376 |
+
behind the Arko007/walnut-rancidity-predictor HF model) so that the
|
| 1377 |
+
endpoint works without downloading a ~85 K-parameter LSTM checkpoint.
|
| 1378 |
+
"""
|
| 1379 |
+
is_valid, error_response = validate_content_type(request)
|
| 1380 |
+
if not is_valid:
|
| 1381 |
+
return jsonify(error_response), error_response["status"]
|
| 1382 |
+
|
| 1383 |
+
try:
|
| 1384 |
+
data = request.get_json(force=True)
|
| 1385 |
+
except Exception:
|
| 1386 |
+
return jsonify({"error": "Invalid JSON payload"}), 400
|
| 1387 |
+
|
| 1388 |
+
# Required fields
|
| 1389 |
+
storage_days = data.get("storage_days")
|
| 1390 |
+
temperature = data.get("temperature")
|
| 1391 |
+
humidity = data.get("humidity")
|
| 1392 |
+
moisture = data.get("moisture")
|
| 1393 |
+
|
| 1394 |
+
if storage_days is None or temperature is None or humidity is None or moisture is None:
|
| 1395 |
+
return jsonify({
|
| 1396 |
+
"error": "Missing required fields: storage_days, temperature, humidity, moisture"
|
| 1397 |
+
}), 400
|
| 1398 |
+
|
| 1399 |
+
try:
|
| 1400 |
+
storage_days = float(storage_days)
|
| 1401 |
+
temperature = float(temperature)
|
| 1402 |
+
humidity = float(humidity)
|
| 1403 |
+
moisture = float(moisture)
|
| 1404 |
+
except (ValueError, TypeError):
|
| 1405 |
+
return jsonify({"error": "All inputs must be numeric"}), 400
|
| 1406 |
+
|
| 1407 |
+
# Validation with friendly responses
|
| 1408 |
+
if storage_days < 0 or storage_days > 365:
|
| 1409 |
+
return jsonify({
|
| 1410 |
+
"success": False,
|
| 1411 |
+
"message": "Please choose storage days between 0 and 365 days.",
|
| 1412 |
+
"validation_error": "storage_days_out_of_range"
|
| 1413 |
+
}), 200
|
| 1414 |
+
|
| 1415 |
+
if temperature < -10 or temperature > 50:
|
| 1416 |
+
return jsonify({
|
| 1417 |
+
"success": False,
|
| 1418 |
+
"message": "Please choose temperature between -10 and 50 degrees Celsius.",
|
| 1419 |
+
"validation_error": "temperature_out_of_range"
|
| 1420 |
+
}), 200
|
| 1421 |
+
|
| 1422 |
+
if humidity < 0 or humidity > 100:
|
| 1423 |
+
return jsonify({
|
| 1424 |
+
"success": False,
|
| 1425 |
+
"message": "Please choose humidity between 0 and 100 percent.",
|
| 1426 |
+
"validation_error": "humidity_out_of_range"
|
| 1427 |
+
}), 200
|
| 1428 |
+
|
| 1429 |
+
if moisture < 0 or moisture > 15:
|
| 1430 |
+
return jsonify({
|
| 1431 |
+
"success": False,
|
| 1432 |
+
"message": "Please choose a value between 0 and 15 percent for moisture content.",
|
| 1433 |
+
"validation_error": "moisture_out_of_range"
|
| 1434 |
+
}), 200
|
| 1435 |
+
|
| 1436 |
+
try:
|
| 1437 |
+
import math
|
| 1438 |
+
|
| 1439 |
+
# Arrhenius kinetics: k(T) = A * exp(-Ea / (R * T_kelvin))
|
| 1440 |
+
A = 1.5e12
|
| 1441 |
+
Ea = 80_000 # J/mol
|
| 1442 |
+
R = 8.314 # J/(mol*K)
|
| 1443 |
+
T_kelvin = temperature + 273.15
|
| 1444 |
+
k_base = A * math.exp(-Ea / (R * T_kelvin))
|
| 1445 |
+
|
| 1446 |
+
# Humidity and moisture correction factors
|
| 1447 |
+
humidity_factor = 1.0 + 0.005 * max(0, humidity - 50)
|
| 1448 |
+
moisture_factor = 1.0 + 0.02 * max(0, moisture - 4)
|
| 1449 |
+
k_eff = k_base * humidity_factor * moisture_factor
|
| 1450 |
+
|
| 1451 |
+
# PV(t) = PV_0 * exp(k * t) — initial PV ~ 0.5 meq/kg for fresh walnuts
|
| 1452 |
+
PV_0 = 0.5
|
| 1453 |
+
PV_t = PV_0 * math.exp(k_eff * storage_days)
|
| 1454 |
+
|
| 1455 |
+
# Rancidity threshold: PV > 5 meq/kg (FSSAI / Codex)
|
| 1456 |
+
rancidity_prob = 1.0 / (1.0 + math.exp(-(PV_t - 5)))
|
| 1457 |
+
|
| 1458 |
+
# Shelf life remaining = days until PV reaches 5
|
| 1459 |
+
if PV_t >= 5:
|
| 1460 |
+
shelf_life_remaining = 0.0
|
| 1461 |
+
elif k_eff > 0:
|
| 1462 |
+
shelf_life_remaining = max(0.0, (math.log(5 / PV_0) / k_eff) - storage_days)
|
| 1463 |
+
else:
|
| 1464 |
+
shelf_life_remaining = 365.0
|
| 1465 |
+
|
| 1466 |
+
# Decay curve (normalised PV, capped at 1)
|
| 1467 |
+
decay_curve = min(1.0, PV_t / 10.0)
|
| 1468 |
+
|
| 1469 |
+
# Risk level
|
| 1470 |
+
if rancidity_prob < 0.30:
|
| 1471 |
+
risk_level = "LOW"
|
| 1472 |
+
elif rancidity_prob < 0.70:
|
| 1473 |
+
risk_level = "MEDIUM"
|
| 1474 |
+
else:
|
| 1475 |
+
risk_level = "HIGH"
|
| 1476 |
+
|
| 1477 |
+
return jsonify({
|
| 1478 |
+
"success": True,
|
| 1479 |
+
"model": "walnut-rancidity-predictor",
|
| 1480 |
+
"prediction": {
|
| 1481 |
+
"rancidity_probability": round(rancidity_prob, 4),
|
| 1482 |
+
"shelf_life_remaining_days": round(shelf_life_remaining, 1),
|
| 1483 |
+
"decay_curve_value": round(decay_curve, 4),
|
| 1484 |
+
},
|
| 1485 |
+
"risk_level": risk_level,
|
| 1486 |
+
"advisory": (
|
| 1487 |
+
"Walnuts are safe for storage."
|
| 1488 |
+
if risk_level == "LOW"
|
| 1489 |
+
else "Monitor quality closely — consider selling soon."
|
| 1490 |
+
if risk_level == "MEDIUM"
|
| 1491 |
+
else "High rancidity risk — sell or consume immediately."
|
| 1492 |
+
),
|
| 1493 |
+
"timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
| 1494 |
+
}), 200
|
| 1495 |
+
except Exception as e:
|
| 1496 |
+
logger.error(f"Walnut rancidity prediction error: {e}")
|
| 1497 |
+
return jsonify({"error": str(e)}), 500
|
| 1498 |
+
|
| 1499 |
+
|
| 1500 |
+
# ── Apple Price Predictor ────────────────────────────────────────────────────
|
| 1501 |
+
APPLE_VARIETY_BASE_PRICES = {
|
| 1502 |
+
"Shimla": 97, "Kinnauri": 125, "Royal Delicious": 82,
|
| 1503 |
+
"Golden Delicious": 87, "Maharaji": 60,
|
| 1504 |
+
}
|
| 1505 |
+
APPLE_REGIONS = ["Himachal Pradesh", "Jammu & Kashmir", "Uttarakhand",
|
| 1506 |
+
"Arunachal Pradesh", "Nagaland"]
|
| 1507 |
+
APPLE_STORAGE_COST_PER_DAY = 0.75 # ₹/kg/day
|
| 1508 |
+
|
| 1509 |
+
|
| 1510 |
+
@app.route("/apple_price_predict", methods=["POST"])
|
| 1511 |
+
def apple_price_predict():
|
| 1512 |
+
"""Predict apple wholesale price 7 days ahead and recommend SELL or STORE."""
|
| 1513 |
+
is_valid, error_response = validate_content_type(request)
|
| 1514 |
+
if not is_valid:
|
| 1515 |
+
return jsonify(error_response), error_response["status"]
|
| 1516 |
+
|
| 1517 |
+
try:
|
| 1518 |
+
data = request.get_json(force=True)
|
| 1519 |
+
except Exception:
|
| 1520 |
+
return jsonify({"error": "Invalid JSON payload"}), 400
|
| 1521 |
+
|
| 1522 |
+
current_price = data.get("current_price")
|
| 1523 |
+
apple_variety = data.get("apple_variety", "Shimla")
|
| 1524 |
+
region = data.get("region", "Himachal Pradesh")
|
| 1525 |
+
storage_time_days = data.get("storage_time_days", 0)
|
| 1526 |
+
date_str = data.get("date", _time.strftime("%Y-%m-%d"))
|
| 1527 |
+
|
| 1528 |
+
if current_price is None:
|
| 1529 |
+
return jsonify({"error": "current_price is required"}), 400
|
| 1530 |
+
|
| 1531 |
+
try:
|
| 1532 |
+
current_price = float(current_price)
|
| 1533 |
+
storage_time_days = int(storage_time_days)
|
| 1534 |
+
except (ValueError, TypeError):
|
| 1535 |
+
return jsonify({"error": "current_price must be numeric, storage_time_days must be integer"}), 400
|
| 1536 |
+
|
| 1537 |
+
if current_price <= 0:
|
| 1538 |
+
return jsonify({"error": "current_price must be positive"}), 400
|
| 1539 |
+
|
| 1540 |
+
try:
|
| 1541 |
+
import math
|
| 1542 |
+
from datetime import datetime
|
| 1543 |
+
|
| 1544 |
+
# Parse date for seasonal adjustment
|
| 1545 |
+
try:
|
| 1546 |
+
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
| 1547 |
+
except ValueError:
|
| 1548 |
+
dt = datetime.utcnow()
|
| 1549 |
+
|
| 1550 |
+
month = dt.month
|
| 1551 |
+
|
| 1552 |
+
# Seasonal price adjustments (Indian apple market dynamics)
|
| 1553 |
+
seasonal_adj = 0.0
|
| 1554 |
+
if 7 <= month <= 9: # Harvest season — supply glut
|
| 1555 |
+
seasonal_adj = -12.0
|
| 1556 |
+
elif 4 <= month <= 6: # Summer scarcity
|
| 1557 |
+
seasonal_adj = 15.0
|
| 1558 |
+
elif month in (10, 11): # Diwali festival demand
|
| 1559 |
+
seasonal_adj = 8.0
|
| 1560 |
+
|
| 1561 |
+
# Variety premium
|
| 1562 |
+
base_price = APPLE_VARIETY_BASE_PRICES.get(apple_variety, 90)
|
| 1563 |
+
variety_factor = base_price / 90.0
|
| 1564 |
+
|
| 1565 |
+
# Storage quality decay
|
| 1566 |
+
storage_decay = -0.08 * storage_time_days
|
| 1567 |
+
|
| 1568 |
+
# Simple trend: mild inflation
|
| 1569 |
+
annual_inflation = 5.0
|
| 1570 |
+
days_in_year = 365.0
|
| 1571 |
+
trend_adj = (7.0 / days_in_year) * annual_inflation
|
| 1572 |
+
|
| 1573 |
+
# Predicted 7-day price (deterministic)
|
| 1574 |
+
predicted_price_7d = round(
|
| 1575 |
+
current_price * variety_factor
|
| 1576 |
+
+ seasonal_adj
|
| 1577 |
+
+ storage_decay
|
| 1578 |
+
+ trend_adj,
|
| 1579 |
+
2,
|
| 1580 |
+
)
|
| 1581 |
+
# Clamp to realistic range
|
| 1582 |
+
predicted_price_7d = max(30.0, min(200.0, predicted_price_7d))
|
| 1583 |
+
|
| 1584 |
+
storage_cost_7d = round(APPLE_STORAGE_COST_PER_DAY * 7, 2)
|
| 1585 |
+
breakeven_price = round(current_price + storage_cost_7d, 2)
|
| 1586 |
+
recommendation = "STORE" if predicted_price_7d > breakeven_price else "SELL"
|
| 1587 |
+
|
| 1588 |
+
return jsonify({
|
| 1589 |
+
"model": "apple-price-predictor",
|
| 1590 |
+
"predicted_price_7d": predicted_price_7d,
|
| 1591 |
+
"recommendation": recommendation,
|
| 1592 |
+
"current_price": current_price,
|
| 1593 |
+
"storage_cost_7d": storage_cost_7d,
|
| 1594 |
+
"breakeven_price": breakeven_price,
|
| 1595 |
+
"currency": "INR",
|
| 1596 |
+
"confidence": "hybrid seasonal+trend model",
|
| 1597 |
+
"advisory": (
|
| 1598 |
+
f"Predicted price in 7 days: ₹{predicted_price_7d}/kg. "
|
| 1599 |
+
f"{'Store for better returns.' if recommendation == 'STORE' else 'Sell now — prices may not cover storage costs.'}"
|
| 1600 |
+
),
|
| 1601 |
+
"timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
| 1602 |
+
}), 200
|
| 1603 |
+
except Exception as e:
|
| 1604 |
+
logger.error(f"Apple price prediction error: {e}")
|
| 1605 |
+
return jsonify({"error": str(e)}), 500
|
| 1606 |
+
|
| 1607 |
+
|
| 1608 |
+
if __name__ == "__main__":
|
| 1609 |
+
app.run(host="0.0.0.0", port=5000, debug=True)
|
ai-backend/forecast_model.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Price Forecasting Module for AgroMind AI Backend
|
| 3 |
+
Uses LightGBM for time-series price prediction with confidence intervals
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
import pickle
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Dict, List, Optional, Tuple, Any
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import pandas as pd
|
| 14 |
+
|
| 15 |
+
# Configure logging
|
| 16 |
+
logging.basicConfig(level=logging.INFO)
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
import lightgbm as lgb
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PriceForecastModel:
|
| 23 |
+
"""
|
| 24 |
+
Price forecasting model using LightGBM
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
self.model = None
|
| 29 |
+
self.scaler = None
|
| 30 |
+
self.feature_names = None
|
| 31 |
+
self.is_trained = False
|
| 32 |
+
self._load_model()
|
| 33 |
+
|
| 34 |
+
def _load_model(self):
|
| 35 |
+
"""Load pre-trained model if available (currently no pre-trained
|
| 36 |
+
price forecast model is shipped)."""
|
| 37 |
+
self.model = None
|
| 38 |
+
self.scaler = None
|
| 39 |
+
self.is_trained = False
|
| 40 |
+
|
| 41 |
+
def prepare_features(
|
| 42 |
+
self,
|
| 43 |
+
historical_prices: List[Dict],
|
| 44 |
+
location: Optional[Dict] = None,
|
| 45 |
+
commodity_type: str = "oilseed",
|
| 46 |
+
global_indices: Optional[Dict] = None
|
| 47 |
+
) -> Tuple[np.ndarray, List[str]]:
|
| 48 |
+
"""
|
| 49 |
+
Prepare features for price forecasting
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
historical_prices: List of {date, price, volume} dicts
|
| 53 |
+
location: {lat, lng, state, district}
|
| 54 |
+
commodity_type: Type of commodity
|
| 55 |
+
global_indices: Optional global market indices
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
Feature array and feature names
|
| 59 |
+
"""
|
| 60 |
+
df = pd.DataFrame(historical_prices)
|
| 61 |
+
|
| 62 |
+
if 'date' in df.columns:
|
| 63 |
+
df['date'] = pd.to_datetime(df['date'])
|
| 64 |
+
df = df.sort_values('date')
|
| 65 |
+
|
| 66 |
+
features = {}
|
| 67 |
+
feature_names = []
|
| 68 |
+
|
| 69 |
+
# Price-based features
|
| 70 |
+
if 'price' in df.columns:
|
| 71 |
+
prices = df['price'].values
|
| 72 |
+
|
| 73 |
+
# Basic statistics
|
| 74 |
+
features['price_mean'] = np.mean(prices)
|
| 75 |
+
features['price_std'] = np.std(prices)
|
| 76 |
+
features['price_min'] = np.min(prices)
|
| 77 |
+
features['price_max'] = np.max(prices)
|
| 78 |
+
features['price_last'] = prices[-1] if len(prices) > 0 else 0
|
| 79 |
+
|
| 80 |
+
# Trend features
|
| 81 |
+
if len(prices) >= 7:
|
| 82 |
+
features['price_ma_7'] = np.mean(prices[-7:])
|
| 83 |
+
else:
|
| 84 |
+
features['price_ma_7'] = features['price_mean']
|
| 85 |
+
|
| 86 |
+
if len(prices) >= 30:
|
| 87 |
+
features['price_ma_30'] = np.mean(prices[-30:])
|
| 88 |
+
else:
|
| 89 |
+
features['price_ma_30'] = features['price_mean']
|
| 90 |
+
|
| 91 |
+
# Volatility
|
| 92 |
+
if len(prices) >= 2:
|
| 93 |
+
returns = np.diff(prices) / prices[:-1]
|
| 94 |
+
features['volatility'] = np.std(returns) if len(returns) > 0 else 0
|
| 95 |
+
else:
|
| 96 |
+
features['volatility'] = 0
|
| 97 |
+
|
| 98 |
+
# Momentum
|
| 99 |
+
if len(prices) >= 7:
|
| 100 |
+
features['momentum_7d'] = (prices[-1] - prices[-7]) / prices[-7] if prices[-7] != 0 else 0
|
| 101 |
+
else:
|
| 102 |
+
features['momentum_7d'] = 0
|
| 103 |
+
|
| 104 |
+
feature_names.extend([
|
| 105 |
+
'price_mean', 'price_std', 'price_min', 'price_max',
|
| 106 |
+
'price_last', 'price_ma_7', 'price_ma_30',
|
| 107 |
+
'volatility', 'momentum_7d'
|
| 108 |
+
])
|
| 109 |
+
|
| 110 |
+
# Volume features
|
| 111 |
+
if 'volume' in df.columns:
|
| 112 |
+
volumes = df['volume'].values
|
| 113 |
+
features['volume_mean'] = np.mean(volumes)
|
| 114 |
+
features['volume_last'] = volumes[-1] if len(volumes) > 0 else 0
|
| 115 |
+
feature_names.extend(['volume_mean', 'volume_last'])
|
| 116 |
+
|
| 117 |
+
# Temporal features
|
| 118 |
+
if 'date' in df.columns and len(df) > 0:
|
| 119 |
+
last_date = df['date'].iloc[-1]
|
| 120 |
+
features['month'] = last_date.month
|
| 121 |
+
features['quarter'] = (last_date.month - 1) // 3 + 1
|
| 122 |
+
features['is_harvest_season'] = 1 if last_date.month in [10, 11, 12, 1, 2, 3] else 0
|
| 123 |
+
feature_names.extend(['month', 'quarter', 'is_harvest_season'])
|
| 124 |
+
|
| 125 |
+
# Location features (encoded)
|
| 126 |
+
if location:
|
| 127 |
+
# Simple state encoding (can be expanded)
|
| 128 |
+
state_codes = {
|
| 129 |
+
'maharashtra': 1, 'gujarat': 2, 'rajasthan': 3,
|
| 130 |
+
'madhya pradesh': 4, 'karnataka': 5, 'andhra pradesh': 6,
|
| 131 |
+
'telangana': 7, 'tamil nadu': 8, 'punjab': 9, 'haryana': 10
|
| 132 |
+
}
|
| 133 |
+
state = location.get('state', '').lower()
|
| 134 |
+
features['state_code'] = state_codes.get(state, 0)
|
| 135 |
+
feature_names.append('state_code')
|
| 136 |
+
|
| 137 |
+
# Commodity type encoding
|
| 138 |
+
commodity_codes = {
|
| 139 |
+
'groundnut': 1, 'sunflower': 2, 'soybean': 3, 'mustard': 4,
|
| 140 |
+
'sesame': 5, 'oilseed_meal': 6, 'oilseed_cake': 7, 'oilseed_husk': 8,
|
| 141 |
+
'castor': 9, 'linseed': 10, 'oilseed': 0
|
| 142 |
+
}
|
| 143 |
+
features['commodity_code'] = commodity_codes.get(commodity_type.lower(), 0)
|
| 144 |
+
feature_names.append('commodity_code')
|
| 145 |
+
|
| 146 |
+
# Global indices
|
| 147 |
+
if global_indices:
|
| 148 |
+
features['global_oil_price'] = global_indices.get('crude_oil', 0)
|
| 149 |
+
features['global_soy_price'] = global_indices.get('soybean', 0)
|
| 150 |
+
features['usd_inr'] = global_indices.get('usd_inr', 83.0)
|
| 151 |
+
feature_names.extend(['global_oil_price', 'global_soy_price', 'usd_inr'])
|
| 152 |
+
|
| 153 |
+
# Create feature array
|
| 154 |
+
feature_array = np.array([features.get(f, 0) for f in feature_names]).reshape(1, -1)
|
| 155 |
+
|
| 156 |
+
return feature_array, feature_names
|
| 157 |
+
|
| 158 |
+
def forecast(
|
| 159 |
+
self,
|
| 160 |
+
historical_prices: List[Dict],
|
| 161 |
+
location: Optional[Dict] = None,
|
| 162 |
+
commodity_type: str = "oilseed",
|
| 163 |
+
global_indices: Optional[Dict] = None,
|
| 164 |
+
forecast_days: int = 30
|
| 165 |
+
) -> Dict[str, Any]:
|
| 166 |
+
"""
|
| 167 |
+
Generate price forecast with confidence intervals
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
historical_prices: List of {date, price, volume} dicts
|
| 171 |
+
location: Location dict
|
| 172 |
+
commodity_type: Type of commodity
|
| 173 |
+
global_indices: Global market indices
|
| 174 |
+
forecast_days: Number of days to forecast
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
Forecast results with predictions and confidence intervals
|
| 178 |
+
"""
|
| 179 |
+
if not historical_prices or len(historical_prices) < 5:
|
| 180 |
+
return {
|
| 181 |
+
"success": False,
|
| 182 |
+
"error": "Insufficient historical data. Need at least 5 price points."
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
try:
|
| 186 |
+
# Prepare features
|
| 187 |
+
features, feature_names = self.prepare_features(
|
| 188 |
+
historical_prices, location, commodity_type, global_indices
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# Get last price for baseline
|
| 192 |
+
df = pd.DataFrame(historical_prices)
|
| 193 |
+
df['date'] = pd.to_datetime(df['date'])
|
| 194 |
+
df = df.sort_values('date')
|
| 195 |
+
last_price = float(df['price'].iloc[-1])
|
| 196 |
+
last_date = df['date'].iloc[-1]
|
| 197 |
+
|
| 198 |
+
# Calculate historical volatility for confidence intervals
|
| 199 |
+
prices = df['price'].values
|
| 200 |
+
if len(prices) >= 2:
|
| 201 |
+
returns = np.diff(prices) / prices[:-1]
|
| 202 |
+
daily_volatility = np.std(returns) if len(returns) > 0 else 0.02
|
| 203 |
+
else:
|
| 204 |
+
daily_volatility = 0.02
|
| 205 |
+
|
| 206 |
+
# Generate forecasts
|
| 207 |
+
forecasts = []
|
| 208 |
+
|
| 209 |
+
if self.model is not None and self.is_trained:
|
| 210 |
+
# Use trained model
|
| 211 |
+
for day in range(1, forecast_days + 1):
|
| 212 |
+
# This is simplified - in production, would update features iteratively
|
| 213 |
+
pred = self.model.predict(features)[0]
|
| 214 |
+
forecasts.append(pred)
|
| 215 |
+
else:
|
| 216 |
+
# Statistical fallback using trend extrapolation and seasonal adjustment.
|
| 217 |
+
# Each day's forecast builds on the previous day (random walk with drift),
|
| 218 |
+
# which is standard for short-horizon price forecasting.
|
| 219 |
+
logger.info("Using statistical fallback for price forecast (no trained model)")
|
| 220 |
+
trend = 0.0
|
| 221 |
+
if len(prices) >= 2:
|
| 222 |
+
daily_changes = np.diff(prices)
|
| 223 |
+
trend = np.mean(daily_changes)
|
| 224 |
+
|
| 225 |
+
current_price = last_price
|
| 226 |
+
for day in range(1, forecast_days + 1):
|
| 227 |
+
forecast_date = last_date + timedelta(days=day)
|
| 228 |
+
seasonal = self._get_seasonal_factor(forecast_date.month)
|
| 229 |
+
# Monthly seasonal factor scaled to a daily effect
|
| 230 |
+
daily_seasonal = current_price * seasonal / 30
|
| 231 |
+
pred = current_price + trend + daily_seasonal
|
| 232 |
+
pred = max(pred, 0)
|
| 233 |
+
forecasts.append(pred)
|
| 234 |
+
current_price = pred
|
| 235 |
+
|
| 236 |
+
# Calculate confidence intervals
|
| 237 |
+
forecast_dates = []
|
| 238 |
+
predictions = []
|
| 239 |
+
lower_bounds = []
|
| 240 |
+
upper_bounds = []
|
| 241 |
+
|
| 242 |
+
for day, pred in enumerate(forecasts, 1):
|
| 243 |
+
forecast_date = last_date + timedelta(days=day)
|
| 244 |
+
forecast_dates.append(forecast_date.strftime('%Y-%m-%d'))
|
| 245 |
+
predictions.append(round(pred, 2))
|
| 246 |
+
|
| 247 |
+
# CI widens with forecast horizon
|
| 248 |
+
ci_width = daily_volatility * last_price * np.sqrt(day) * 1.96
|
| 249 |
+
lower_bounds.append(round(max(pred - ci_width, 0), 2))
|
| 250 |
+
upper_bounds.append(round(pred + ci_width, 2))
|
| 251 |
+
|
| 252 |
+
# Summary statistics
|
| 253 |
+
avg_forecast = np.mean(predictions)
|
| 254 |
+
forecast_change = ((predictions[-1] - last_price) / last_price) * 100
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
"success": True,
|
| 258 |
+
"data": {
|
| 259 |
+
"commodity": commodity_type,
|
| 260 |
+
"location": location,
|
| 261 |
+
"last_price": round(last_price, 2),
|
| 262 |
+
"last_date": last_date.strftime('%Y-%m-%d'),
|
| 263 |
+
"forecast_period_days": forecast_days,
|
| 264 |
+
"forecasts": [
|
| 265 |
+
{
|
| 266 |
+
"date": date,
|
| 267 |
+
"predicted_price": pred,
|
| 268 |
+
"lower_bound": lb,
|
| 269 |
+
"upper_bound": ub,
|
| 270 |
+
"confidence_level": 0.95
|
| 271 |
+
}
|
| 272 |
+
for date, pred, lb, ub in zip(
|
| 273 |
+
forecast_dates, predictions, lower_bounds, upper_bounds
|
| 274 |
+
)
|
| 275 |
+
],
|
| 276 |
+
"summary": {
|
| 277 |
+
"average_forecast": round(avg_forecast, 2),
|
| 278 |
+
"forecast_change_percent": round(forecast_change, 2),
|
| 279 |
+
"trend": "bullish" if forecast_change > 2 else "bearish" if forecast_change < -2 else "neutral",
|
| 280 |
+
"volatility": round(daily_volatility * 100, 2),
|
| 281 |
+
"model_type": "lightgbm" if self.is_trained else "statistical"
|
| 282 |
+
},
|
| 283 |
+
"feature_importance": self._get_feature_importance(feature_names) if self.is_trained else None
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
except Exception as e:
|
| 288 |
+
logger.error(f"Forecast error: {e}")
|
| 289 |
+
return {
|
| 290 |
+
"success": False,
|
| 291 |
+
"error": str(e)
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
def _get_seasonal_factor(self, month: int) -> float:
|
| 295 |
+
"""Get seasonal adjustment factor based on month"""
|
| 296 |
+
# Oilseed prices typically higher during off-season
|
| 297 |
+
seasonal_factors = {
|
| 298 |
+
1: 0.02, 2: 0.03, 3: 0.02, 4: 0.01, # Post-harvest
|
| 299 |
+
5: 0.02, 6: 0.03, 7: 0.04, 8: 0.05, # Pre-harvest (higher)
|
| 300 |
+
9: 0.03, 10: -0.02, 11: -0.03, 12: -0.02 # Harvest (lower)
|
| 301 |
+
}
|
| 302 |
+
return seasonal_factors.get(month, 0)
|
| 303 |
+
|
| 304 |
+
def _get_feature_importance(self, feature_names: List[str]) -> Dict[str, float]:
|
| 305 |
+
"""Get feature importance from trained model"""
|
| 306 |
+
if self.model is None or not hasattr(self.model, 'feature_importances_'):
|
| 307 |
+
return None
|
| 308 |
+
|
| 309 |
+
importances = self.model.feature_importances_
|
| 310 |
+
return {
|
| 311 |
+
name: round(float(imp), 4)
|
| 312 |
+
for name, imp in zip(feature_names, importances)
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# Global model instance
|
| 317 |
+
_forecast_model = None
|
| 318 |
+
|
| 319 |
+
def get_forecast_model() -> PriceForecastModel:
|
| 320 |
+
"""Get or create the price forecast model instance"""
|
| 321 |
+
global _forecast_model
|
| 322 |
+
if _forecast_model is None:
|
| 323 |
+
_forecast_model = PriceForecastModel()
|
| 324 |
+
return _forecast_model
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def forecast_prices(
|
| 328 |
+
historical_prices: List[Dict],
|
| 329 |
+
location: Optional[Dict] = None,
|
| 330 |
+
commodity_type: str = "oilseed",
|
| 331 |
+
global_indices: Optional[Dict] = None,
|
| 332 |
+
forecast_days: int = 30
|
| 333 |
+
) -> Dict[str, Any]:
|
| 334 |
+
"""
|
| 335 |
+
Main entry point for price forecasting
|
| 336 |
+
|
| 337 |
+
Args:
|
| 338 |
+
historical_prices: List of {date, price, volume} dicts
|
| 339 |
+
location: {lat, lng, state, district}
|
| 340 |
+
commodity_type: Type of commodity
|
| 341 |
+
global_indices: Global market indices
|
| 342 |
+
forecast_days: Number of days to forecast (7, 30, or 90)
|
| 343 |
+
|
| 344 |
+
Returns:
|
| 345 |
+
Forecast results with predictions and confidence intervals
|
| 346 |
+
"""
|
| 347 |
+
model = get_forecast_model()
|
| 348 |
+
return model.forecast(
|
| 349 |
+
historical_prices=historical_prices,
|
| 350 |
+
location=location,
|
| 351 |
+
commodity_type=commodity_type,
|
| 352 |
+
global_indices=global_indices,
|
| 353 |
+
forecast_days=forecast_days
|
| 354 |
+
)
|
ai-backend/model/.gitkeep
ADDED
|
File without changes
|
ai-backend/model_utils.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""model_utils.py
|
| 2 |
+
|
| 3 |
+
Utilities for loading the image classifier and running inference.
|
| 4 |
+
Downloads model weights from Hugging Face Hub at runtime.
|
| 5 |
+
Supports the NFNet-F1 (safetensors) and MobileNetV2 (.pth) flows.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
from torchvision import models, transforms
|
| 15 |
+
from PIL import Image
|
| 16 |
+
from huggingface_hub import hf_hub_download
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# HF repo identifiers
|
| 21 |
+
HF_REPO_NFNET = os.environ.get(
|
| 22 |
+
"HF_REPO_NFNET", "Arko007/nfnet-f1-plant-disease"
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Default assumptions (can be overridden by model config.json)
|
| 26 |
+
IMAGENET_MEAN = [0.485, 0.456, 0.406]
|
| 27 |
+
IMAGENET_STD = [0.229, 0.224, 0.225]
|
| 28 |
+
INPUT_SIZE = 224
|
| 29 |
+
|
| 30 |
+
# Preprocessing transform (resize -> center crop -> to tensor -> normalize)
|
| 31 |
+
transform = transforms.Compose([
|
| 32 |
+
transforms.Resize(256),
|
| 33 |
+
transforms.CenterCrop(INPUT_SIZE),
|
| 34 |
+
transforms.ToTensor(),
|
| 35 |
+
transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD)
|
| 36 |
+
])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _download(repo_id, filename):
|
| 40 |
+
"""Download a file from HF Hub with caching."""
|
| 41 |
+
logger.info("Downloading %s from %s ...", filename, repo_id)
|
| 42 |
+
return hf_hub_download(repo_id=repo_id, filename=filename)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def load_labels(path):
|
| 46 |
+
"""Load labels from a text file (one label per line) or return empty list."""
|
| 47 |
+
if path is None:
|
| 48 |
+
return []
|
| 49 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 50 |
+
return [line.strip() for line in f if line.strip()]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_remedies(path):
|
| 54 |
+
"""Load remedies JSON or return empty dict."""
|
| 55 |
+
if path is None:
|
| 56 |
+
return {}
|
| 57 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 58 |
+
return json.load(f)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def build_timm_model_from_config(config, checkpoint_path, device):
|
| 62 |
+
try:
|
| 63 |
+
import timm
|
| 64 |
+
except Exception as e:
|
| 65 |
+
raise ImportError(
|
| 66 |
+
"timm is required to load timm models: pip install timm"
|
| 67 |
+
) from e
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
from safetensors.torch import load_file as load_safetensors
|
| 71 |
+
except Exception as e:
|
| 72 |
+
raise ImportError(
|
| 73 |
+
"safetensors is required: pip install safetensors"
|
| 74 |
+
) from e
|
| 75 |
+
|
| 76 |
+
class_names = config.get("class_names") or config.get("labels")
|
| 77 |
+
if class_names is None:
|
| 78 |
+
raise ValueError("config.json must contain 'class_names' list")
|
| 79 |
+
|
| 80 |
+
model = timm.create_model(
|
| 81 |
+
config["architecture"], pretrained=False, num_classes=len(class_names)
|
| 82 |
+
)
|
| 83 |
+
state_dict = load_safetensors(checkpoint_path)
|
| 84 |
+
model.load_state_dict(state_dict)
|
| 85 |
+
model.to(device)
|
| 86 |
+
model.eval()
|
| 87 |
+
return model
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def load_model_from_hf(device):
|
| 91 |
+
"""
|
| 92 |
+
Download and load the plant-disease model from Hugging Face Hub.
|
| 93 |
+
|
| 94 |
+
Loads the NFNet-F1 model (safetensors).
|
| 95 |
+
|
| 96 |
+
Returns: model, labels, remedies
|
| 97 |
+
"""
|
| 98 |
+
global transform
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
st_path = _download(HF_REPO_NFNET, "model.safetensors")
|
| 102 |
+
cfg_path = _download(HF_REPO_NFNET, "config.json")
|
| 103 |
+
|
| 104 |
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
| 105 |
+
config = json.load(f)
|
| 106 |
+
|
| 107 |
+
labels = config.get("class_names", [])
|
| 108 |
+
|
| 109 |
+
# Download labels.txt optionally (if available)
|
| 110 |
+
try:
|
| 111 |
+
labels_path = _download(HF_REPO_NFNET, "labels.txt")
|
| 112 |
+
if os.path.exists(labels_path):
|
| 113 |
+
file_labels = load_labels(labels_path)
|
| 114 |
+
if len(file_labels) == len(labels):
|
| 115 |
+
labels = file_labels
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.warning("Optional labels.txt not found or could not be loaded: %s", e)
|
| 118 |
+
|
| 119 |
+
# Download remedies.json optionally (if available)
|
| 120 |
+
remedies = {}
|
| 121 |
+
try:
|
| 122 |
+
remedies_path = _download(HF_REPO_NFNET, "remedies.json")
|
| 123 |
+
remedies = load_remedies(remedies_path)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.warning("Optional remedies.json not found or could not be loaded: %s", e)
|
| 126 |
+
|
| 127 |
+
img_size = config.get("input_size", INPUT_SIZE)
|
| 128 |
+
mean = config.get("normalization", {}).get("mean", IMAGENET_MEAN)
|
| 129 |
+
std = config.get("normalization", {}).get("std", IMAGENET_STD)
|
| 130 |
+
transform = transforms.Compose([
|
| 131 |
+
transforms.Resize((img_size, img_size)),
|
| 132 |
+
transforms.ToTensor(),
|
| 133 |
+
transforms.Normalize(mean=mean, std=std),
|
| 134 |
+
])
|
| 135 |
+
|
| 136 |
+
model = build_timm_model_from_config(config, st_path, device)
|
| 137 |
+
logger.info("Loaded NFNet-F1 model from HF Hub (%s)", HF_REPO_NFNET)
|
| 138 |
+
return model, labels, remedies
|
| 139 |
+
except Exception as e:
|
| 140 |
+
raise RuntimeError(f"Failed to load NFNet-F1 model from HF Hub ({HF_REPO_NFNET}): {e}") from e
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def predict(model, pil_image, labels, device, topk=3, crop_filter=None):
|
| 144 |
+
"""Return top-1 label, confidence, and topk list of (label, prob)."""
|
| 145 |
+
img_t = transform(pil_image).unsqueeze(0).to(device)
|
| 146 |
+
with torch.no_grad():
|
| 147 |
+
outputs = model(img_t)
|
| 148 |
+
probs = F.softmax(outputs, dim=1)
|
| 149 |
+
|
| 150 |
+
# Apply crop filter if provided
|
| 151 |
+
if crop_filter:
|
| 152 |
+
mask = torch.zeros_like(probs)
|
| 153 |
+
for item in crop_filter:
|
| 154 |
+
if item in labels:
|
| 155 |
+
idx = labels.index(item)
|
| 156 |
+
mask[0, idx] = 1.0
|
| 157 |
+
|
| 158 |
+
filtered_probs = probs * mask
|
| 159 |
+
sum_probs = filtered_probs.sum(dim=1, keepdim=True)
|
| 160 |
+
if sum_probs.item() > 0:
|
| 161 |
+
probs = filtered_probs / sum_probs
|
| 162 |
+
|
| 163 |
+
actual_topk = min(topk, len(crop_filter)) if crop_filter else topk
|
| 164 |
+
top_probs, top_idxs = probs.topk(actual_topk, dim=1)
|
| 165 |
+
top_probs = top_probs.cpu().numpy()[0]
|
| 166 |
+
top_idxs = top_idxs.cpu().numpy()[0]
|
| 167 |
+
top_labels = [labels[i] for i in top_idxs]
|
| 168 |
+
return top_labels[0], float(top_probs[0]), list(zip(top_labels, top_probs.tolist()))
|
| 169 |
+
|
ai-backend/price-forecast/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Price Forecast Module
|
ai-backend/requirements.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask==3.1.3
|
| 2 |
+
pillow==12.3.0
|
| 3 |
+
numpy<2,>=1.26.4
|
| 4 |
+
|
| 5 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 6 |
+
torch
|
| 7 |
+
torchvision
|
| 8 |
+
torchaudio
|
| 9 |
+
|
| 10 |
+
huggingface_hub==1.27.0
|
| 11 |
+
|
| 12 |
+
# optional for NFNet-F1 safetensors model
|
| 13 |
+
timm==1.0.28
|
| 14 |
+
safetensors==0.8.0
|
| 15 |
+
|
| 16 |
+
flask-cors==6.0.5
|
| 17 |
+
pandas==2.3.3
|
| 18 |
+
scikit-learn==1.5.1
|
| 19 |
+
joblib==1.5.3
|
| 20 |
+
lightgbm==4.7.0
|
| 21 |
+
gunicorn==26.0.0
|
| 22 |
+
|
| 23 |
+
# Retry and testing utilities
|
| 24 |
+
tenacity==9.1.4
|
| 25 |
+
pytest==9.1.1
|
| 26 |
+
pytest-cov==7.1.0
|
| 27 |
+
pytest-mock==3.15.1
|
| 28 |
+
httpx==0.28.1
|
| 29 |
+
|
| 30 |
+
# YOLO harvest readiness model
|
| 31 |
+
ultralytics==8.4.116
|
| 32 |
+
|
| 33 |
+
# HTTP client for HF Inference API fallback
|
| 34 |
+
requests==2.34.2
|
ai-backend/src/__init__.py
ADDED
|
File without changes
|
ai-backend/src/error_handlers.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized error handlers for Flask application.
|
| 2 |
+
|
| 3 |
+
Provides consistent JSON error responses and logging for all exceptions.
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
from flask import Flask, jsonify
|
| 7 |
+
from werkzeug.exceptions import HTTPException
|
| 8 |
+
|
| 9 |
+
from src.logging_config import log_exception
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger('ai_backend.error_handlers')
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def register_error_handlers(app: Flask) -> None:
|
| 15 |
+
"""Register centralized error handlers for the Flask app.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
app: Flask application instance
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
@app.errorhandler(400)
|
| 22 |
+
def bad_request(error):
|
| 23 |
+
"""Handle 400 Bad Request errors."""
|
| 24 |
+
logger.warning(f"Bad request: {error}")
|
| 25 |
+
return jsonify({
|
| 26 |
+
"error": "Bad Request",
|
| 27 |
+
"message": str(error.description) if hasattr(error, 'description') else str(error),
|
| 28 |
+
"status": 400
|
| 29 |
+
}), 400
|
| 30 |
+
|
| 31 |
+
@app.errorhandler(404)
|
| 32 |
+
def not_found(error):
|
| 33 |
+
"""Handle 404 Not Found errors."""
|
| 34 |
+
logger.warning(f"Not found: {error}")
|
| 35 |
+
return jsonify({
|
| 36 |
+
"error": "Not Found",
|
| 37 |
+
"message": "The requested resource was not found",
|
| 38 |
+
"status": 404
|
| 39 |
+
}), 404
|
| 40 |
+
|
| 41 |
+
@app.errorhandler(500)
|
| 42 |
+
def internal_server_error(error):
|
| 43 |
+
"""Handle 500 Internal Server Error."""
|
| 44 |
+
log_exception(logger, error, "Internal server error")
|
| 45 |
+
return jsonify({
|
| 46 |
+
"error": "Internal Server Error",
|
| 47 |
+
"message": "An unexpected error occurred. Please try again later.",
|
| 48 |
+
"status": 500
|
| 49 |
+
}), 500
|
| 50 |
+
|
| 51 |
+
@app.errorhandler(HTTPException)
|
| 52 |
+
def handle_http_exception(error):
|
| 53 |
+
"""Handle all HTTP exceptions."""
|
| 54 |
+
logger.warning(f"HTTP exception {error.code}: {error.description}")
|
| 55 |
+
return jsonify({
|
| 56 |
+
"error": error.name,
|
| 57 |
+
"message": error.description,
|
| 58 |
+
"status": error.code
|
| 59 |
+
}), error.code
|
| 60 |
+
|
| 61 |
+
@app.errorhandler(Exception)
|
| 62 |
+
def handle_unexpected_error(error):
|
| 63 |
+
"""Catch-all handler for unexpected exceptions.
|
| 64 |
+
|
| 65 |
+
This prevents unhandled exceptions from returning raw 502 errors.
|
| 66 |
+
"""
|
| 67 |
+
log_exception(logger, error, "Unexpected error")
|
| 68 |
+
|
| 69 |
+
# Never expose internal error details to clients in production
|
| 70 |
+
return jsonify({
|
| 71 |
+
"error": "Internal Server Error",
|
| 72 |
+
"message": "An unexpected error occurred. Please try again later.",
|
| 73 |
+
"status": 500
|
| 74 |
+
}), 500
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def validate_content_type(request, expected='application/json'):
|
| 78 |
+
"""Validate request Content-Type header.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
request: Flask request object
|
| 82 |
+
expected: Expected content type (default: 'application/json')
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
tuple: (is_valid: bool, error_response: dict or None)
|
| 86 |
+
"""
|
| 87 |
+
content_type = request.content_type
|
| 88 |
+
if not content_type or expected not in content_type:
|
| 89 |
+
return False, {
|
| 90 |
+
"error": "Invalid Content-Type",
|
| 91 |
+
"message": f"Expected Content-Type: {expected}",
|
| 92 |
+
"status": 400
|
| 93 |
+
}
|
| 94 |
+
return True, None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def validate_json_payload(request, required_fields=None):
|
| 98 |
+
"""Validate JSON payload and required fields.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
request: Flask request object
|
| 102 |
+
required_fields: List of required field names (optional)
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
tuple: (is_valid: bool, data_or_error: dict)
|
| 106 |
+
"""
|
| 107 |
+
try:
|
| 108 |
+
data = request.get_json(force=False)
|
| 109 |
+
if data is None:
|
| 110 |
+
return False, {
|
| 111 |
+
"error": "Invalid JSON",
|
| 112 |
+
"message": "Request body must be valid JSON",
|
| 113 |
+
"status": 400
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
if required_fields:
|
| 117 |
+
missing = [f for f in required_fields if f not in data]
|
| 118 |
+
if missing:
|
| 119 |
+
return False, {
|
| 120 |
+
"error": "Missing required fields",
|
| 121 |
+
"message": f"Missing fields: {', '.join(missing)}",
|
| 122 |
+
"status": 400
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return True, data
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.warning(f"JSON parsing error: {e}")
|
| 128 |
+
return False, {
|
| 129 |
+
"error": "Invalid JSON",
|
| 130 |
+
"message": "Failed to parse JSON payload",
|
| 131 |
+
"status": 400
|
| 132 |
+
}
|
ai-backend/src/logging_config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured logging configuration for AI Backend.
|
| 2 |
+
|
| 3 |
+
Provides consistent logging format with stack traces for debugging.
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import sys
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def setup_logging(
|
| 11 |
+
level: int = logging.INFO,
|
| 12 |
+
format_string: Optional[str] = None
|
| 13 |
+
) -> logging.Logger:
|
| 14 |
+
"""Configure structured logging for the application.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
level: Logging level (default: INFO)
|
| 18 |
+
format_string: Custom format string (optional)
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
Configured logger instance
|
| 22 |
+
"""
|
| 23 |
+
if format_string is None:
|
| 24 |
+
format_string = (
|
| 25 |
+
'%(asctime)s - %(name)s - %(levelname)s - '
|
| 26 |
+
'%(funcName)s:%(lineno)d - %(message)s'
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# Configure root logger
|
| 30 |
+
logging.basicConfig(
|
| 31 |
+
level=level,
|
| 32 |
+
format=format_string,
|
| 33 |
+
handlers=[
|
| 34 |
+
logging.StreamHandler(sys.stdout)
|
| 35 |
+
],
|
| 36 |
+
force=True # Override any existing configuration
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger('ai_backend')
|
| 40 |
+
logger.setLevel(level)
|
| 41 |
+
|
| 42 |
+
return logger
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def log_exception(logger: logging.Logger, exc: Exception, context: str = ""):
|
| 46 |
+
"""Log an exception with full stack trace.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
logger: Logger instance to use
|
| 50 |
+
exc: Exception to log
|
| 51 |
+
context: Additional context about where the exception occurred
|
| 52 |
+
"""
|
| 53 |
+
if context:
|
| 54 |
+
logger.error(f"{context}: {type(exc).__name__}: {str(exc)}", exc_info=True)
|
| 55 |
+
else:
|
| 56 |
+
logger.error(f"{type(exc).__name__}: {str(exc)}", exc_info=True)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# Module-level logger is intentionally NOT created here to avoid
|
| 60 |
+
# double-initializing the root logger before app.py calls setup_logging().
|
ai-backend/src/models/__init__.py
ADDED
|
File without changes
|
ai-backend/src/models/manager.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model manager for lazy loading and caching ML models.
|
| 2 |
+
|
| 3 |
+
Centralizes model loading logic and ensures models are loaded only once
|
| 4 |
+
at startup with proper error handling and logging.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import pickle
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Dict, Any, Optional, Tuple
|
| 10 |
+
import torch
|
| 11 |
+
import joblib
|
| 12 |
+
import numpy as np
|
| 13 |
+
from huggingface_hub import hf_hub_download
|
| 14 |
+
|
| 15 |
+
from src.logging_config import log_exception
|
| 16 |
+
from src.utils.retry_utils import retry_with_backoff
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger('ai_backend.model_manager')
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Global model cache
|
| 22 |
+
_model_cache: Dict[str, Any] = {}
|
| 23 |
+
_device: Optional[torch.device] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _is_numpy_binary_compat_error(exc: Exception) -> bool:
|
| 27 |
+
"""Detect common NumPy 2.x vs SciPy/sklearn binary compatibility errors."""
|
| 28 |
+
error_text = f"{type(exc).__name__}: {exc}".lower()
|
| 29 |
+
markers = (
|
| 30 |
+
"numpy.core.multiarray failed to import",
|
| 31 |
+
"_array_api not found",
|
| 32 |
+
"compiled using numpy 1",
|
| 33 |
+
"a numpy version >=",
|
| 34 |
+
"node array from the pickle has an incompatible dtype",
|
| 35 |
+
)
|
| 36 |
+
return any(marker in error_text for marker in markers)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_device() -> torch.device:
|
| 40 |
+
"""Get the torch device (CPU or CUDA).
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
torch.device instance
|
| 44 |
+
"""
|
| 45 |
+
global _device
|
| 46 |
+
if _device is None:
|
| 47 |
+
_device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 48 |
+
logger.info(f"Using device: {_device}")
|
| 49 |
+
return _device
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@retry_with_backoff(max_attempts=3, wait_min=2.0, wait_max=10.0)
|
| 53 |
+
def _download_from_hf(repo_id: str, filename: str) -> str:
|
| 54 |
+
"""Download a file from Hugging Face Hub with retry.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
repo_id: Hugging Face repository ID
|
| 58 |
+
filename: File to download
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
Path to downloaded file
|
| 62 |
+
"""
|
| 63 |
+
logger.info(f"Downloading {filename} from {repo_id}...")
|
| 64 |
+
return hf_hub_download(repo_id=repo_id, filename=filename)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def load_disease_model() -> Tuple[Any, list, dict]:
|
| 68 |
+
"""Load the disease detection model from HF Hub.
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
Tuple of (model, labels, remedies)
|
| 72 |
+
"""
|
| 73 |
+
# Import model_utils which has the loading logic
|
| 74 |
+
from model_utils import load_model_from_hf
|
| 75 |
+
|
| 76 |
+
device = get_device()
|
| 77 |
+
model, labels, remedies = load_model_from_hf(device)
|
| 78 |
+
logger.info(f"Disease model loaded — {len(labels)} labels")
|
| 79 |
+
return model, labels, remedies
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def load_crop_recommendation_models() -> Dict[str, Any]:
|
| 83 |
+
"""Load crop recommendation models from HF Hub.
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Dictionary containing model, StandardScaler, and MinMaxScaler
|
| 87 |
+
"""
|
| 88 |
+
repo_id = os.environ.get("HF_REPO_CROP", "Arko007/agromind-crop-recommendation")
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
model = joblib.load(_download_from_hf(repo_id, "crop_predict_model.pkl"))
|
| 92 |
+
standard_scaler = joblib.load(_download_from_hf(repo_id, "crop_predict_standscaler.pkl"))
|
| 93 |
+
minmax_scaler = joblib.load(_download_from_hf(repo_id, "crop_predict_minmaxscaler.pkl"))
|
| 94 |
+
logger.info("Crop recommendation models loaded from HF Hub")
|
| 95 |
+
except Exception as exc:
|
| 96 |
+
if _is_numpy_binary_compat_error(exc):
|
| 97 |
+
raise RuntimeError(
|
| 98 |
+
"Crop recommendation models failed to load due to NumPy/SciPy binary compatibility. "
|
| 99 |
+
"Ensure scikit-learn==1.5.1 and numpy<2 are installed (matching training environment)."
|
| 100 |
+
) from exc
|
| 101 |
+
raise
|
| 102 |
+
|
| 103 |
+
return {
|
| 104 |
+
"model": model,
|
| 105 |
+
"standard_scaler": standard_scaler,
|
| 106 |
+
"minmax_scaler": minmax_scaler
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def load_fertilizer_models() -> Dict[str, Any]:
|
| 111 |
+
"""Load fertilizer prediction models from HF Hub.
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
Dictionary containing classifier and label_encoder
|
| 115 |
+
"""
|
| 116 |
+
repo_id = os.environ.get("HF_REPO_FERTILIZER", "Arko007/agromind-fertilizer-prediction")
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
with open(_download_from_hf(repo_id, "classifier.pkl"), "rb") as f:
|
| 120 |
+
classifier = pickle.load(f)
|
| 121 |
+
with open(_download_from_hf(repo_id, "fertilizer.pkl"), "rb") as f:
|
| 122 |
+
label_encoder = pickle.load(f)
|
| 123 |
+
logger.info("Fertilizer prediction models loaded from HF Hub")
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
if _is_numpy_binary_compat_error(exc):
|
| 126 |
+
raise RuntimeError(
|
| 127 |
+
"Fertilizer models failed to load due to NumPy/SciPy binary compatibility. "
|
| 128 |
+
"Ensure scikit-learn==1.5.1 and numpy<2 are installed (matching training environment)."
|
| 129 |
+
) from exc
|
| 130 |
+
raise
|
| 131 |
+
|
| 132 |
+
return {
|
| 133 |
+
"classifier": classifier,
|
| 134 |
+
"label_encoder": label_encoder
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def load_loan_models() -> Dict[str, Any]:
|
| 139 |
+
"""Load loan prediction models from HF Hub.
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Dictionary containing price_model and approval_model
|
| 143 |
+
"""
|
| 144 |
+
repo_id = os.environ.get("HF_REPO_LOAN", "Arko007/agromind-loan-prediction")
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
price_model = joblib.load(_download_from_hf(repo_id, "price_model.pkl"))
|
| 148 |
+
approval_model = joblib.load(_download_from_hf(repo_id, "approval_model.pkl"))
|
| 149 |
+
logger.info("Loan prediction models loaded from HF Hub")
|
| 150 |
+
except Exception as exc:
|
| 151 |
+
if _is_numpy_binary_compat_error(exc):
|
| 152 |
+
raise RuntimeError(
|
| 153 |
+
"Loan models failed to load due to NumPy/SciPy binary compatibility. "
|
| 154 |
+
"Ensure scikit-learn==1.5.1 and numpy<2 are installed (matching training environment)."
|
| 155 |
+
) from exc
|
| 156 |
+
raise
|
| 157 |
+
|
| 158 |
+
return {
|
| 159 |
+
"price_model": price_model,
|
| 160 |
+
"approval_model": approval_model
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def initialize_models(load_all: bool = True) -> None:
|
| 165 |
+
"""Initialize all models at startup.
|
| 166 |
+
|
| 167 |
+
This function should be called during application startup to load
|
| 168 |
+
all models into the cache. Models that fail to load will be logged
|
| 169 |
+
but won't crash the application.
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
load_all: If True, attempts to load all models. If False, only loads on-demand.
|
| 173 |
+
"""
|
| 174 |
+
if not load_all:
|
| 175 |
+
logger.info("Model lazy-loading enabled. Models will load on first use.")
|
| 176 |
+
return
|
| 177 |
+
|
| 178 |
+
logger.info("Initializing all models...")
|
| 179 |
+
|
| 180 |
+
# Load disease model
|
| 181 |
+
try:
|
| 182 |
+
model, labels, remedies = load_disease_model()
|
| 183 |
+
_model_cache['disease_model'] = model
|
| 184 |
+
_model_cache['disease_labels'] = labels
|
| 185 |
+
_model_cache['disease_remedies'] = remedies
|
| 186 |
+
except Exception as e:
|
| 187 |
+
log_exception(logger, e, "Failed to load disease model")
|
| 188 |
+
_model_cache['disease_model'] = None
|
| 189 |
+
_model_cache['disease_labels'] = []
|
| 190 |
+
_model_cache['disease_remedies'] = {}
|
| 191 |
+
|
| 192 |
+
# Load crop recommendation models
|
| 193 |
+
try:
|
| 194 |
+
crop_models = load_crop_recommendation_models()
|
| 195 |
+
_model_cache['crop_model'] = crop_models['model']
|
| 196 |
+
_model_cache['crop_standard_scaler'] = crop_models['standard_scaler']
|
| 197 |
+
_model_cache['crop_minmax_scaler'] = crop_models['minmax_scaler']
|
| 198 |
+
except Exception as e:
|
| 199 |
+
log_exception(logger, e, "Failed to load crop recommendation models")
|
| 200 |
+
_model_cache['crop_model'] = None
|
| 201 |
+
_model_cache['crop_standard_scaler'] = None
|
| 202 |
+
_model_cache['crop_minmax_scaler'] = None
|
| 203 |
+
|
| 204 |
+
# Load fertilizer models
|
| 205 |
+
try:
|
| 206 |
+
fertilizer_models = load_fertilizer_models()
|
| 207 |
+
_model_cache['fertilizer_classifier'] = fertilizer_models['classifier']
|
| 208 |
+
_model_cache['fertilizer_label_encoder'] = fertilizer_models['label_encoder']
|
| 209 |
+
except Exception as e:
|
| 210 |
+
log_exception(logger, e, "Failed to load fertilizer models")
|
| 211 |
+
_model_cache['fertilizer_classifier'] = None
|
| 212 |
+
_model_cache['fertilizer_label_encoder'] = None
|
| 213 |
+
|
| 214 |
+
# Load loan models
|
| 215 |
+
try:
|
| 216 |
+
loan_models = load_loan_models()
|
| 217 |
+
_model_cache['loan_price_model'] = loan_models['price_model']
|
| 218 |
+
_model_cache['loan_approval_model'] = loan_models['approval_model']
|
| 219 |
+
except Exception as e:
|
| 220 |
+
log_exception(logger, e, "Failed to load loan models")
|
| 221 |
+
_model_cache['loan_price_model'] = None
|
| 222 |
+
_model_cache['loan_approval_model'] = None
|
| 223 |
+
|
| 224 |
+
logger.info("Model initialization complete")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def get_model(model_name: str, auto_load: bool = True) -> Optional[Any]:
|
| 228 |
+
"""Get a model from the cache.
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
model_name: Name of the model to retrieve
|
| 232 |
+
auto_load: If True and model not in cache, attempt to load it
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
Model instance or None if not available
|
| 236 |
+
"""
|
| 237 |
+
if model_name in _model_cache:
|
| 238 |
+
return _model_cache[model_name]
|
| 239 |
+
|
| 240 |
+
if not auto_load:
|
| 241 |
+
return None
|
| 242 |
+
|
| 243 |
+
# Attempt to load on-demand
|
| 244 |
+
logger.info(f"Model '{model_name}' not in cache, loading on-demand...")
|
| 245 |
+
|
| 246 |
+
try:
|
| 247 |
+
if model_name == 'disease_model':
|
| 248 |
+
model, labels, remedies = load_disease_model()
|
| 249 |
+
_model_cache['disease_model'] = model
|
| 250 |
+
_model_cache['disease_labels'] = labels
|
| 251 |
+
_model_cache['disease_remedies'] = remedies
|
| 252 |
+
return model
|
| 253 |
+
elif 'crop' in model_name:
|
| 254 |
+
crop_models = load_crop_recommendation_models()
|
| 255 |
+
_model_cache['crop_model'] = crop_models['model']
|
| 256 |
+
_model_cache['crop_standard_scaler'] = crop_models['standard_scaler']
|
| 257 |
+
_model_cache['crop_minmax_scaler'] = crop_models['minmax_scaler']
|
| 258 |
+
return _model_cache.get(model_name)
|
| 259 |
+
elif 'fertilizer' in model_name:
|
| 260 |
+
fertilizer_models = load_fertilizer_models()
|
| 261 |
+
_model_cache['fertilizer_classifier'] = fertilizer_models['classifier']
|
| 262 |
+
_model_cache['fertilizer_label_encoder'] = fertilizer_models['label_encoder']
|
| 263 |
+
return _model_cache.get(model_name)
|
| 264 |
+
elif 'loan' in model_name:
|
| 265 |
+
loan_models = load_loan_models()
|
| 266 |
+
_model_cache['loan_price_model'] = loan_models['price_model']
|
| 267 |
+
_model_cache['loan_approval_model'] = loan_models['approval_model']
|
| 268 |
+
return _model_cache.get(model_name)
|
| 269 |
+
except Exception as e:
|
| 270 |
+
log_exception(logger, e, f"Failed to load model '{model_name}'")
|
| 271 |
+
return None
|
| 272 |
+
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def predict_crop(features: np.ndarray) -> int:
|
| 277 |
+
"""Predict crop recommendation from features.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
features: NumPy array of shape (1, 7) with [N, P, K, temp, humidity, ph, rainfall]
|
| 281 |
+
|
| 282 |
+
Returns:
|
| 283 |
+
Predicted crop ID (integer)
|
| 284 |
+
|
| 285 |
+
Raises:
|
| 286 |
+
RuntimeError: If models are not loaded
|
| 287 |
+
"""
|
| 288 |
+
model = get_model('crop_model')
|
| 289 |
+
minmax_scaler = get_model('crop_minmax_scaler')
|
| 290 |
+
standard_scaler = get_model('crop_standard_scaler')
|
| 291 |
+
|
| 292 |
+
if model is None or minmax_scaler is None or standard_scaler is None:
|
| 293 |
+
raise RuntimeError("Crop recommendation models not loaded")
|
| 294 |
+
|
| 295 |
+
# Scale features
|
| 296 |
+
scaled_features = minmax_scaler.transform(features)
|
| 297 |
+
final_features = standard_scaler.transform(scaled_features)
|
| 298 |
+
|
| 299 |
+
# Make prediction
|
| 300 |
+
prediction = model.predict(final_features)
|
| 301 |
+
return int(prediction[0])
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def predict_fertilizer(features: np.ndarray) -> str:
|
| 305 |
+
"""Predict fertilizer recommendation from features.
|
| 306 |
+
|
| 307 |
+
Args:
|
| 308 |
+
features: NumPy array with soil and crop features
|
| 309 |
+
|
| 310 |
+
Returns:
|
| 311 |
+
Predicted fertilizer name (string)
|
| 312 |
+
|
| 313 |
+
Raises:
|
| 314 |
+
RuntimeError: If models are not loaded
|
| 315 |
+
"""
|
| 316 |
+
classifier = get_model('fertilizer_classifier')
|
| 317 |
+
label_encoder = get_model('fertilizer_label_encoder')
|
| 318 |
+
|
| 319 |
+
if classifier is None or label_encoder is None:
|
| 320 |
+
raise RuntimeError("Fertilizer prediction models not loaded")
|
| 321 |
+
|
| 322 |
+
# Make prediction
|
| 323 |
+
prediction = classifier.predict(features)
|
| 324 |
+
fertilizer = label_encoder.inverse_transform(prediction)
|
| 325 |
+
return str(fertilizer[0])
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def predict_disease(pil_image, topk: int = 3) -> Tuple[str, float, list]:
|
| 329 |
+
"""Predict disease from plant image.
|
| 330 |
+
|
| 331 |
+
Args:
|
| 332 |
+
pil_image: PIL Image object
|
| 333 |
+
topk: Number of top predictions to return
|
| 334 |
+
|
| 335 |
+
Returns:
|
| 336 |
+
Tuple of (top_label, confidence, top_k_predictions)
|
| 337 |
+
|
| 338 |
+
Raises:
|
| 339 |
+
RuntimeError: If model is not loaded
|
| 340 |
+
"""
|
| 341 |
+
from model_utils import predict
|
| 342 |
+
|
| 343 |
+
model = get_model('disease_model')
|
| 344 |
+
labels = get_model('disease_labels')
|
| 345 |
+
|
| 346 |
+
if model is None or not labels:
|
| 347 |
+
raise RuntimeError("Disease model not loaded")
|
| 348 |
+
|
| 349 |
+
device = get_device()
|
| 350 |
+
return predict(model, pil_image, labels, device, topk=topk)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def get_disease_remedy(label: str) -> Optional[str]:
|
| 354 |
+
"""Get remedy for a disease label.
|
| 355 |
+
|
| 356 |
+
Args:
|
| 357 |
+
label: Disease label
|
| 358 |
+
|
| 359 |
+
Returns:
|
| 360 |
+
Remedy string or None if not found
|
| 361 |
+
"""
|
| 362 |
+
remedies = get_model('disease_remedies')
|
| 363 |
+
if remedies is None:
|
| 364 |
+
return None
|
| 365 |
+
return remedies.get(label)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def is_model_loaded(model_name: str) -> bool:
|
| 369 |
+
"""Check if a model is loaded in the cache.
|
| 370 |
+
|
| 371 |
+
Args:
|
| 372 |
+
model_name: Name of the model to check
|
| 373 |
+
|
| 374 |
+
Returns:
|
| 375 |
+
True if model is loaded and not None, False otherwise
|
| 376 |
+
"""
|
| 377 |
+
return model_name in _model_cache and _model_cache[model_name] is not None
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def get_model_status() -> Dict[str, bool]:
|
| 381 |
+
"""Get the status of all models.
|
| 382 |
+
|
| 383 |
+
Returns:
|
| 384 |
+
Dictionary mapping model names to their loaded status
|
| 385 |
+
"""
|
| 386 |
+
return {
|
| 387 |
+
'disease_model': is_model_loaded('disease_model'),
|
| 388 |
+
'crop_model': is_model_loaded('crop_model'),
|
| 389 |
+
'fertilizer_classifier': is_model_loaded('fertilizer_classifier'),
|
| 390 |
+
'loan_price_model': is_model_loaded('loan_price_model'),
|
| 391 |
+
'loan_approval_model': is_model_loaded('loan_approval_model'),
|
| 392 |
+
'harvest_readiness_model': is_model_loaded('harvest_readiness_model'),
|
| 393 |
+
}
|
ai-backend/src/utils/__init__.py
ADDED
|
File without changes
|
ai-backend/src/utils/retry_utils.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retry utilities with exponential backoff.
|
| 2 |
+
|
| 3 |
+
Provides decorators for retrying operations with configurable backoff.
|
| 4 |
+
Uses tenacity library for robust retry logic.
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
from functools import wraps
|
| 8 |
+
from typing import Callable, Optional, Type, Tuple
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from tenacity import (
|
| 13 |
+
retry,
|
| 14 |
+
stop_after_attempt,
|
| 15 |
+
wait_exponential,
|
| 16 |
+
retry_if_exception_type,
|
| 17 |
+
before_sleep_log,
|
| 18 |
+
RetryError
|
| 19 |
+
)
|
| 20 |
+
HAS_TENACITY = True
|
| 21 |
+
except ImportError:
|
| 22 |
+
HAS_TENACITY = False
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger('ai_backend.retry_utils')
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def retry_with_backoff(
|
| 28 |
+
max_attempts: int = 3,
|
| 29 |
+
wait_min: float = 1.0,
|
| 30 |
+
wait_max: float = 10.0,
|
| 31 |
+
retry_on_exceptions: Optional[Tuple[Type[Exception], ...]] = None
|
| 32 |
+
) -> Callable:
|
| 33 |
+
"""Decorator to retry a function with exponential backoff.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
max_attempts: Maximum number of retry attempts (default: 3)
|
| 37 |
+
wait_min: Minimum wait time in seconds (default: 1.0)
|
| 38 |
+
wait_max: Maximum wait time in seconds (default: 10.0)
|
| 39 |
+
retry_on_exceptions: Tuple of exception types to retry on (default: all exceptions)
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Decorated function with retry logic
|
| 43 |
+
|
| 44 |
+
Example:
|
| 45 |
+
@retry_with_backoff(max_attempts=3, wait_min=1.0, wait_max=10.0)
|
| 46 |
+
def call_external_api():
|
| 47 |
+
response = requests.get("https://api.example.com/data")
|
| 48 |
+
response.raise_for_status()
|
| 49 |
+
return response.json()
|
| 50 |
+
"""
|
| 51 |
+
if HAS_TENACITY:
|
| 52 |
+
# Use tenacity for robust retry logic
|
| 53 |
+
if retry_on_exceptions:
|
| 54 |
+
retry_condition = retry_if_exception_type(retry_on_exceptions)
|
| 55 |
+
else:
|
| 56 |
+
retry_condition = retry_if_exception_type(Exception)
|
| 57 |
+
|
| 58 |
+
return retry(
|
| 59 |
+
stop=stop_after_attempt(max_attempts),
|
| 60 |
+
wait=wait_exponential(multiplier=wait_min, max=wait_max),
|
| 61 |
+
retry=retry_condition,
|
| 62 |
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
| 63 |
+
reraise=True
|
| 64 |
+
)
|
| 65 |
+
else:
|
| 66 |
+
# Fallback to simple retry logic if tenacity is not available
|
| 67 |
+
def decorator(func: Callable) -> Callable:
|
| 68 |
+
@wraps(func)
|
| 69 |
+
def wrapper(*args, **kwargs):
|
| 70 |
+
last_exception = None
|
| 71 |
+
for attempt in range(max_attempts):
|
| 72 |
+
try:
|
| 73 |
+
return func(*args, **kwargs)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
last_exception = e
|
| 76 |
+
if retry_on_exceptions and not isinstance(e, retry_on_exceptions):
|
| 77 |
+
# Don't retry if it's not a retryable exception
|
| 78 |
+
raise
|
| 79 |
+
|
| 80 |
+
if attempt < max_attempts - 1:
|
| 81 |
+
# Calculate wait time with exponential backoff
|
| 82 |
+
wait_time = min(wait_min * (2 ** attempt), wait_max)
|
| 83 |
+
logger.warning(
|
| 84 |
+
f"Attempt {attempt + 1}/{max_attempts} failed: {e}. "
|
| 85 |
+
f"Retrying in {wait_time:.1f}s..."
|
| 86 |
+
)
|
| 87 |
+
time.sleep(wait_time)
|
| 88 |
+
else:
|
| 89 |
+
logger.error(
|
| 90 |
+
f"All {max_attempts} attempts failed. Last error: {e}"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Raise the last exception if all attempts failed
|
| 94 |
+
if last_exception:
|
| 95 |
+
raise last_exception
|
| 96 |
+
|
| 97 |
+
return wrapper
|
| 98 |
+
return decorator
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def retry_model_inference(
|
| 102 |
+
max_attempts: int = 2,
|
| 103 |
+
wait_min: float = 0.5,
|
| 104 |
+
wait_max: float = 2.0
|
| 105 |
+
) -> Callable:
|
| 106 |
+
"""Specialized retry decorator for model inference operations.
|
| 107 |
+
|
| 108 |
+
Uses shorter wait times and fewer attempts since model inference
|
| 109 |
+
failures are typically not transient.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
max_attempts: Maximum number of retry attempts (default: 2)
|
| 113 |
+
wait_min: Minimum wait time in seconds (default: 0.5)
|
| 114 |
+
wait_max: Maximum wait time in seconds (default: 2.0)
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
Decorated function with retry logic
|
| 118 |
+
"""
|
| 119 |
+
return retry_with_backoff(
|
| 120 |
+
max_attempts=max_attempts,
|
| 121 |
+
wait_min=wait_min,
|
| 122 |
+
wait_max=wait_max,
|
| 123 |
+
retry_on_exceptions=(RuntimeError, OSError, IOError)
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def with_timeout(timeout_seconds: float) -> Callable:
|
| 128 |
+
"""Decorator to add timeout to a function.
|
| 129 |
+
|
| 130 |
+
Note: This is a simple implementation. For production use with true
|
| 131 |
+
timeouts on blocking operations, consider using concurrent.futures
|
| 132 |
+
or signal-based timeouts.
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
timeout_seconds: Maximum execution time in seconds
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Decorated function with timeout
|
| 139 |
+
"""
|
| 140 |
+
def decorator(func: Callable) -> Callable:
|
| 141 |
+
@wraps(func)
|
| 142 |
+
def wrapper(*args, **kwargs):
|
| 143 |
+
import signal
|
| 144 |
+
|
| 145 |
+
def timeout_handler(signum, frame):
|
| 146 |
+
raise TimeoutError(f"Function {func.__name__} timed out after {timeout_seconds}s")
|
| 147 |
+
|
| 148 |
+
# Set up signal handler (Unix-like systems only)
|
| 149 |
+
try:
|
| 150 |
+
signal.signal(signal.SIGALRM, timeout_handler)
|
| 151 |
+
signal.alarm(int(timeout_seconds))
|
| 152 |
+
try:
|
| 153 |
+
result = func(*args, **kwargs)
|
| 154 |
+
finally:
|
| 155 |
+
signal.alarm(0) # Cancel the alarm
|
| 156 |
+
return result
|
| 157 |
+
except AttributeError:
|
| 158 |
+
# SIGALRM not available (e.g., on Windows)
|
| 159 |
+
logger.warning(
|
| 160 |
+
f"Timeout decorator not supported on this platform for {func.__name__}"
|
| 161 |
+
)
|
| 162 |
+
return func(*args, **kwargs)
|
| 163 |
+
|
| 164 |
+
return wrapper
|
| 165 |
+
return decorator
|
ai-backend/tests/conftest.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pytest configuration and shared fixtures for AI Backend tests."""
|
| 2 |
+
import pytest
|
| 3 |
+
import sys
|
| 4 |
+
import os
|
| 5 |
+
from unittest.mock import MagicMock, Mock
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
# Add parent directory to path for imports
|
| 9 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def app():
|
| 14 |
+
"""Create Flask app for testing."""
|
| 15 |
+
# Import app here to avoid loading models during test collection
|
| 16 |
+
from app import app as flask_app
|
| 17 |
+
flask_app.config['TESTING'] = True
|
| 18 |
+
return flask_app
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture
|
| 22 |
+
def client(app):
|
| 23 |
+
"""Create test client for Flask app."""
|
| 24 |
+
return app.test_client()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest.fixture
|
| 28 |
+
def mock_disease_model():
|
| 29 |
+
"""Mock disease detection model."""
|
| 30 |
+
model = MagicMock()
|
| 31 |
+
labels = ["healthy", "bacterial_blight", "leaf_spot", "rust"]
|
| 32 |
+
remedies = {
|
| 33 |
+
"bacterial_blight": "Apply copper-based fungicide",
|
| 34 |
+
"leaf_spot": "Remove infected leaves, apply fungicide",
|
| 35 |
+
"rust": "Apply sulfur-based fungicide"
|
| 36 |
+
}
|
| 37 |
+
return model, labels, remedies
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.fixture
|
| 41 |
+
def mock_crop_model():
|
| 42 |
+
"""Mock crop recommendation model and scalers."""
|
| 43 |
+
model = MagicMock()
|
| 44 |
+
model.predict = MagicMock(return_value=np.array([1])) # Returns "Rice"
|
| 45 |
+
|
| 46 |
+
standard_scaler = MagicMock()
|
| 47 |
+
standard_scaler.transform = MagicMock(side_effect=lambda x: x)
|
| 48 |
+
|
| 49 |
+
minmax_scaler = MagicMock()
|
| 50 |
+
minmax_scaler.transform = MagicMock(side_effect=lambda x: x)
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
'model': model,
|
| 54 |
+
'standard_scaler': standard_scaler,
|
| 55 |
+
'minmax_scaler': minmax_scaler
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@pytest.fixture
|
| 60 |
+
def mock_fertilizer_model():
|
| 61 |
+
"""Mock fertilizer prediction model."""
|
| 62 |
+
classifier = MagicMock()
|
| 63 |
+
classifier.predict = MagicMock(return_value=np.array([0]))
|
| 64 |
+
|
| 65 |
+
label_encoder = MagicMock()
|
| 66 |
+
label_encoder.inverse_transform = MagicMock(return_value=np.array(["Urea"]))
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
'classifier': classifier,
|
| 70 |
+
'label_encoder': label_encoder
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@pytest.fixture
|
| 75 |
+
def mock_loan_models():
|
| 76 |
+
"""Mock loan prediction models."""
|
| 77 |
+
price_model = MagicMock()
|
| 78 |
+
price_model.predict = MagicMock(return_value=np.array([50000]))
|
| 79 |
+
|
| 80 |
+
approval_model = MagicMock()
|
| 81 |
+
approval_model.predict = MagicMock(return_value=np.array([1]))
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
'price_model': price_model,
|
| 85 |
+
'approval_model': approval_model
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.fixture
|
| 90 |
+
def sample_crop_input():
|
| 91 |
+
"""Sample input for crop recommendation."""
|
| 92 |
+
return {
|
| 93 |
+
"N": 50,
|
| 94 |
+
"P": 30,
|
| 95 |
+
"K": 40,
|
| 96 |
+
"temperature": 28,
|
| 97 |
+
"humidity": 65,
|
| 98 |
+
"ph": 6.5,
|
| 99 |
+
"rainfall": 200
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@pytest.fixture
|
| 104 |
+
def sample_fertilizer_input():
|
| 105 |
+
"""Sample input for fertilizer prediction."""
|
| 106 |
+
return {
|
| 107 |
+
"temperature": 28,
|
| 108 |
+
"humidity": 65,
|
| 109 |
+
"moisture": 45,
|
| 110 |
+
"soil_type": "Loamy",
|
| 111 |
+
"crop_type": "Wheat",
|
| 112 |
+
"nitrogen": 50,
|
| 113 |
+
"potassium": 40,
|
| 114 |
+
"phosphorus": 30
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@pytest.fixture
|
| 119 |
+
def sample_loan_input():
|
| 120 |
+
"""Sample input for loan prediction."""
|
| 121 |
+
return {
|
| 122 |
+
"area": 5.5,
|
| 123 |
+
"land_contour": "flat",
|
| 124 |
+
"distance_from_road": 2.0,
|
| 125 |
+
"soil_type": "loam",
|
| 126 |
+
"income": 150000,
|
| 127 |
+
"loan_request": 50000
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@pytest.fixture
|
| 132 |
+
def mock_pil_image():
|
| 133 |
+
"""Mock PIL Image for disease detection."""
|
| 134 |
+
try:
|
| 135 |
+
from PIL import Image
|
| 136 |
+
import io
|
| 137 |
+
# Create a simple 224x224 RGB image
|
| 138 |
+
img = Image.new('RGB', (224, 224), color='green')
|
| 139 |
+
return img
|
| 140 |
+
except ImportError:
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@pytest.fixture
|
| 145 |
+
def sample_price_forecast_input():
|
| 146 |
+
"""Sample input for price forecasting."""
|
| 147 |
+
return {
|
| 148 |
+
"commodity_type": "wheat",
|
| 149 |
+
"historical_prices": [
|
| 150 |
+
{"date": "2026-01-01", "price": 55, "volume": 1000},
|
| 151 |
+
{"date": "2026-01-02", "price": 56, "volume": 1000},
|
| 152 |
+
{"date": "2026-01-03", "price": 54, "volume": 1000},
|
| 153 |
+
{"date": "2026-01-04", "price": 57, "volume": 1000},
|
| 154 |
+
{"date": "2026-01-05", "price": 55, "volume": 1000}
|
| 155 |
+
],
|
| 156 |
+
"forecast_days": 7
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@pytest.fixture
|
| 161 |
+
def sample_yield_input():
|
| 162 |
+
"""Sample input for yield prediction."""
|
| 163 |
+
return {
|
| 164 |
+
"crop_type": "groundnut",
|
| 165 |
+
"area_hectares": 5,
|
| 166 |
+
"soil_data": {"nitrogen": 50, "phosphorus": 30, "potassium": 40, "ph": 6.5},
|
| 167 |
+
"weather_data": {"rainfall": 800, "temperature": 28, "humidity": 65}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@pytest.fixture
|
| 172 |
+
def sample_tariff_input():
|
| 173 |
+
"""Sample input for tariff simulation."""
|
| 174 |
+
return {
|
| 175 |
+
"tariff_pct": 45,
|
| 176 |
+
"period": "6_months",
|
| 177 |
+
"global_price_shock": 0
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@pytest.fixture(autouse=True)
|
| 182 |
+
def reset_model_cache():
|
| 183 |
+
"""Reset model cache before each test."""
|
| 184 |
+
from src.models import manager
|
| 185 |
+
manager._model_cache.clear()
|
| 186 |
+
yield
|
| 187 |
+
manager._model_cache.clear()
|
ai-backend/tests/test_api_integration.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Integration tests for API endpoints.
|
| 2 |
+
|
| 3 |
+
Tests all endpoints with various scenarios including:
|
| 4 |
+
- Happy path with valid inputs
|
| 5 |
+
- Invalid inputs and validation
|
| 6 |
+
- Error handling and retry logic
|
| 7 |
+
- Malformed JSON payloads
|
| 8 |
+
"""
|
| 9 |
+
import pytest
|
| 10 |
+
import json
|
| 11 |
+
import io
|
| 12 |
+
from unittest.mock import patch, MagicMock
|
| 13 |
+
import sys
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestHealthEndpoint:
|
| 20 |
+
"""Test health check endpoint."""
|
| 21 |
+
|
| 22 |
+
def test_health_endpoint_returns_200(self, client):
|
| 23 |
+
"""Health endpoint should return 200 OK."""
|
| 24 |
+
response = client.get('/health')
|
| 25 |
+
assert response.status_code == 200
|
| 26 |
+
data = response.get_json()
|
| 27 |
+
assert data['status'] == 'ok'
|
| 28 |
+
|
| 29 |
+
def test_root_endpoint_returns_200(self, client):
|
| 30 |
+
"""Root endpoint should return welcome message."""
|
| 31 |
+
response = client.get('/')
|
| 32 |
+
assert response.status_code == 200
|
| 33 |
+
data = response.get_json()
|
| 34 |
+
assert 'message' in data
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TestCropRecommendationEndpoint:
|
| 38 |
+
"""Test crop recommendation endpoint."""
|
| 39 |
+
|
| 40 |
+
def test_crop_recommendation_happy_path(
|
| 41 |
+
self, client, sample_crop_input
|
| 42 |
+
):
|
| 43 |
+
"""Test successful crop recommendation."""
|
| 44 |
+
from src.models import manager
|
| 45 |
+
# Inject mocks into model cache
|
| 46 |
+
mock_model = MagicMock()
|
| 47 |
+
mock_model.predict = MagicMock(return_value=[1]) # Rice
|
| 48 |
+
mock_ms = MagicMock()
|
| 49 |
+
mock_ms.transform = MagicMock(side_effect=lambda x: x)
|
| 50 |
+
mock_sc = MagicMock()
|
| 51 |
+
mock_sc.transform = MagicMock(side_effect=lambda x: x)
|
| 52 |
+
manager._model_cache['crop_model'] = mock_model
|
| 53 |
+
manager._model_cache['crop_minmax_scaler'] = mock_ms
|
| 54 |
+
manager._model_cache['crop_standard_scaler'] = mock_sc
|
| 55 |
+
|
| 56 |
+
response = client.post(
|
| 57 |
+
'/crop_recommendation',
|
| 58 |
+
data=json.dumps(sample_crop_input),
|
| 59 |
+
content_type='application/json'
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
assert response.status_code == 200
|
| 63 |
+
data = response.get_json()
|
| 64 |
+
assert data['success'] is True
|
| 65 |
+
assert 'crop' in data
|
| 66 |
+
assert 'message' in data
|
| 67 |
+
assert 'prediction_id' in data
|
| 68 |
+
|
| 69 |
+
def test_crop_recommendation_missing_field(self, client):
|
| 70 |
+
"""Test crop recommendation with missing required field."""
|
| 71 |
+
incomplete_data = {
|
| 72 |
+
"N": 50,
|
| 73 |
+
"P": 30,
|
| 74 |
+
# Missing K and other fields
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
response = client.post(
|
| 78 |
+
'/crop_recommendation',
|
| 79 |
+
data=json.dumps(incomplete_data),
|
| 80 |
+
content_type='application/json'
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
assert response.status_code == 400
|
| 84 |
+
data = response.get_json()
|
| 85 |
+
assert 'error' in data
|
| 86 |
+
|
| 87 |
+
def test_crop_recommendation_invalid_range(self, client, sample_crop_input):
|
| 88 |
+
"""Test crop recommendation with out-of-range values."""
|
| 89 |
+
sample_crop_input['N'] = 150 # Out of valid range (0-100)
|
| 90 |
+
|
| 91 |
+
response = client.post(
|
| 92 |
+
'/crop_recommendation',
|
| 93 |
+
data=json.dumps(sample_crop_input),
|
| 94 |
+
content_type='application/json'
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
assert response.status_code == 400
|
| 98 |
+
data = response.get_json()
|
| 99 |
+
assert 'error' in data
|
| 100 |
+
|
| 101 |
+
def test_crop_recommendation_invalid_json(self, client):
|
| 102 |
+
"""Test crop recommendation with malformed JSON."""
|
| 103 |
+
response = client.post(
|
| 104 |
+
'/crop_recommendation',
|
| 105 |
+
data='invalid json{',
|
| 106 |
+
content_type='application/json'
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
assert response.status_code in [400, 500]
|
| 110 |
+
|
| 111 |
+
def test_crop_recommendation_invalid_data_type(self, client):
|
| 112 |
+
"""Test crop recommendation with invalid data types."""
|
| 113 |
+
invalid_data = {
|
| 114 |
+
"N": "not_a_number",
|
| 115 |
+
"P": 30,
|
| 116 |
+
"K": 40,
|
| 117 |
+
"temperature": 28,
|
| 118 |
+
"humidity": 65,
|
| 119 |
+
"ph": 6.5,
|
| 120 |
+
"rainfall": 200
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
response = client.post(
|
| 124 |
+
'/crop_recommendation',
|
| 125 |
+
data=json.dumps(invalid_data),
|
| 126 |
+
content_type='application/json'
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
assert response.status_code == 400
|
| 130 |
+
data = response.get_json()
|
| 131 |
+
assert 'error' in data
|
| 132 |
+
|
| 133 |
+
def test_crop_recommendation_model_not_loaded(self, client, sample_crop_input):
|
| 134 |
+
"""Test crop recommendation when model is not loaded."""
|
| 135 |
+
from src.models import manager
|
| 136 |
+
manager._model_cache['crop_model'] = None
|
| 137 |
+
manager._model_cache['crop_minmax_scaler'] = None
|
| 138 |
+
manager._model_cache['crop_standard_scaler'] = None
|
| 139 |
+
response = client.post(
|
| 140 |
+
'/crop_recommendation',
|
| 141 |
+
data=json.dumps(sample_crop_input),
|
| 142 |
+
content_type='application/json'
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
assert response.status_code == 500
|
| 146 |
+
data = response.get_json()
|
| 147 |
+
assert 'error' in data
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class TestFertilizerPredictionEndpoint:
|
| 151 |
+
"""Test fertilizer prediction endpoint."""
|
| 152 |
+
|
| 153 |
+
def test_fertilizer_prediction_happy_path(
|
| 154 |
+
self, client, sample_fertilizer_input
|
| 155 |
+
):
|
| 156 |
+
"""Test successful fertilizer prediction."""
|
| 157 |
+
import numpy as np
|
| 158 |
+
from src.models import manager
|
| 159 |
+
# Inject mocks into model cache
|
| 160 |
+
mock_classifier = MagicMock()
|
| 161 |
+
mock_classifier.predict = MagicMock(return_value=np.array([0]))
|
| 162 |
+
mock_encoder = MagicMock()
|
| 163 |
+
mock_encoder.inverse_transform = MagicMock(return_value=np.array(["Urea"]))
|
| 164 |
+
manager._model_cache['fertilizer_classifier'] = mock_classifier
|
| 165 |
+
manager._model_cache['fertilizer_label_encoder'] = mock_encoder
|
| 166 |
+
|
| 167 |
+
response = client.post(
|
| 168 |
+
'/fertilizer_prediction',
|
| 169 |
+
data=json.dumps(sample_fertilizer_input),
|
| 170 |
+
content_type='application/json'
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
assert response.status_code == 200
|
| 174 |
+
data = response.get_json()
|
| 175 |
+
assert 'fertilizer' in data
|
| 176 |
+
|
| 177 |
+
def test_fertilizer_prediction_missing_field(self, client):
|
| 178 |
+
"""Test fertilizer prediction with missing fields."""
|
| 179 |
+
incomplete_data = {
|
| 180 |
+
"temperature": 28,
|
| 181 |
+
"humidity": 65,
|
| 182 |
+
# Missing other required fields
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
response = client.post(
|
| 186 |
+
'/fertilizer_prediction',
|
| 187 |
+
data=json.dumps(incomplete_data),
|
| 188 |
+
content_type='application/json'
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
assert response.status_code == 400
|
| 192 |
+
data = response.get_json()
|
| 193 |
+
assert 'error' in data
|
| 194 |
+
|
| 195 |
+
def test_fertilizer_prediction_invalid_soil_type(self, client, sample_fertilizer_input):
|
| 196 |
+
"""Test fertilizer prediction with invalid soil type."""
|
| 197 |
+
sample_fertilizer_input['soil_type'] = "InvalidSoil"
|
| 198 |
+
|
| 199 |
+
response = client.post(
|
| 200 |
+
'/fertilizer_prediction',
|
| 201 |
+
data=json.dumps(sample_fertilizer_input),
|
| 202 |
+
content_type='application/json'
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Should return 400 for invalid soil type
|
| 206 |
+
assert response.status_code in [400, 500]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
class TestDiseasePredictionEndpoint:
|
| 210 |
+
"""Test disease prediction endpoint."""
|
| 211 |
+
|
| 212 |
+
@patch('app.predict')
|
| 213 |
+
def test_disease_prediction_happy_path(
|
| 214 |
+
self, mock_predict, client, mock_pil_image
|
| 215 |
+
):
|
| 216 |
+
"""Test successful disease prediction."""
|
| 217 |
+
if mock_pil_image is None:
|
| 218 |
+
pytest.skip("PIL not available")
|
| 219 |
+
|
| 220 |
+
from src.models import manager
|
| 221 |
+
mock_model = MagicMock()
|
| 222 |
+
manager._model_cache['disease_model'] = mock_model
|
| 223 |
+
manager._model_cache['disease_labels'] = ["healthy", "bacterial_blight", "leaf_spot"]
|
| 224 |
+
manager._model_cache['disease_remedies'] = {"bacterial_blight": "Apply copper-based fungicide"}
|
| 225 |
+
|
| 226 |
+
mock_predict.return_value = ("bacterial_blight", 0.95, [
|
| 227 |
+
("bacterial_blight", 0.95),
|
| 228 |
+
("leaf_spot", 0.03),
|
| 229 |
+
("healthy", 0.02)
|
| 230 |
+
])
|
| 231 |
+
|
| 232 |
+
# Create image bytes
|
| 233 |
+
img_byte_arr = io.BytesIO()
|
| 234 |
+
mock_pil_image.save(img_byte_arr, format='PNG')
|
| 235 |
+
img_byte_arr.seek(0)
|
| 236 |
+
|
| 237 |
+
response = client.post(
|
| 238 |
+
'/predict_disease',
|
| 239 |
+
data={'file': (img_byte_arr, 'test.png')},
|
| 240 |
+
content_type='multipart/form-data'
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
assert response.status_code == 200
|
| 244 |
+
data = response.get_json()
|
| 245 |
+
assert 'label' in data
|
| 246 |
+
assert 'confidence' in data
|
| 247 |
+
|
| 248 |
+
def test_disease_prediction_no_file(self, client):
|
| 249 |
+
"""Test disease prediction without file."""
|
| 250 |
+
response = client.post(
|
| 251 |
+
'/predict_disease',
|
| 252 |
+
data={},
|
| 253 |
+
content_type='multipart/form-data'
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
assert response.status_code == 400
|
| 257 |
+
data = response.get_json()
|
| 258 |
+
assert 'error' in data
|
| 259 |
+
|
| 260 |
+
def test_disease_prediction_model_not_loaded(self, client, mock_pil_image):
|
| 261 |
+
"""Test disease prediction when model not loaded."""
|
| 262 |
+
if mock_pil_image is None:
|
| 263 |
+
pytest.skip("PIL not available")
|
| 264 |
+
|
| 265 |
+
from src.models import manager
|
| 266 |
+
manager._model_cache['disease_model'] = None
|
| 267 |
+
|
| 268 |
+
img_byte_arr = io.BytesIO()
|
| 269 |
+
mock_pil_image.save(img_byte_arr, format='PNG')
|
| 270 |
+
img_byte_arr.seek(0)
|
| 271 |
+
|
| 272 |
+
response = client.post(
|
| 273 |
+
'/predict_disease',
|
| 274 |
+
data={'file': (img_byte_arr, 'test.png')},
|
| 275 |
+
content_type='multipart/form-data'
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
assert response.status_code == 503
|
| 279 |
+
data = response.get_json()
|
| 280 |
+
assert 'error' in data
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
class TestLoanPredictionEndpoint:
|
| 284 |
+
"""Test loan prediction endpoint."""
|
| 285 |
+
|
| 286 |
+
def test_loan_prediction_happy_path(
|
| 287 |
+
self, client, sample_loan_input
|
| 288 |
+
):
|
| 289 |
+
"""Test successful loan prediction."""
|
| 290 |
+
import numpy as np
|
| 291 |
+
from src.models import manager
|
| 292 |
+
# Inject mocks with feature_names_in_ for column ordering
|
| 293 |
+
mock_price = MagicMock()
|
| 294 |
+
mock_price.predict = MagicMock(return_value=np.array([50000]))
|
| 295 |
+
mock_price.feature_names_in_ = [
|
| 296 |
+
'area', 'distance_from_road', 'income',
|
| 297 |
+
'land_contour_hilly', 'land_contour_sloping',
|
| 298 |
+
'soil_type_clay', 'soil_type_sandy', 'soil_type_silty'
|
| 299 |
+
]
|
| 300 |
+
mock_approval = MagicMock()
|
| 301 |
+
mock_approval.predict = MagicMock(return_value=np.array([1]))
|
| 302 |
+
manager._model_cache['loan_price_model'] = mock_price
|
| 303 |
+
manager._model_cache['loan_approval_model'] = mock_approval
|
| 304 |
+
|
| 305 |
+
response = client.post(
|
| 306 |
+
'/loan_prediction',
|
| 307 |
+
data=json.dumps(sample_loan_input),
|
| 308 |
+
content_type='application/json'
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
assert response.status_code == 200
|
| 312 |
+
data = response.get_json()
|
| 313 |
+
assert 'predicted_price' in data or 'approval_status' in data
|
| 314 |
+
|
| 315 |
+
def test_loan_prediction_missing_fields(self, client):
|
| 316 |
+
"""Test loan prediction with missing fields."""
|
| 317 |
+
incomplete_data = {
|
| 318 |
+
"farmer_age": 35,
|
| 319 |
+
# Missing other fields
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
response = client.post(
|
| 323 |
+
'/loan_prediction',
|
| 324 |
+
data=json.dumps(incomplete_data),
|
| 325 |
+
content_type='application/json'
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
assert response.status_code == 400
|
| 329 |
+
data = response.get_json()
|
| 330 |
+
assert 'error' in data
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
class TestPriceForecastEndpoint:
|
| 334 |
+
"""Test price forecast endpoint."""
|
| 335 |
+
|
| 336 |
+
def test_price_forecast_happy_path(self, client, sample_price_forecast_input):
|
| 337 |
+
"""Test successful price forecast."""
|
| 338 |
+
response = client.post(
|
| 339 |
+
'/ai/price-forecast',
|
| 340 |
+
data=json.dumps(sample_price_forecast_input),
|
| 341 |
+
content_type='application/json'
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
assert response.status_code == 200
|
| 345 |
+
data = response.get_json()
|
| 346 |
+
assert data.get('success') is True
|
| 347 |
+
assert 'data' in data
|
| 348 |
+
|
| 349 |
+
def test_price_forecast_insufficient_data(self, client):
|
| 350 |
+
"""Test price forecast with insufficient historical data."""
|
| 351 |
+
insufficient_data = {
|
| 352 |
+
"commodity": "wheat",
|
| 353 |
+
"historical_data": [
|
| 354 |
+
{"date": "2026-01-01", "price": 55},
|
| 355 |
+
{"date": "2026-01-02", "price": 56}
|
| 356 |
+
],
|
| 357 |
+
"forecast_days": 7
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
response = client.post(
|
| 361 |
+
'/ai/price-forecast',
|
| 362 |
+
data=json.dumps(insufficient_data),
|
| 363 |
+
content_type='application/json'
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
assert response.status_code == 400
|
| 367 |
+
data = response.get_json()
|
| 368 |
+
assert 'error' in data
|
| 369 |
+
|
| 370 |
+
def test_price_forecast_invalid_days(self, client, sample_price_forecast_input):
|
| 371 |
+
"""Test price forecast with invalid forecast days."""
|
| 372 |
+
sample_price_forecast_input['forecast_days'] = 365 # Invalid
|
| 373 |
+
|
| 374 |
+
response = client.post(
|
| 375 |
+
'/ai/price-forecast',
|
| 376 |
+
data=json.dumps(sample_price_forecast_input),
|
| 377 |
+
content_type='application/json'
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
# Should either reject or default to valid value
|
| 381 |
+
assert response.status_code in [200, 400]
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
class TestYieldPredictionEndpoint:
|
| 385 |
+
"""Test yield prediction endpoint."""
|
| 386 |
+
|
| 387 |
+
def test_yield_prediction_happy_path(self, client, sample_yield_input):
|
| 388 |
+
"""Test successful yield prediction."""
|
| 389 |
+
response = client.post(
|
| 390 |
+
'/ai/yield-predict',
|
| 391 |
+
data=json.dumps(sample_yield_input),
|
| 392 |
+
content_type='application/json'
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
assert response.status_code == 200
|
| 396 |
+
data = response.get_json()
|
| 397 |
+
assert data.get('success') is True
|
| 398 |
+
assert data['data']['predicted_yield_kg_per_ha'] > 0
|
| 399 |
+
|
| 400 |
+
def test_yield_prediction_unknown_crop(self, client, sample_yield_input):
|
| 401 |
+
"""Test yield prediction with unknown crop."""
|
| 402 |
+
sample_yield_input['crop'] = "unknown_crop_xyz"
|
| 403 |
+
|
| 404 |
+
response = client.post(
|
| 405 |
+
'/ai/yield-predict',
|
| 406 |
+
data=json.dumps(sample_yield_input),
|
| 407 |
+
content_type='application/json'
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
# Unknown crops get a default yield, endpoint returns 200 with default
|
| 411 |
+
assert response.status_code == 200
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
class TestTariffSimulationEndpoint:
|
| 415 |
+
"""Test tariff simulation endpoint."""
|
| 416 |
+
|
| 417 |
+
def test_tariff_simulation_happy_path(self, client, sample_tariff_input):
|
| 418 |
+
"""Test successful tariff simulation."""
|
| 419 |
+
response = client.post(
|
| 420 |
+
'/ai/tariff-simulate',
|
| 421 |
+
data=json.dumps(sample_tariff_input),
|
| 422 |
+
content_type='application/json'
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
assert response.status_code == 200
|
| 426 |
+
data = response.get_json()
|
| 427 |
+
assert data.get('success') is True
|
| 428 |
+
assert 'data' in data
|
| 429 |
+
assert 'sensitivity_analysis' in data['data']
|
| 430 |
+
|
| 431 |
+
def test_tariff_simulation_missing_fields(self, client):
|
| 432 |
+
"""Test tariff simulation with missing fields."""
|
| 433 |
+
incomplete_data = {
|
| 434 |
+
"commodity": "wheat",
|
| 435 |
+
"current_tariff_pct": 35
|
| 436 |
+
# Missing other fields
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
response = client.post(
|
| 440 |
+
'/ai/tariff-simulate',
|
| 441 |
+
data=json.dumps(incomplete_data),
|
| 442 |
+
content_type='application/json'
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
# Endpoint uses defaults for all fields - returns 200 always
|
| 446 |
+
assert response.status_code == 200
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
class TestCROPICEndpoint:
|
| 450 |
+
"""Test CROPIC crop damage analysis endpoint."""
|
| 451 |
+
|
| 452 |
+
def test_cropic_analyze_happy_path(self, client, mock_pil_image):
|
| 453 |
+
"""Test successful CROPIC analysis."""
|
| 454 |
+
if mock_pil_image is None:
|
| 455 |
+
pytest.skip("PIL not available")
|
| 456 |
+
|
| 457 |
+
img_byte_arr = io.BytesIO()
|
| 458 |
+
mock_pil_image.save(img_byte_arr, format='PNG')
|
| 459 |
+
img_byte_arr.seek(0)
|
| 460 |
+
|
| 461 |
+
response = client.post(
|
| 462 |
+
'/ai/cropic/analyze',
|
| 463 |
+
data={'file': (img_byte_arr, 'crop.png')},
|
| 464 |
+
content_type='multipart/form-data'
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
assert response.status_code == 200
|
| 468 |
+
data = response.get_json()
|
| 469 |
+
assert data.get('success') is True
|
| 470 |
+
assert 'damage_type' in data['data']
|
| 471 |
+
assert 'damage_percentage' in data['data']
|
| 472 |
+
assert 'recommendations' in data['data']
|
| 473 |
+
|
| 474 |
+
def test_cropic_analyze_no_image(self, client):
|
| 475 |
+
"""Test CROPIC analysis without image."""
|
| 476 |
+
response = client.post(
|
| 477 |
+
'/ai/cropic/analyze',
|
| 478 |
+
data={},
|
| 479 |
+
content_type='multipart/form-data'
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
assert response.status_code == 400
|
| 483 |
+
data = response.get_json()
|
| 484 |
+
assert 'error' in data
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
class TestErrorHandling:
|
| 488 |
+
"""Test error handling across endpoints."""
|
| 489 |
+
|
| 490 |
+
def test_404_not_found(self, client):
|
| 491 |
+
"""Test 404 error for non-existent endpoint."""
|
| 492 |
+
response = client.get('/nonexistent')
|
| 493 |
+
assert response.status_code == 404
|
| 494 |
+
|
| 495 |
+
def test_405_method_not_allowed(self, client):
|
| 496 |
+
"""Test 405 error for wrong HTTP method."""
|
| 497 |
+
response = client.get('/crop_recommendation') # Should be POST
|
| 498 |
+
assert response.status_code == 405
|
| 499 |
+
|
| 500 |
+
@pytest.mark.parametrize("endpoint", [
|
| 501 |
+
"/crop_recommendation",
|
| 502 |
+
"/fertilizer_prediction",
|
| 503 |
+
"/loan_prediction",
|
| 504 |
+
"/ai/price-forecast",
|
| 505 |
+
"/ai/yield-predict",
|
| 506 |
+
"/ai/tariff-simulate"
|
| 507 |
+
])
|
| 508 |
+
def test_missing_content_type(self, client, endpoint):
|
| 509 |
+
"""Test endpoints with missing Content-Type header."""
|
| 510 |
+
response = client.post(
|
| 511 |
+
endpoint,
|
| 512 |
+
data='{"test": "data"}'
|
| 513 |
+
# No content_type specified
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
# Should handle gracefully, either 400 or attempt to parse
|
| 517 |
+
assert response.status_code in [200, 400, 415, 500]
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
class TestRetryBehavior:
|
| 521 |
+
"""Test retry behavior with transient failures."""
|
| 522 |
+
|
| 523 |
+
def test_transient_failure_then_success(
|
| 524 |
+
self, client, sample_crop_input
|
| 525 |
+
):
|
| 526 |
+
"""Test that transient failures are retried successfully."""
|
| 527 |
+
from src.models import manager
|
| 528 |
+
# Inject mocks into model cache
|
| 529 |
+
mock_model = MagicMock()
|
| 530 |
+
mock_model.predict = MagicMock(side_effect=[
|
| 531 |
+
Exception("Transient error"),
|
| 532 |
+
[1] # Success on retry
|
| 533 |
+
])
|
| 534 |
+
mock_ms = MagicMock()
|
| 535 |
+
mock_ms.transform = MagicMock(side_effect=lambda x: x)
|
| 536 |
+
mock_sc = MagicMock()
|
| 537 |
+
mock_sc.transform = MagicMock(side_effect=lambda x: x)
|
| 538 |
+
manager._model_cache['crop_model'] = mock_model
|
| 539 |
+
manager._model_cache['crop_minmax_scaler'] = mock_ms
|
| 540 |
+
manager._model_cache['crop_standard_scaler'] = mock_sc
|
| 541 |
+
|
| 542 |
+
# Note: This test will only work if retry logic is implemented
|
| 543 |
+
# For now, it will fail on first exception
|
| 544 |
+
response = client.post(
|
| 545 |
+
'/crop_recommendation',
|
| 546 |
+
data=json.dumps(sample_crop_input),
|
| 547 |
+
content_type='application/json'
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
# Without retry wrapper, this will return 500
|
| 551 |
+
# With retry wrapper, should succeed
|
| 552 |
+
assert response.status_code in [200, 500]
|
ai-backend/tests/test_endpoints.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Backend Tests
|
| 3 |
+
"""
|
| 4 |
+
import pytest
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
# Add parent directory to path
|
| 10 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestHealthEndpoint:
|
| 14 |
+
"""Test health check endpoint"""
|
| 15 |
+
|
| 16 |
+
def test_health_response_format(self):
|
| 17 |
+
"""Health endpoint should return correct format"""
|
| 18 |
+
expected_keys = {"status", "message"}
|
| 19 |
+
# Simulating expected response structure
|
| 20 |
+
response = {"status": "healthy", "message": "API is running"}
|
| 21 |
+
assert set(response.keys()) == expected_keys
|
| 22 |
+
assert response["status"] == "healthy"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TestPriceForecast:
|
| 26 |
+
"""Test price forecasting functionality"""
|
| 27 |
+
|
| 28 |
+
def test_minimum_data_points_required(self):
|
| 29 |
+
"""Should require at least 5 historical data points"""
|
| 30 |
+
min_required = 5
|
| 31 |
+
short_data = [{"date": "2026-01-01", "price": 55}] * 4
|
| 32 |
+
long_data = [{"date": "2026-01-01", "price": 55}] * 5
|
| 33 |
+
|
| 34 |
+
assert len(short_data) < min_required
|
| 35 |
+
assert len(long_data) >= min_required
|
| 36 |
+
|
| 37 |
+
def test_forecast_days_validation(self):
|
| 38 |
+
"""Forecast days should be 7, 30, or 90"""
|
| 39 |
+
valid_days = [7, 30, 90]
|
| 40 |
+
|
| 41 |
+
for days in valid_days:
|
| 42 |
+
assert days in [7, 30, 90]
|
| 43 |
+
|
| 44 |
+
# Invalid should default to 30
|
| 45 |
+
invalid_days = 45
|
| 46 |
+
default_days = 30 if invalid_days not in valid_days else invalid_days
|
| 47 |
+
assert default_days == 30
|
| 48 |
+
|
| 49 |
+
def test_confidence_interval_calculation(self):
|
| 50 |
+
"""Confidence intervals should widen with forecast horizon"""
|
| 51 |
+
import math
|
| 52 |
+
|
| 53 |
+
daily_volatility = 0.02
|
| 54 |
+
last_price = 55
|
| 55 |
+
|
| 56 |
+
ci_day_1 = daily_volatility * last_price * math.sqrt(1) * 1.96
|
| 57 |
+
ci_day_30 = daily_volatility * last_price * math.sqrt(30) * 1.96
|
| 58 |
+
|
| 59 |
+
assert ci_day_30 > ci_day_1
|
| 60 |
+
assert ci_day_30 == pytest.approx(ci_day_1 * math.sqrt(30), rel=0.01)
|
| 61 |
+
|
| 62 |
+
def test_feature_preparation(self):
|
| 63 |
+
"""Feature preparation should produce valid arrays"""
|
| 64 |
+
# Sample historical data
|
| 65 |
+
historical_prices = [
|
| 66 |
+
{"date": "2026-01-01", "price": 55, "volume": 1000},
|
| 67 |
+
{"date": "2026-01-02", "price": 56, "volume": 1200},
|
| 68 |
+
{"date": "2026-01-03", "price": 54, "volume": 900},
|
| 69 |
+
{"date": "2026-01-04", "price": 57, "volume": 1100},
|
| 70 |
+
{"date": "2026-01-05", "price": 55, "volume": 1050},
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
prices = [p["price"] for p in historical_prices]
|
| 74 |
+
|
| 75 |
+
# Basic statistics using standard library
|
| 76 |
+
price_mean = sum(prices) / len(prices)
|
| 77 |
+
price_min = min(prices)
|
| 78 |
+
price_max = max(prices)
|
| 79 |
+
|
| 80 |
+
assert price_mean == pytest.approx(55.4, rel=0.01)
|
| 81 |
+
assert price_min == 54
|
| 82 |
+
assert price_max == 57
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class TestYieldPrediction:
|
| 86 |
+
"""Test yield prediction functionality"""
|
| 87 |
+
|
| 88 |
+
def test_base_yield_lookup(self):
|
| 89 |
+
"""Should return base yield for known crops"""
|
| 90 |
+
base_yields = {
|
| 91 |
+
"groundnut": 1800,
|
| 92 |
+
"sunflower": 1200,
|
| 93 |
+
"soybean": 2000,
|
| 94 |
+
"mustard": 1100,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
for crop, yield_value in base_yields.items():
|
| 98 |
+
assert yield_value > 0
|
| 99 |
+
assert crop in base_yields
|
| 100 |
+
|
| 101 |
+
def test_soil_factor_calculation(self):
|
| 102 |
+
"""Soil factor should be between 0.5 and 1.5"""
|
| 103 |
+
# Test optimal conditions
|
| 104 |
+
n, p, k = 50, 30, 40
|
| 105 |
+
soil_factor = 1.0
|
| 106 |
+
|
| 107 |
+
if 40 <= n <= 60 and 25 <= p <= 40 and 30 <= k <= 50:
|
| 108 |
+
soil_factor = 1.1
|
| 109 |
+
|
| 110 |
+
assert 0.5 <= soil_factor <= 1.5
|
| 111 |
+
|
| 112 |
+
def test_weather_factor_calculation(self):
|
| 113 |
+
"""Weather factor should adjust based on rainfall and temperature"""
|
| 114 |
+
rainfall = 800
|
| 115 |
+
temp = 28
|
| 116 |
+
weather_factor = 1.0
|
| 117 |
+
|
| 118 |
+
if 600 <= rainfall <= 1000:
|
| 119 |
+
weather_factor = 1.1
|
| 120 |
+
|
| 121 |
+
if 25 <= temp <= 32:
|
| 122 |
+
weather_factor *= 1.05
|
| 123 |
+
|
| 124 |
+
assert weather_factor == pytest.approx(1.155, rel=0.01)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class TestTariffSimulation:
|
| 128 |
+
"""Test tariff impact simulation"""
|
| 129 |
+
|
| 130 |
+
def test_import_elasticity_effect(self):
|
| 131 |
+
"""Higher tariffs should reduce imports"""
|
| 132 |
+
base_import = 15000000
|
| 133 |
+
import_elasticity = -0.8
|
| 134 |
+
tariff_change = 0.05 # 5% increase
|
| 135 |
+
|
| 136 |
+
import_change = tariff_change * import_elasticity
|
| 137 |
+
new_import = base_import * (1 + import_change)
|
| 138 |
+
|
| 139 |
+
assert new_import < base_import
|
| 140 |
+
|
| 141 |
+
def test_price_pass_through(self):
|
| 142 |
+
"""Price changes should pass through to farmers and consumers"""
|
| 143 |
+
price_change = 10 # 10% change
|
| 144 |
+
farmer_pass_through = 0.6
|
| 145 |
+
consumer_pass_through = 0.8
|
| 146 |
+
|
| 147 |
+
farmer_impact = price_change * farmer_pass_through
|
| 148 |
+
consumer_impact = price_change * consumer_pass_through
|
| 149 |
+
|
| 150 |
+
assert farmer_impact == 6
|
| 151 |
+
assert consumer_impact == 8
|
| 152 |
+
assert farmer_impact <= consumer_impact
|
| 153 |
+
|
| 154 |
+
def test_sensitivity_analysis(self):
|
| 155 |
+
"""Should generate sensitivity table for different tariff levels"""
|
| 156 |
+
tariff_levels = [25, 30, 35, 40, 45, 50]
|
| 157 |
+
sensitivity_results = []
|
| 158 |
+
|
| 159 |
+
for tariff in tariff_levels:
|
| 160 |
+
result = {
|
| 161 |
+
"tariff_pct": tariff,
|
| 162 |
+
"import_volume": 15 - (tariff - 35) * 0.1,
|
| 163 |
+
}
|
| 164 |
+
sensitivity_results.append(result)
|
| 165 |
+
|
| 166 |
+
assert len(sensitivity_results) == 6
|
| 167 |
+
# Higher tariffs should result in lower imports
|
| 168 |
+
imports = [r["import_volume"] for r in sensitivity_results]
|
| 169 |
+
assert imports == sorted(imports, reverse=True)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class TestCROPIC:
|
| 173 |
+
"""Test crop damage analysis"""
|
| 174 |
+
|
| 175 |
+
def test_image_size_validation(self):
|
| 176 |
+
"""Should validate image dimensions"""
|
| 177 |
+
min_dimension = 100
|
| 178 |
+
max_size_bytes = 10 * 1024 * 1024 # 10MB
|
| 179 |
+
|
| 180 |
+
valid_image = {"width": 640, "height": 480, "size": 500000}
|
| 181 |
+
invalid_image = {"width": 50, "height": 50, "size": 5000}
|
| 182 |
+
|
| 183 |
+
assert valid_image["width"] >= min_dimension
|
| 184 |
+
assert valid_image["height"] >= min_dimension
|
| 185 |
+
assert valid_image["size"] <= max_size_bytes
|
| 186 |
+
|
| 187 |
+
assert invalid_image["width"] < min_dimension
|
| 188 |
+
|
| 189 |
+
def test_damage_classification(self):
|
| 190 |
+
"""Should classify damage types correctly"""
|
| 191 |
+
damage_types = [
|
| 192 |
+
"none",
|
| 193 |
+
"pest_damage",
|
| 194 |
+
"disease",
|
| 195 |
+
"drought_stress",
|
| 196 |
+
"flood_damage",
|
| 197 |
+
"bacterial_infection",
|
| 198 |
+
"fungal_disease",
|
| 199 |
+
]
|
| 200 |
+
|
| 201 |
+
for dtype in damage_types:
|
| 202 |
+
assert isinstance(dtype, str)
|
| 203 |
+
assert len(dtype) > 0
|
| 204 |
+
|
| 205 |
+
def test_damage_percentage_range(self):
|
| 206 |
+
"""Damage percentage should be between 0 and 100"""
|
| 207 |
+
import random
|
| 208 |
+
|
| 209 |
+
for _ in range(10):
|
| 210 |
+
damage_pct = random.randint(0, 100)
|
| 211 |
+
assert 0 <= damage_pct <= 100
|
| 212 |
+
|
| 213 |
+
def test_recommendation_generation(self):
|
| 214 |
+
"""Should generate recommendations based on damage"""
|
| 215 |
+
def get_recommendations(damage_type, damage_pct):
|
| 216 |
+
recs = []
|
| 217 |
+
if damage_type == "none":
|
| 218 |
+
recs.append("monitoring")
|
| 219 |
+
elif damage_type == "pest_damage":
|
| 220 |
+
recs.append("pesticide_application")
|
| 221 |
+
elif damage_pct >= 50:
|
| 222 |
+
recs.append("insurance_claim")
|
| 223 |
+
return recs
|
| 224 |
+
|
| 225 |
+
assert "monitoring" in get_recommendations("none", 0)
|
| 226 |
+
assert "pesticide_application" in get_recommendations("pest_damage", 30)
|
| 227 |
+
assert "insurance_claim" in get_recommendations("disease", 60)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class TestCropRecommendation:
|
| 231 |
+
"""Test crop recommendation functionality"""
|
| 232 |
+
|
| 233 |
+
def test_input_validation_ranges(self):
|
| 234 |
+
"""Should validate input ranges"""
|
| 235 |
+
valid_inputs = {
|
| 236 |
+
"N": 50, # 0-100
|
| 237 |
+
"P": 30, # 0-100
|
| 238 |
+
"K": 40, # 0-100
|
| 239 |
+
"temperature": 28, # -10 to 50
|
| 240 |
+
"humidity": 65, # 0-100
|
| 241 |
+
"ph": 6.5, # 0-14
|
| 242 |
+
"rainfall": 200, # 0-500
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
assert 0 <= valid_inputs["N"] <= 100
|
| 246 |
+
assert 0 <= valid_inputs["P"] <= 100
|
| 247 |
+
assert 0 <= valid_inputs["K"] <= 100
|
| 248 |
+
assert -10 <= valid_inputs["temperature"] <= 50
|
| 249 |
+
assert 0 <= valid_inputs["humidity"] <= 100
|
| 250 |
+
assert 0 <= valid_inputs["ph"] <= 14
|
| 251 |
+
assert 0 <= valid_inputs["rainfall"] <= 500
|
| 252 |
+
|
| 253 |
+
def test_crop_dictionary(self):
|
| 254 |
+
"""Should have valid crop mappings"""
|
| 255 |
+
crop_dict = {
|
| 256 |
+
1: "Rice", 2: "Maize", 3: "Jute", 4: "Cotton",
|
| 257 |
+
5: "Coconut", 6: "Papaya", 7: "Orange",
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
assert len(crop_dict) > 0
|
| 261 |
+
for key, value in crop_dict.items():
|
| 262 |
+
assert isinstance(key, int)
|
| 263 |
+
assert isinstance(value, str)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
class TestSaffronClassifier:
|
| 267 |
+
"""Test saffron authenticity classification"""
|
| 268 |
+
|
| 269 |
+
def test_saffron_classes(self):
|
| 270 |
+
"""Should have exactly 3 saffron classes"""
|
| 271 |
+
classes = ["mogra", "lacha", "adulterated"]
|
| 272 |
+
assert len(classes) == 3
|
| 273 |
+
assert "mogra" in classes
|
| 274 |
+
assert "lacha" in classes
|
| 275 |
+
assert "adulterated" in classes
|
| 276 |
+
|
| 277 |
+
def test_saffron_response_format(self):
|
| 278 |
+
"""Saffron response should have correct fields"""
|
| 279 |
+
expected_keys = {"model", "prediction", "confidence", "all_predictions", "timestamp"}
|
| 280 |
+
response = {
|
| 281 |
+
"model": "saffron-verify-pretrained",
|
| 282 |
+
"prediction": "mogra",
|
| 283 |
+
"confidence": 0.95,
|
| 284 |
+
"all_predictions": [
|
| 285 |
+
{"label": "mogra", "confidence": 0.95},
|
| 286 |
+
{"label": "lacha", "confidence": 0.04},
|
| 287 |
+
{"label": "adulterated", "confidence": 0.01},
|
| 288 |
+
],
|
| 289 |
+
"timestamp": "2026-03-07T08:00:00Z",
|
| 290 |
+
}
|
| 291 |
+
assert set(response.keys()) == expected_keys
|
| 292 |
+
assert response["model"] == "saffron-verify-pretrained"
|
| 293 |
+
assert 0 <= response["confidence"] <= 1
|
| 294 |
+
assert response["prediction"] in ["mogra", "lacha", "adulterated"]
|
| 295 |
+
|
| 296 |
+
def test_saffron_grade_mapping(self):
|
| 297 |
+
"""Saffron grades should map correctly"""
|
| 298 |
+
grade_map = {
|
| 299 |
+
"mogra": "Grade A",
|
| 300 |
+
"lacha": "Grade B",
|
| 301 |
+
"adulterated": "Adulterated",
|
| 302 |
+
}
|
| 303 |
+
assert grade_map["mogra"] == "Grade A"
|
| 304 |
+
assert grade_map["lacha"] == "Grade B"
|
| 305 |
+
assert grade_map["adulterated"] == "Adulterated"
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
class TestWalnutDefectClassifier:
|
| 309 |
+
"""Test walnut defect classification"""
|
| 310 |
+
|
| 311 |
+
def test_walnut_defect_classes(self):
|
| 312 |
+
"""Should have 4 defect classes"""
|
| 313 |
+
classes = ["Healthy", "Black Spot", "Shriveled", "Damaged"]
|
| 314 |
+
assert len(classes) == 4
|
| 315 |
+
assert "Healthy" in classes
|
| 316 |
+
|
| 317 |
+
def test_walnut_defect_response_format(self):
|
| 318 |
+
"""Walnut defect response should have correct fields"""
|
| 319 |
+
response = {
|
| 320 |
+
"model": "walnut-defect-classifier",
|
| 321 |
+
"prediction": "Healthy",
|
| 322 |
+
"confidence": 0.98,
|
| 323 |
+
"all_predictions": [
|
| 324 |
+
{"label": "Healthy", "confidence": 0.98},
|
| 325 |
+
{"label": "Black Spot", "confidence": 0.01},
|
| 326 |
+
{"label": "Shriveled", "confidence": 0.005},
|
| 327 |
+
{"label": "Damaged", "confidence": 0.005},
|
| 328 |
+
],
|
| 329 |
+
"timestamp": "2026-03-07T08:00:00Z",
|
| 330 |
+
}
|
| 331 |
+
assert response["model"] == "walnut-defect-classifier"
|
| 332 |
+
assert 0 <= response["confidence"] <= 1
|
| 333 |
+
assert len(response["all_predictions"]) == 4
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
class TestWalnutRancidityPredictor:
|
| 337 |
+
"""Test walnut rancidity prediction"""
|
| 338 |
+
|
| 339 |
+
def test_rancidity_arrhenius_kinetics(self):
|
| 340 |
+
"""Arrhenius kinetics should produce valid rate constant"""
|
| 341 |
+
import math
|
| 342 |
+
A = 1.5e12
|
| 343 |
+
Ea = 80000
|
| 344 |
+
R = 8.314
|
| 345 |
+
T_kelvin = 25 + 273.15 # 25°C
|
| 346 |
+
k = A * math.exp(-Ea / (R * T_kelvin))
|
| 347 |
+
assert k > 0
|
| 348 |
+
assert k < 1 # rate constant should be small for real conditions
|
| 349 |
+
|
| 350 |
+
def test_rancidity_probability_range(self):
|
| 351 |
+
"""Rancidity probability should be between 0 and 1"""
|
| 352 |
+
import math
|
| 353 |
+
for pv in [0.1, 1.0, 3.0, 5.0, 8.0, 15.0]:
|
| 354 |
+
prob = 1.0 / (1.0 + math.exp(-(pv - 5)))
|
| 355 |
+
assert 0 <= prob <= 1
|
| 356 |
+
|
| 357 |
+
def test_rancidity_threshold(self):
|
| 358 |
+
"""PV > 5 should give rancidity probability > 0.5"""
|
| 359 |
+
import math
|
| 360 |
+
pv_safe = 2.0
|
| 361 |
+
pv_rancid = 8.0
|
| 362 |
+
prob_safe = 1.0 / (1.0 + math.exp(-(pv_safe - 5)))
|
| 363 |
+
prob_rancid = 1.0 / (1.0 + math.exp(-(pv_rancid - 5)))
|
| 364 |
+
assert prob_safe < 0.5
|
| 365 |
+
assert prob_rancid > 0.5
|
| 366 |
+
|
| 367 |
+
def test_risk_level_classification(self):
|
| 368 |
+
"""Risk levels should classify correctly"""
|
| 369 |
+
def classify(prob):
|
| 370 |
+
if prob < 0.30:
|
| 371 |
+
return "LOW"
|
| 372 |
+
elif prob < 0.70:
|
| 373 |
+
return "MEDIUM"
|
| 374 |
+
else:
|
| 375 |
+
return "HIGH"
|
| 376 |
+
|
| 377 |
+
assert classify(0.1) == "LOW"
|
| 378 |
+
assert classify(0.5) == "MEDIUM"
|
| 379 |
+
assert classify(0.8) == "HIGH"
|
| 380 |
+
|
| 381 |
+
def test_rancidity_input_validation(self):
|
| 382 |
+
"""Should validate input ranges"""
|
| 383 |
+
valid_inputs = {
|
| 384 |
+
"storage_days": 30,
|
| 385 |
+
"temperature": 25,
|
| 386 |
+
"humidity": 60,
|
| 387 |
+
"moisture": 5,
|
| 388 |
+
}
|
| 389 |
+
assert 0 <= valid_inputs["storage_days"] <= 365
|
| 390 |
+
assert -10 <= valid_inputs["temperature"] <= 50
|
| 391 |
+
assert 0 <= valid_inputs["humidity"] <= 100
|
| 392 |
+
assert 0 <= valid_inputs["moisture"] <= 20
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
class TestApplePricePredictor:
|
| 396 |
+
"""Test apple price prediction"""
|
| 397 |
+
|
| 398 |
+
def test_apple_varieties(self):
|
| 399 |
+
"""Should have correct apple varieties"""
|
| 400 |
+
varieties = ["Shimla", "Kinnauri", "Royal Delicious", "Golden Delicious", "Maharaji"]
|
| 401 |
+
assert len(varieties) == 5
|
| 402 |
+
assert "Kinnauri" in varieties
|
| 403 |
+
|
| 404 |
+
def test_apple_regions(self):
|
| 405 |
+
"""Should have correct Indian regions"""
|
| 406 |
+
regions = ["Himachal Pradesh", "Jammu & Kashmir", "Uttarakhand",
|
| 407 |
+
"Arunachal Pradesh", "Nagaland"]
|
| 408 |
+
assert len(regions) == 5
|
| 409 |
+
assert "Himachal Pradesh" in regions
|
| 410 |
+
|
| 411 |
+
def test_storage_cost_calculation(self):
|
| 412 |
+
"""Storage cost should be ₹0.75/kg/day"""
|
| 413 |
+
storage_cost_per_day = 0.75
|
| 414 |
+
storage_cost_7d = storage_cost_per_day * 7
|
| 415 |
+
assert storage_cost_7d == pytest.approx(5.25)
|
| 416 |
+
|
| 417 |
+
def test_sell_store_decision(self):
|
| 418 |
+
"""SELL/STORE decision should be based on breakeven"""
|
| 419 |
+
current_price = 120.0
|
| 420 |
+
storage_cost_7d = 5.25
|
| 421 |
+
breakeven = current_price + storage_cost_7d
|
| 422 |
+
|
| 423 |
+
predicted_high = 130.0
|
| 424 |
+
predicted_low = 122.0
|
| 425 |
+
|
| 426 |
+
assert predicted_high > breakeven # should STORE
|
| 427 |
+
assert predicted_low < breakeven # should SELL
|
| 428 |
+
|
| 429 |
+
def test_seasonal_adjustment(self):
|
| 430 |
+
"""Seasonal adjustments should be applied for Indian market"""
|
| 431 |
+
# Harvest season (Jul-Oct): discount
|
| 432 |
+
# Summer scarcity (Apr-Jun): premium
|
| 433 |
+
harvest_months = [7, 8, 9, 10]
|
| 434 |
+
scarcity_months = [4, 5, 6]
|
| 435 |
+
|
| 436 |
+
for m in harvest_months:
|
| 437 |
+
assert 7 <= m <= 10
|
| 438 |
+
for m in scarcity_months:
|
| 439 |
+
assert 4 <= m <= 6
|
| 440 |
+
|
| 441 |
+
def test_apple_price_response_format(self):
|
| 442 |
+
"""Apple price response should have correct fields"""
|
| 443 |
+
expected_keys = {"model", "predicted_price_7d", "recommendation",
|
| 444 |
+
"current_price", "storage_cost_7d", "breakeven_price",
|
| 445 |
+
"currency", "confidence", "advisory", "timestamp"}
|
| 446 |
+
response = {
|
| 447 |
+
"model": "apple-price-predictor",
|
| 448 |
+
"predicted_price_7d": 127.5,
|
| 449 |
+
"recommendation": "STORE",
|
| 450 |
+
"current_price": 120.0,
|
| 451 |
+
"storage_cost_7d": 5.25,
|
| 452 |
+
"breakeven_price": 125.25,
|
| 453 |
+
"currency": "INR",
|
| 454 |
+
"confidence": "hybrid seasonal+trend model",
|
| 455 |
+
"advisory": "Predicted price in 7 days: ₹127.5/kg. Store for better returns.",
|
| 456 |
+
"timestamp": "2026-03-07T08:00:00Z",
|
| 457 |
+
}
|
| 458 |
+
assert set(response.keys()) == expected_keys
|
| 459 |
+
assert response["currency"] == "INR"
|
| 460 |
+
assert response["recommendation"] in ["SELL", "STORE"]
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
if __name__ == "__main__":
|
| 464 |
+
pytest.main([__file__, "-v"])
|
ai-backend/tests/test_models.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for model manager and prediction functions.
|
| 2 |
+
|
| 3 |
+
Tests each model's prediction functionality with sample inputs.
|
| 4 |
+
Uses mocked models to avoid heavy downloads and ensure fast, deterministic tests.
|
| 5 |
+
"""
|
| 6 |
+
import pytest
|
| 7 |
+
import numpy as np
|
| 8 |
+
from unittest.mock import patch, MagicMock
|
| 9 |
+
import sys
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 13 |
+
|
| 14 |
+
from src.models import manager
|
| 15 |
+
|
| 16 |
+
# Check if torchvision is available
|
| 17 |
+
try:
|
| 18 |
+
import torchvision
|
| 19 |
+
TORCHVISION_AVAILABLE = True
|
| 20 |
+
except ImportError:
|
| 21 |
+
TORCHVISION_AVAILABLE = False
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TestModelManager:
|
| 25 |
+
"""Tests for model manager initialization and caching."""
|
| 26 |
+
|
| 27 |
+
def test_get_device(self):
|
| 28 |
+
"""Test device detection."""
|
| 29 |
+
device = manager.get_device()
|
| 30 |
+
assert device is not None
|
| 31 |
+
assert str(device) in ['cpu', 'cuda']
|
| 32 |
+
|
| 33 |
+
def test_model_cache_empty_initially(self):
|
| 34 |
+
"""Test that model cache starts empty."""
|
| 35 |
+
manager._model_cache.clear()
|
| 36 |
+
assert len(manager._model_cache) == 0
|
| 37 |
+
|
| 38 |
+
def test_is_model_loaded(self):
|
| 39 |
+
"""Test model loaded status check."""
|
| 40 |
+
manager._model_cache.clear()
|
| 41 |
+
assert not manager.is_model_loaded('crop_model')
|
| 42 |
+
|
| 43 |
+
manager._model_cache['crop_model'] = MagicMock()
|
| 44 |
+
assert manager.is_model_loaded('crop_model')
|
| 45 |
+
|
| 46 |
+
def test_get_model_status(self):
|
| 47 |
+
"""Test getting status of all models."""
|
| 48 |
+
manager._model_cache.clear()
|
| 49 |
+
status = manager.get_model_status()
|
| 50 |
+
|
| 51 |
+
assert isinstance(status, dict)
|
| 52 |
+
assert 'disease_model' in status
|
| 53 |
+
assert 'crop_model' in status
|
| 54 |
+
assert 'fertilizer_classifier' in status
|
| 55 |
+
assert 'loan_price_model' in status
|
| 56 |
+
assert 'loan_approval_model' in status
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class TestCropRecommendation:
|
| 60 |
+
"""Tests for crop recommendation model."""
|
| 61 |
+
|
| 62 |
+
def test_predict_crop_with_valid_input(self, mock_crop_model):
|
| 63 |
+
"""Test crop prediction with valid input."""
|
| 64 |
+
# Setup mocks in cache
|
| 65 |
+
manager._model_cache['crop_model'] = mock_crop_model['model']
|
| 66 |
+
manager._model_cache['crop_standard_scaler'] = mock_crop_model['standard_scaler']
|
| 67 |
+
manager._model_cache['crop_minmax_scaler'] = mock_crop_model['minmax_scaler']
|
| 68 |
+
|
| 69 |
+
# Create sample features
|
| 70 |
+
features = np.array([[50, 30, 40, 28, 65, 6.5, 200]])
|
| 71 |
+
|
| 72 |
+
# Make prediction
|
| 73 |
+
result = manager.predict_crop(features)
|
| 74 |
+
|
| 75 |
+
# Assertions
|
| 76 |
+
assert isinstance(result, int)
|
| 77 |
+
assert result == 1 # Mock returns 1 for "Rice"
|
| 78 |
+
|
| 79 |
+
def test_predict_crop_without_models_raises_error(self):
|
| 80 |
+
"""Test that prediction fails gracefully when models not loaded."""
|
| 81 |
+
manager._model_cache.clear()
|
| 82 |
+
|
| 83 |
+
# Ensure models are explicitly None to avoid auto-loading
|
| 84 |
+
manager._model_cache['crop_model'] = None
|
| 85 |
+
manager._model_cache['crop_standard_scaler'] = None
|
| 86 |
+
manager._model_cache['crop_minmax_scaler'] = None
|
| 87 |
+
|
| 88 |
+
features = np.array([[50, 30, 40, 28, 65, 6.5, 200]])
|
| 89 |
+
|
| 90 |
+
with pytest.raises(RuntimeError, match="Crop recommendation models not loaded"):
|
| 91 |
+
manager.predict_crop(features)
|
| 92 |
+
|
| 93 |
+
def test_crop_prediction_calls_scalers(self, mock_crop_model):
|
| 94 |
+
"""Test that crop prediction uses both scalers."""
|
| 95 |
+
manager._model_cache['crop_model'] = mock_crop_model['model']
|
| 96 |
+
manager._model_cache['crop_standard_scaler'] = mock_crop_model['standard_scaler']
|
| 97 |
+
manager._model_cache['crop_minmax_scaler'] = mock_crop_model['minmax_scaler']
|
| 98 |
+
|
| 99 |
+
features = np.array([[50, 30, 40, 28, 65, 6.5, 200]])
|
| 100 |
+
manager.predict_crop(features)
|
| 101 |
+
|
| 102 |
+
# Verify scalers were called
|
| 103 |
+
mock_crop_model['minmax_scaler'].transform.assert_called_once()
|
| 104 |
+
mock_crop_model['standard_scaler'].transform.assert_called_once()
|
| 105 |
+
mock_crop_model['model'].predict.assert_called_once()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class TestFertilizerPrediction:
|
| 109 |
+
"""Tests for fertilizer prediction model."""
|
| 110 |
+
|
| 111 |
+
def test_predict_fertilizer_with_valid_input(self, mock_fertilizer_model):
|
| 112 |
+
"""Test fertilizer prediction with valid input."""
|
| 113 |
+
manager._model_cache['fertilizer_classifier'] = mock_fertilizer_model['classifier']
|
| 114 |
+
manager._model_cache['fertilizer_label_encoder'] = mock_fertilizer_model['label_encoder']
|
| 115 |
+
|
| 116 |
+
features = np.array([[28, 65, 45, 2, 10, 50, 40, 30]])
|
| 117 |
+
|
| 118 |
+
result = manager.predict_fertilizer(features)
|
| 119 |
+
|
| 120 |
+
assert isinstance(result, str)
|
| 121 |
+
assert result == "Urea"
|
| 122 |
+
|
| 123 |
+
def test_predict_fertilizer_without_models_raises_error(self):
|
| 124 |
+
"""Test that prediction fails when models not loaded."""
|
| 125 |
+
manager._model_cache.clear()
|
| 126 |
+
|
| 127 |
+
# Ensure models are explicitly None to avoid auto-loading
|
| 128 |
+
manager._model_cache['fertilizer_classifier'] = None
|
| 129 |
+
manager._model_cache['fertilizer_label_encoder'] = None
|
| 130 |
+
|
| 131 |
+
features = np.array([[28, 65, 45, 2, 10, 50, 40, 30]])
|
| 132 |
+
|
| 133 |
+
with pytest.raises(RuntimeError, match="Fertilizer prediction models not loaded"):
|
| 134 |
+
manager.predict_fertilizer(features)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class TestDiseasePrediction:
|
| 138 |
+
"""Tests for disease detection model."""
|
| 139 |
+
|
| 140 |
+
@pytest.mark.skipif(not TORCHVISION_AVAILABLE, reason="torchvision not installed")
|
| 141 |
+
@patch('model_utils.predict')
|
| 142 |
+
def test_predict_disease_with_valid_image(self, mock_predict, mock_disease_model, mock_pil_image):
|
| 143 |
+
"""Test disease prediction with valid image."""
|
| 144 |
+
if mock_pil_image is None:
|
| 145 |
+
pytest.skip("PIL not available")
|
| 146 |
+
|
| 147 |
+
model, labels, remedies = mock_disease_model
|
| 148 |
+
manager._model_cache['disease_model'] = model
|
| 149 |
+
manager._model_cache['disease_labels'] = labels
|
| 150 |
+
manager._model_cache['disease_remedies'] = remedies
|
| 151 |
+
|
| 152 |
+
# Mock the predict function to return expected values
|
| 153 |
+
mock_predict.return_value = ("bacterial_blight", 0.95, [
|
| 154 |
+
("bacterial_blight", 0.95),
|
| 155 |
+
("leaf_spot", 0.03),
|
| 156 |
+
("rust", 0.02)
|
| 157 |
+
])
|
| 158 |
+
|
| 159 |
+
label, confidence, topk = manager.predict_disease(mock_pil_image, topk=3)
|
| 160 |
+
|
| 161 |
+
assert label == "bacterial_blight"
|
| 162 |
+
assert confidence == 0.95
|
| 163 |
+
assert len(topk) == 3
|
| 164 |
+
|
| 165 |
+
@pytest.mark.skipif(not TORCHVISION_AVAILABLE, reason="torchvision not installed")
|
| 166 |
+
def test_predict_disease_without_model_raises_error(self, mock_pil_image):
|
| 167 |
+
"""Test that prediction fails when model not loaded."""
|
| 168 |
+
if mock_pil_image is None:
|
| 169 |
+
pytest.skip("PIL not available")
|
| 170 |
+
|
| 171 |
+
manager._model_cache.clear()
|
| 172 |
+
# Ensure models are explicitly None to avoid auto-loading
|
| 173 |
+
manager._model_cache['disease_model'] = None
|
| 174 |
+
manager._model_cache['disease_labels'] = []
|
| 175 |
+
|
| 176 |
+
with pytest.raises(RuntimeError, match="Disease model not loaded"):
|
| 177 |
+
manager.predict_disease(mock_pil_image)
|
| 178 |
+
|
| 179 |
+
def test_get_disease_remedy(self, mock_disease_model):
|
| 180 |
+
"""Test getting remedy for a disease."""
|
| 181 |
+
_, _, remedies = mock_disease_model
|
| 182 |
+
manager._model_cache['disease_remedies'] = remedies
|
| 183 |
+
|
| 184 |
+
remedy = manager.get_disease_remedy("bacterial_blight")
|
| 185 |
+
assert remedy == "Apply copper-based fungicide"
|
| 186 |
+
|
| 187 |
+
# Test non-existent disease
|
| 188 |
+
remedy = manager.get_disease_remedy("unknown_disease")
|
| 189 |
+
assert remedy is None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class TestLoanPrediction:
|
| 193 |
+
"""Tests for loan prediction models."""
|
| 194 |
+
|
| 195 |
+
def test_loan_models_in_cache(self, mock_loan_models):
|
| 196 |
+
"""Test that loan models can be cached."""
|
| 197 |
+
manager._model_cache['loan_price_model'] = mock_loan_models['price_model']
|
| 198 |
+
manager._model_cache['loan_approval_model'] = mock_loan_models['approval_model']
|
| 199 |
+
|
| 200 |
+
assert manager.is_model_loaded('loan_price_model')
|
| 201 |
+
assert manager.is_model_loaded('loan_approval_model')
|
| 202 |
+
|
| 203 |
+
def test_get_loan_model(self, mock_loan_models):
|
| 204 |
+
"""Test retrieving loan models from cache."""
|
| 205 |
+
manager._model_cache['loan_price_model'] = mock_loan_models['price_model']
|
| 206 |
+
|
| 207 |
+
model = manager.get_model('loan_price_model', auto_load=False)
|
| 208 |
+
assert model is not None
|
| 209 |
+
assert model == mock_loan_models['price_model']
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class TestBinaryCompatibilityHandling:
|
| 213 |
+
"""Tests explicit failure behavior when sklearn/scipy binaries are incompatible."""
|
| 214 |
+
|
| 215 |
+
def test_fertilizer_load_raises_on_numpy_binary_error(self):
|
| 216 |
+
from src.models import manager
|
| 217 |
+
manager._model_cache.clear()
|
| 218 |
+
|
| 219 |
+
with patch('src.models.manager._download_from_hf', return_value='/tmp/mock.pkl'), \
|
| 220 |
+
patch('builtins.open', side_effect=ImportError('numpy.core.multiarray failed to import')):
|
| 221 |
+
with pytest.raises(RuntimeError, match="NumPy/SciPy binary compatibility"):
|
| 222 |
+
manager.load_fertilizer_models()
|
| 223 |
+
|
| 224 |
+
def test_crop_load_raises_on_array_api_error(self):
|
| 225 |
+
from src.models import manager
|
| 226 |
+
manager._model_cache.clear()
|
| 227 |
+
|
| 228 |
+
with patch('src.models.manager.joblib.load', side_effect=AttributeError('_ARRAY_API not found')):
|
| 229 |
+
with pytest.raises(RuntimeError, match="NumPy/SciPy binary compatibility"):
|
| 230 |
+
manager.load_crop_recommendation_models()
|
| 231 |
+
|
| 232 |
+
def test_loan_load_raises_on_numpy_binary_error(self):
|
| 233 |
+
from src.models import manager
|
| 234 |
+
manager._model_cache.clear()
|
| 235 |
+
|
| 236 |
+
with patch('src.models.manager.joblib.load', side_effect=ImportError('numpy.core.multiarray failed to import')):
|
| 237 |
+
with pytest.raises(RuntimeError, match="NumPy/SciPy binary compatibility"):
|
| 238 |
+
manager.load_loan_models()
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@pytest.mark.parametrize("model_type,expected_keys", [
|
| 242 |
+
("crop", ["model", "standard_scaler", "minmax_scaler"]),
|
| 243 |
+
("fertilizer", ["classifier", "label_encoder"]),
|
| 244 |
+
("loan", ["price_model", "approval_model"]),
|
| 245 |
+
])
|
| 246 |
+
def test_model_loading_functions(model_type, expected_keys):
|
| 247 |
+
"""Parametrized test for model loading functions.
|
| 248 |
+
|
| 249 |
+
Note: This test uses real HF downloads and is slow.
|
| 250 |
+
It should be mocked or skipped in CI without HF access.
|
| 251 |
+
"""
|
| 252 |
+
pytest.skip("Skipping real HF download tests - use mocks instead")
|
| 253 |
+
|
| 254 |
+
if model_type == "crop":
|
| 255 |
+
models = manager.load_crop_recommendation_models()
|
| 256 |
+
elif model_type == "fertilizer":
|
| 257 |
+
models = manager.load_fertilizer_models()
|
| 258 |
+
elif model_type == "loan":
|
| 259 |
+
models = manager.load_loan_models()
|
| 260 |
+
|
| 261 |
+
assert isinstance(models, dict)
|
| 262 |
+
for key in expected_keys:
|
| 263 |
+
assert key in models
|
| 264 |
+
assert models[key] is not None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class TestModelInitialization:
|
| 268 |
+
"""Tests for model initialization."""
|
| 269 |
+
|
| 270 |
+
@patch('src.models.manager.load_disease_model')
|
| 271 |
+
@patch('src.models.manager.load_crop_recommendation_models')
|
| 272 |
+
@patch('src.models.manager.load_fertilizer_models')
|
| 273 |
+
@patch('src.models.manager.load_loan_models')
|
| 274 |
+
def test_initialize_models_all(
|
| 275 |
+
self, mock_loan, mock_fert, mock_crop, mock_disease
|
| 276 |
+
):
|
| 277 |
+
"""Test initializing all models."""
|
| 278 |
+
# Setup mocks
|
| 279 |
+
mock_disease.return_value = (MagicMock(), ["label1"], {"label1": "remedy"})
|
| 280 |
+
mock_crop.return_value = {
|
| 281 |
+
"model": MagicMock(),
|
| 282 |
+
"standard_scaler": MagicMock(),
|
| 283 |
+
"minmax_scaler": MagicMock()
|
| 284 |
+
}
|
| 285 |
+
mock_fert.return_value = {
|
| 286 |
+
"classifier": MagicMock(),
|
| 287 |
+
"label_encoder": MagicMock()
|
| 288 |
+
}
|
| 289 |
+
mock_loan.return_value = {
|
| 290 |
+
"price_model": MagicMock(),
|
| 291 |
+
"approval_model": MagicMock()
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
manager._model_cache.clear()
|
| 295 |
+
manager.initialize_models(load_all=True)
|
| 296 |
+
|
| 297 |
+
# Verify all loaders were called
|
| 298 |
+
mock_disease.assert_called_once()
|
| 299 |
+
mock_crop.assert_called_once()
|
| 300 |
+
mock_fert.assert_called_once()
|
| 301 |
+
mock_loan.assert_called_once()
|
| 302 |
+
|
| 303 |
+
# Verify models are in cache
|
| 304 |
+
assert 'disease_model' in manager._model_cache
|
| 305 |
+
assert 'crop_model' in manager._model_cache
|
| 306 |
+
assert 'fertilizer_classifier' in manager._model_cache
|
| 307 |
+
assert 'loan_price_model' in manager._model_cache
|
| 308 |
+
|
| 309 |
+
def test_initialize_models_lazy(self):
|
| 310 |
+
"""Test lazy initialization (don't load all at startup)."""
|
| 311 |
+
manager._model_cache.clear()
|
| 312 |
+
manager.initialize_models(load_all=False)
|
| 313 |
+
|
| 314 |
+
# Cache should remain empty with lazy loading
|
| 315 |
+
assert len(manager._model_cache) == 0
|
| 316 |
+
|
| 317 |
+
@patch('src.models.manager.load_disease_model')
|
| 318 |
+
def test_initialize_handles_load_failure(self, mock_disease):
|
| 319 |
+
"""Test that initialization continues even if a model fails to load."""
|
| 320 |
+
mock_disease.side_effect = Exception("HF Hub connection failed")
|
| 321 |
+
|
| 322 |
+
manager._model_cache.clear()
|
| 323 |
+
# Should not raise, just log error
|
| 324 |
+
manager.initialize_models(load_all=True)
|
| 325 |
+
|
| 326 |
+
# Disease model should be None in cache
|
| 327 |
+
assert manager._model_cache.get('disease_model') is None
|
| 328 |
+
assert manager._model_cache.get('disease_labels') == []
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Dockerfile (HF Spaces compatible)
|
| 2 |
+
|
| 3 |
+
FROM node:22-alpine as base
|
| 4 |
+
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install dependencies for native modules + wget for healthcheck
|
| 8 |
+
RUN apk add --no-cache python3 make g++ wget
|
| 9 |
+
|
| 10 |
+
# Copy package files
|
| 11 |
+
COPY package*.json ./
|
| 12 |
+
|
| 13 |
+
# ========================
|
| 14 |
+
# Development stage
|
| 15 |
+
# ========================
|
| 16 |
+
FROM base as development
|
| 17 |
+
RUN npm ci
|
| 18 |
+
COPY . .
|
| 19 |
+
EXPOSE 7860
|
| 20 |
+
CMD ["npm", "run", "dev"]
|
| 21 |
+
|
| 22 |
+
# ========================
|
| 23 |
+
# Production stage
|
| 24 |
+
# ========================
|
| 25 |
+
FROM base as production
|
| 26 |
+
|
| 27 |
+
ENV NODE_ENV=production
|
| 28 |
+
|
| 29 |
+
# Install production dependencies only
|
| 30 |
+
RUN npm ci --omit=dev
|
| 31 |
+
|
| 32 |
+
# Copy source files
|
| 33 |
+
COPY . .
|
| 34 |
+
|
| 35 |
+
# Create non-root user (uid 1000 required by HF Spaces)
|
| 36 |
+
RUN set -ex && \
|
| 37 |
+
if ! getent group 1000 > /dev/null 2>&1; then \
|
| 38 |
+
addgroup -g 1000 -S nodejs; \
|
| 39 |
+
fi && \
|
| 40 |
+
GROUP_NAME=$(getent group 1000 | cut -d: -f1) && \
|
| 41 |
+
if ! getent passwd 1000 > /dev/null 2>&1; then \
|
| 42 |
+
adduser -D -u 1000 -G ${GROUP_NAME} nodejs; \
|
| 43 |
+
fi && \
|
| 44 |
+
chown -R 1000:1000 /app
|
| 45 |
+
|
| 46 |
+
USER 1000
|
| 47 |
+
|
| 48 |
+
# EXPOSE is informational (HF ignores it but safe to keep)
|
| 49 |
+
EXPOSE 7860
|
| 50 |
+
|
| 51 |
+
# Healthcheck probes backend on port 7860
|
| 52 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
| 53 |
+
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:7860/health || exit 1
|
| 54 |
+
|
| 55 |
+
CMD ["node", "server.js"]
|
backend/FIREBASE_FIRESTORE_SETUP.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Firebase Auth and Firestore REST setup
|
| 2 |
+
|
| 3 |
+
AgroMind uses **Firebase Authentication** for identity and **Cloud Firestore** for application data. The backend does not use MongoDB, a custom JWT secret, `firebase-admin`, a service-account JSON file, Google Application Default Credentials, or Google Cloud IAM.
|
| 4 |
+
|
| 5 |
+
## Required deployment configuration
|
| 6 |
+
|
| 7 |
+
Set the following Hugging Face Space secrets:
|
| 8 |
+
|
| 9 |
+
| Secret | Value |
|
| 10 |
+
|---|---|
|
| 11 |
+
| `FIREBASE_PROJECT_ID` | `agromind-a62c1` |
|
| 12 |
+
| `FIREBASE_API_KEY` | The Firebase Web API key from the Firebase app configuration |
|
| 13 |
+
|
| 14 |
+
The API key is used only to call Firebase Identity Toolkit's `accounts:lookup` endpoint. The browser sends a short-lived Firebase ID token to the backend. Firestore REST requests use that same user token as a bearer token, so Firestore Security Rules—not a privileged server credential—authorize the data operation.
|
| 15 |
+
|
| 16 |
+
The frontend Vercel deployment continues to use the normal `VITE_FIREBASE_*` Web SDK configuration. Never place service-account JSON, private keys, GitHub tokens, or Hugging Face write tokens in the repository or in `VITE_*` variables.
|
| 17 |
+
|
| 18 |
+
## Authentication and persistence flow
|
| 19 |
+
|
| 20 |
+
The browser signs users in with Firebase Auth using email/password or Google. `newRequest` attaches the current Firebase ID token to protected API calls. The backend verifies that token with Firebase Identity Toolkit, attaches the verified Firebase UID to the request, and carries the token into the REST-backed Firestore compatibility adapter. The adapter preserves the existing model surface while applying Firestore rules to each read and write.
|
| 21 |
+
|
| 22 |
+
The browser stores no custom JWT. A tab-scoped session record keeps the Profile login time stable across refreshes and is cleared on explicit logout or when the tab is closed. The Profile page also reads and upserts the user's `users/{uid}` document with the Firebase Web SDK repository.
|
| 23 |
+
|
| 24 |
+
## Firestore data model
|
| 25 |
+
|
| 26 |
+
Former application models remain top-level Firestore collections such as `users`, `appointments`, `crops`, `farmerDetails`, `tasks`, `records`, `posts`, `notifications`, `milletListings`, `oilPalmProfiles`, and feature-specific collections. User references are Firebase Auth UIDs or Firestore document IDs; passwords are never stored.
|
| 27 |
+
|
| 28 |
+
## Deployment checklist
|
| 29 |
+
|
| 30 |
+
1. Add `FIREBASE_PROJECT_ID` and `FIREBASE_API_KEY` to the Hugging Face Space secrets.
|
| 31 |
+
2. Deploy the rules in `firestore.rules` using the Firebase CLI or the Firebase Console's Rules editor.
|
| 32 |
+
3. Confirm the backend health endpoint returns `services.firestore = "user-token-rest"` and does not attempt a startup Firestore connection.
|
| 33 |
+
4. Confirm a signed-in user can load Profile and perform one representative protected read and write.
|
| 34 |
+
5. Confirm an unauthenticated request receives HTTP 401 and a user cannot write another user's owner-scoped document.
|
backend/contracts/AgroExchange.sol
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// SPDX-License-Identifier: MIT
|
| 2 |
+
pragma solidity ^0.8.19;
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* @title AgroExchange
|
| 6 |
+
* @dev Escrow contract for agricultural commodity transactions
|
| 7 |
+
* @notice This contract facilitates secure transactions between farmers and buyers
|
| 8 |
+
*/
|
| 9 |
+
contract AgroExchange {
|
| 10 |
+
// State variables
|
| 11 |
+
address public owner;
|
| 12 |
+
uint256 public transactionCount;
|
| 13 |
+
uint256 public platformFeePercent; // in basis points (100 = 1%)
|
| 14 |
+
|
| 15 |
+
// Structs
|
| 16 |
+
struct Transaction {
|
| 17 |
+
uint256 id;
|
| 18 |
+
address payable seller;
|
| 19 |
+
address payable buyer;
|
| 20 |
+
uint256 amount;
|
| 21 |
+
string listingId; // MongoDB listing ID
|
| 22 |
+
TransactionState state;
|
| 23 |
+
uint256 createdAt;
|
| 24 |
+
uint256 releasedAt;
|
| 25 |
+
string productType;
|
| 26 |
+
uint256 quantityKg;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
enum TransactionState {
|
| 30 |
+
Created,
|
| 31 |
+
Funded,
|
| 32 |
+
Delivered,
|
| 33 |
+
Completed,
|
| 34 |
+
Disputed,
|
| 35 |
+
Refunded,
|
| 36 |
+
Cancelled
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Mappings
|
| 40 |
+
mapping(uint256 => Transaction) public transactions;
|
| 41 |
+
mapping(address => uint256[]) public userTransactions;
|
| 42 |
+
mapping(string => uint256) public listingToTransaction;
|
| 43 |
+
|
| 44 |
+
// Events
|
| 45 |
+
event TransactionCreated(
|
| 46 |
+
uint256 indexed transactionId,
|
| 47 |
+
address indexed seller,
|
| 48 |
+
address indexed buyer,
|
| 49 |
+
uint256 amount,
|
| 50 |
+
string listingId
|
| 51 |
+
);
|
| 52 |
+
|
| 53 |
+
event TransactionFunded(
|
| 54 |
+
uint256 indexed transactionId,
|
| 55 |
+
address indexed buyer,
|
| 56 |
+
uint256 amount
|
| 57 |
+
);
|
| 58 |
+
|
| 59 |
+
event DeliveryConfirmed(
|
| 60 |
+
uint256 indexed transactionId,
|
| 61 |
+
address indexed buyer
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
event FundsReleased(
|
| 65 |
+
uint256 indexed transactionId,
|
| 66 |
+
address indexed seller,
|
| 67 |
+
uint256 amount
|
| 68 |
+
);
|
| 69 |
+
|
| 70 |
+
event TransactionDisputed(
|
| 71 |
+
uint256 indexed transactionId,
|
| 72 |
+
address indexed disputer,
|
| 73 |
+
string reason
|
| 74 |
+
);
|
| 75 |
+
|
| 76 |
+
event DisputeResolved(
|
| 77 |
+
uint256 indexed transactionId,
|
| 78 |
+
address indexed winner,
|
| 79 |
+
uint256 amount
|
| 80 |
+
);
|
| 81 |
+
|
| 82 |
+
event TransactionRefunded(
|
| 83 |
+
uint256 indexed transactionId,
|
| 84 |
+
address indexed buyer,
|
| 85 |
+
uint256 amount
|
| 86 |
+
);
|
| 87 |
+
|
| 88 |
+
event TransactionCancelled(
|
| 89 |
+
uint256 indexed transactionId
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
// Modifiers
|
| 93 |
+
modifier onlyOwner() {
|
| 94 |
+
require(msg.sender == owner, "Only owner can call this function");
|
| 95 |
+
_;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
modifier onlySeller(uint256 _transactionId) {
|
| 99 |
+
require(
|
| 100 |
+
msg.sender == transactions[_transactionId].seller,
|
| 101 |
+
"Only seller can call this function"
|
| 102 |
+
);
|
| 103 |
+
_;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
modifier onlyBuyer(uint256 _transactionId) {
|
| 107 |
+
require(
|
| 108 |
+
msg.sender == transactions[_transactionId].buyer,
|
| 109 |
+
"Only buyer can call this function"
|
| 110 |
+
);
|
| 111 |
+
_;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
modifier onlyParties(uint256 _transactionId) {
|
| 115 |
+
require(
|
| 116 |
+
msg.sender == transactions[_transactionId].seller ||
|
| 117 |
+
msg.sender == transactions[_transactionId].buyer,
|
| 118 |
+
"Only transaction parties can call this function"
|
| 119 |
+
);
|
| 120 |
+
_;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
modifier inState(uint256 _transactionId, TransactionState _state) {
|
| 124 |
+
require(
|
| 125 |
+
transactions[_transactionId].state == _state,
|
| 126 |
+
"Transaction is not in the required state"
|
| 127 |
+
);
|
| 128 |
+
_;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// Constructor
|
| 132 |
+
constructor() {
|
| 133 |
+
owner = msg.sender;
|
| 134 |
+
platformFeePercent = 100; // 1% platform fee
|
| 135 |
+
transactionCount = 0;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* @dev Create a new escrow transaction
|
| 140 |
+
* @param _seller Address of the seller
|
| 141 |
+
* @param _listingId MongoDB listing ID for reference
|
| 142 |
+
* @param _productType Type of product being sold
|
| 143 |
+
* @param _quantityKg Quantity in kilograms
|
| 144 |
+
*/
|
| 145 |
+
function createTransaction(
|
| 146 |
+
address payable _seller,
|
| 147 |
+
string memory _listingId,
|
| 148 |
+
string memory _productType,
|
| 149 |
+
uint256 _quantityKg
|
| 150 |
+
) external payable returns (uint256) {
|
| 151 |
+
require(_seller != address(0), "Invalid seller address");
|
| 152 |
+
require(_seller != msg.sender, "Seller cannot be buyer");
|
| 153 |
+
require(msg.value > 0, "Transaction amount must be greater than 0");
|
| 154 |
+
require(bytes(_listingId).length > 0, "Listing ID required");
|
| 155 |
+
require(listingToTransaction[_listingId] == 0, "Transaction already exists for this listing");
|
| 156 |
+
|
| 157 |
+
transactionCount++;
|
| 158 |
+
uint256 transactionId = transactionCount;
|
| 159 |
+
|
| 160 |
+
transactions[transactionId] = Transaction({
|
| 161 |
+
id: transactionId,
|
| 162 |
+
seller: _seller,
|
| 163 |
+
buyer: payable(msg.sender),
|
| 164 |
+
amount: msg.value,
|
| 165 |
+
listingId: _listingId,
|
| 166 |
+
state: TransactionState.Funded,
|
| 167 |
+
createdAt: block.timestamp,
|
| 168 |
+
releasedAt: 0,
|
| 169 |
+
productType: _productType,
|
| 170 |
+
quantityKg: _quantityKg
|
| 171 |
+
});
|
| 172 |
+
|
| 173 |
+
userTransactions[_seller].push(transactionId);
|
| 174 |
+
userTransactions[msg.sender].push(transactionId);
|
| 175 |
+
listingToTransaction[_listingId] = transactionId;
|
| 176 |
+
|
| 177 |
+
emit TransactionCreated(transactionId, _seller, msg.sender, msg.value, _listingId);
|
| 178 |
+
emit TransactionFunded(transactionId, msg.sender, msg.value);
|
| 179 |
+
|
| 180 |
+
return transactionId;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/**
|
| 184 |
+
* @dev Buyer confirms delivery and releases funds to seller
|
| 185 |
+
* @param _transactionId ID of the transaction
|
| 186 |
+
*/
|
| 187 |
+
function confirmDelivery(uint256 _transactionId)
|
| 188 |
+
external
|
| 189 |
+
onlyBuyer(_transactionId)
|
| 190 |
+
inState(_transactionId, TransactionState.Funded)
|
| 191 |
+
{
|
| 192 |
+
Transaction storage txn = transactions[_transactionId];
|
| 193 |
+
|
| 194 |
+
txn.state = TransactionState.Completed;
|
| 195 |
+
txn.releasedAt = block.timestamp;
|
| 196 |
+
|
| 197 |
+
// Calculate platform fee
|
| 198 |
+
uint256 platformFee = (txn.amount * platformFeePercent) / 10000;
|
| 199 |
+
uint256 sellerAmount = txn.amount - platformFee;
|
| 200 |
+
|
| 201 |
+
// Transfer funds
|
| 202 |
+
txn.seller.transfer(sellerAmount);
|
| 203 |
+
payable(owner).transfer(platformFee);
|
| 204 |
+
|
| 205 |
+
emit DeliveryConfirmed(_transactionId, msg.sender);
|
| 206 |
+
emit FundsReleased(_transactionId, txn.seller, sellerAmount);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
/**
|
| 210 |
+
* @dev Raise a dispute for a transaction
|
| 211 |
+
* @param _transactionId ID of the transaction
|
| 212 |
+
* @param _reason Reason for dispute
|
| 213 |
+
*/
|
| 214 |
+
function raiseDispute(uint256 _transactionId, string memory _reason)
|
| 215 |
+
external
|
| 216 |
+
onlyParties(_transactionId)
|
| 217 |
+
inState(_transactionId, TransactionState.Funded)
|
| 218 |
+
{
|
| 219 |
+
transactions[_transactionId].state = TransactionState.Disputed;
|
| 220 |
+
|
| 221 |
+
emit TransactionDisputed(_transactionId, msg.sender, _reason);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* @dev Resolve a dispute (only owner/arbitrator can call)
|
| 226 |
+
* @param _transactionId ID of the transaction
|
| 227 |
+
* @param _refundBuyer If true, refund buyer; if false, release to seller
|
| 228 |
+
*/
|
| 229 |
+
function resolveDispute(uint256 _transactionId, bool _refundBuyer)
|
| 230 |
+
external
|
| 231 |
+
onlyOwner
|
| 232 |
+
inState(_transactionId, TransactionState.Disputed)
|
| 233 |
+
{
|
| 234 |
+
Transaction storage txn = transactions[_transactionId];
|
| 235 |
+
|
| 236 |
+
if (_refundBuyer) {
|
| 237 |
+
txn.state = TransactionState.Refunded;
|
| 238 |
+
txn.buyer.transfer(txn.amount);
|
| 239 |
+
emit DisputeResolved(_transactionId, txn.buyer, txn.amount);
|
| 240 |
+
emit TransactionRefunded(_transactionId, txn.buyer, txn.amount);
|
| 241 |
+
} else {
|
| 242 |
+
txn.state = TransactionState.Completed;
|
| 243 |
+
txn.releasedAt = block.timestamp;
|
| 244 |
+
|
| 245 |
+
uint256 platformFee = (txn.amount * platformFeePercent) / 10000;
|
| 246 |
+
uint256 sellerAmount = txn.amount - platformFee;
|
| 247 |
+
|
| 248 |
+
txn.seller.transfer(sellerAmount);
|
| 249 |
+
payable(owner).transfer(platformFee);
|
| 250 |
+
|
| 251 |
+
emit DisputeResolved(_transactionId, txn.seller, sellerAmount);
|
| 252 |
+
emit FundsReleased(_transactionId, txn.seller, sellerAmount);
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
/**
|
| 257 |
+
* @dev Cancel a transaction (only if not yet funded or both parties agree)
|
| 258 |
+
* @param _transactionId ID of the transaction
|
| 259 |
+
*/
|
| 260 |
+
function cancelTransaction(uint256 _transactionId)
|
| 261 |
+
external
|
| 262 |
+
onlyParties(_transactionId)
|
| 263 |
+
{
|
| 264 |
+
Transaction storage txn = transactions[_transactionId];
|
| 265 |
+
|
| 266 |
+
require(
|
| 267 |
+
txn.state == TransactionState.Created ||
|
| 268 |
+
txn.state == TransactionState.Funded,
|
| 269 |
+
"Cannot cancel transaction in current state"
|
| 270 |
+
);
|
| 271 |
+
|
| 272 |
+
if (txn.state == TransactionState.Funded) {
|
| 273 |
+
// Refund buyer
|
| 274 |
+
txn.state = TransactionState.Cancelled;
|
| 275 |
+
txn.buyer.transfer(txn.amount);
|
| 276 |
+
emit TransactionRefunded(_transactionId, txn.buyer, txn.amount);
|
| 277 |
+
} else {
|
| 278 |
+
txn.state = TransactionState.Cancelled;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
emit TransactionCancelled(_transactionId);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/**
|
| 285 |
+
* @dev Auto-release funds after timeout (14 days)
|
| 286 |
+
* @param _transactionId ID of the transaction
|
| 287 |
+
*/
|
| 288 |
+
function autoRelease(uint256 _transactionId)
|
| 289 |
+
external
|
| 290 |
+
inState(_transactionId, TransactionState.Funded)
|
| 291 |
+
{
|
| 292 |
+
Transaction storage txn = transactions[_transactionId];
|
| 293 |
+
|
| 294 |
+
require(
|
| 295 |
+
block.timestamp >= txn.createdAt + 14 days,
|
| 296 |
+
"Auto-release period not yet reached"
|
| 297 |
+
);
|
| 298 |
+
|
| 299 |
+
txn.state = TransactionState.Completed;
|
| 300 |
+
txn.releasedAt = block.timestamp;
|
| 301 |
+
|
| 302 |
+
uint256 platformFee = (txn.amount * platformFeePercent) / 10000;
|
| 303 |
+
uint256 sellerAmount = txn.amount - platformFee;
|
| 304 |
+
|
| 305 |
+
txn.seller.transfer(sellerAmount);
|
| 306 |
+
payable(owner).transfer(platformFee);
|
| 307 |
+
|
| 308 |
+
emit FundsReleased(_transactionId, txn.seller, sellerAmount);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// View functions
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* @dev Get transaction details
|
| 315 |
+
* @param _transactionId ID of the transaction
|
| 316 |
+
*/
|
| 317 |
+
function getTransaction(uint256 _transactionId)
|
| 318 |
+
external
|
| 319 |
+
view
|
| 320 |
+
returns (Transaction memory)
|
| 321 |
+
{
|
| 322 |
+
require(_transactionId > 0 && _transactionId <= transactionCount, "Invalid transaction ID");
|
| 323 |
+
return transactions[_transactionId];
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
/**
|
| 327 |
+
* @dev Get user's transactions
|
| 328 |
+
* @param _user Address of the user
|
| 329 |
+
*/
|
| 330 |
+
function getUserTransactions(address _user)
|
| 331 |
+
external
|
| 332 |
+
view
|
| 333 |
+
returns (uint256[] memory)
|
| 334 |
+
{
|
| 335 |
+
return userTransactions[_user];
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
/**
|
| 339 |
+
* @dev Get transaction ID by listing ID
|
| 340 |
+
* @param _listingId MongoDB listing ID
|
| 341 |
+
*/
|
| 342 |
+
function getTransactionByListing(string memory _listingId)
|
| 343 |
+
external
|
| 344 |
+
view
|
| 345 |
+
returns (uint256)
|
| 346 |
+
{
|
| 347 |
+
return listingToTransaction[_listingId];
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
/**
|
| 351 |
+
* @dev Get contract balance
|
| 352 |
+
*/
|
| 353 |
+
function getContractBalance() external view returns (uint256) {
|
| 354 |
+
return address(this).balance;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// Admin functions
|
| 358 |
+
|
| 359 |
+
/**
|
| 360 |
+
* @dev Update platform fee (only owner)
|
| 361 |
+
* @param _newFeePercent New fee in basis points
|
| 362 |
+
*/
|
| 363 |
+
function updatePlatformFee(uint256 _newFeePercent) external onlyOwner {
|
| 364 |
+
require(_newFeePercent <= 500, "Fee cannot exceed 5%");
|
| 365 |
+
platformFeePercent = _newFeePercent;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
/**
|
| 369 |
+
* @dev Transfer ownership (only owner)
|
| 370 |
+
* @param _newOwner Address of new owner
|
| 371 |
+
*/
|
| 372 |
+
function transferOwnership(address _newOwner) external onlyOwner {
|
| 373 |
+
require(_newOwner != address(0), "Invalid address");
|
| 374 |
+
owner = _newOwner;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
/**
|
| 378 |
+
* @dev Emergency withdraw (only owner, for stuck funds)
|
| 379 |
+
*/
|
| 380 |
+
function emergencyWithdraw() external onlyOwner {
|
| 381 |
+
payable(owner).transfer(address(this).balance);
|
| 382 |
+
}
|
| 383 |
+
}
|
backend/contracts/hardhat.config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
require("@nomicfoundation/hardhat-toolbox");
|
| 2 |
+
require("dotenv").config();
|
| 3 |
+
|
| 4 |
+
/** @type import('hardhat/config').HardhatUserConfig */
|
| 5 |
+
module.exports = {
|
| 6 |
+
solidity: {
|
| 7 |
+
version: "0.8.19",
|
| 8 |
+
settings: {
|
| 9 |
+
optimizer: {
|
| 10 |
+
enabled: true,
|
| 11 |
+
runs: 200,
|
| 12 |
+
},
|
| 13 |
+
},
|
| 14 |
+
},
|
| 15 |
+
networks: {
|
| 16 |
+
localhost: {
|
| 17 |
+
url: "http://127.0.0.1:8545",
|
| 18 |
+
},
|
| 19 |
+
hardhat: {
|
| 20 |
+
chainId: 31337,
|
| 21 |
+
},
|
| 22 |
+
sepolia: {
|
| 23 |
+
url: process.env.BLOCKCHAIN_RPC_URL || "",
|
| 24 |
+
accounts: process.env.BLOCKCHAIN_PRIVATE_KEY
|
| 25 |
+
? [process.env.BLOCKCHAIN_PRIVATE_KEY]
|
| 26 |
+
: [],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
paths: {
|
| 30 |
+
sources: "./",
|
| 31 |
+
tests: "./test",
|
| 32 |
+
cache: "./cache",
|
| 33 |
+
artifacts: "./artifacts",
|
| 34 |
+
},
|
| 35 |
+
};
|
backend/contracts/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/contracts/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "agromind-contracts",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Smart contracts for AgroMind escrow and transactions",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"compile": "npx hardhat compile",
|
| 7 |
+
"test": "npx hardhat test",
|
| 8 |
+
"deploy:local": "npx hardhat run scripts/deploy.js --network localhost",
|
| 9 |
+
"deploy:sepolia": "npx hardhat run scripts/deploy.js --network sepolia",
|
| 10 |
+
"node": "npx hardhat node"
|
| 11 |
+
},
|
| 12 |
+
"devDependencies": {
|
| 13 |
+
"@nomicfoundation/hardhat-toolbox": "^4.0.0",
|
| 14 |
+
"hardhat": "^2.19.0"
|
| 15 |
+
},
|
| 16 |
+
"dependencies": {
|
| 17 |
+
"ethers": "^6.9.0"
|
| 18 |
+
}
|
| 19 |
+
}
|
backend/contracts/scripts/deploy.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { ethers } = require("hardhat");
|
| 2 |
+
|
| 3 |
+
async function main() {
|
| 4 |
+
console.log("Deploying AgroExchange contract...");
|
| 5 |
+
|
| 6 |
+
const [deployer] = await ethers.getSigners();
|
| 7 |
+
console.log("Deploying with account:", deployer.address);
|
| 8 |
+
|
| 9 |
+
const balance = await ethers.provider.getBalance(deployer.address);
|
| 10 |
+
console.log("Account balance:", ethers.formatEther(balance), "ETH");
|
| 11 |
+
|
| 12 |
+
// Deploy contract
|
| 13 |
+
const AgroExchange = await ethers.getContractFactory("AgroExchange");
|
| 14 |
+
const agroExchange = await AgroExchange.deploy();
|
| 15 |
+
|
| 16 |
+
await agroExchange.waitForDeployment();
|
| 17 |
+
|
| 18 |
+
const address = await agroExchange.getAddress();
|
| 19 |
+
console.log("AgroExchange deployed to:", address);
|
| 20 |
+
|
| 21 |
+
// Log deployment info
|
| 22 |
+
console.log("\n=== Deployment Summary ===");
|
| 23 |
+
console.log("Contract Address:", address);
|
| 24 |
+
console.log("Owner:", deployer.address);
|
| 25 |
+
console.log("Network:", network.name); // eslint-disable-line no-undef
|
| 26 |
+
console.log("Gas Used:", (await agroExchange.deploymentTransaction().wait()).gasUsed.toString());
|
| 27 |
+
|
| 28 |
+
// Verify contract settings
|
| 29 |
+
const platformFee = await agroExchange.platformFeePercent();
|
| 30 |
+
console.log("Platform Fee:", platformFee.toString(), "basis points (", Number(platformFee) / 100, "%)");
|
| 31 |
+
|
| 32 |
+
return address;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
main()
|
| 36 |
+
.then(() => process.exit(0))
|
| 37 |
+
.catch((error) => {
|
| 38 |
+
console.error(error);
|
| 39 |
+
process.exit(1);
|
| 40 |
+
});
|
backend/contracts/test/AgroExchange.test.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { expect } = require("chai");
|
| 2 |
+
const { ethers } = require("hardhat");
|
| 3 |
+
|
| 4 |
+
describe("AgroExchange", function () {
|
| 5 |
+
let agroExchange;
|
| 6 |
+
let owner;
|
| 7 |
+
let seller;
|
| 8 |
+
let buyer;
|
| 9 |
+
let addr3;
|
| 10 |
+
|
| 11 |
+
const listingId = "listing123";
|
| 12 |
+
const productType = "groundnut";
|
| 13 |
+
const quantityKg = 1000;
|
| 14 |
+
const transactionAmount = ethers.parseEther("1.0");
|
| 15 |
+
|
| 16 |
+
beforeEach(async function () {
|
| 17 |
+
[owner, seller, buyer, addr3] = await ethers.getSigners();
|
| 18 |
+
|
| 19 |
+
const AgroExchange = await ethers.getContractFactory("AgroExchange");
|
| 20 |
+
agroExchange = await AgroExchange.deploy();
|
| 21 |
+
await agroExchange.waitForDeployment();
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
describe("Deployment", function () {
|
| 25 |
+
it("Should set the right owner", async function () {
|
| 26 |
+
expect(await agroExchange.owner()).to.equal(owner.address);
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
it("Should have correct initial platform fee", async function () {
|
| 30 |
+
expect(await agroExchange.platformFeePercent()).to.equal(100); // 1%
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
it("Should have zero transaction count initially", async function () {
|
| 34 |
+
expect(await agroExchange.transactionCount()).to.equal(0);
|
| 35 |
+
});
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
describe("Transaction Creation", function () {
|
| 39 |
+
it("Should create a transaction successfully", async function () {
|
| 40 |
+
await expect(
|
| 41 |
+
agroExchange.connect(buyer).createTransaction(
|
| 42 |
+
seller.address,
|
| 43 |
+
listingId,
|
| 44 |
+
productType,
|
| 45 |
+
quantityKg,
|
| 46 |
+
{ value: transactionAmount }
|
| 47 |
+
)
|
| 48 |
+
)
|
| 49 |
+
.to.emit(agroExchange, "TransactionCreated")
|
| 50 |
+
.withArgs(1, seller.address, buyer.address, transactionAmount, listingId);
|
| 51 |
+
|
| 52 |
+
expect(await agroExchange.transactionCount()).to.equal(1);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
it("Should reject if seller is buyer", async function () {
|
| 56 |
+
await expect(
|
| 57 |
+
agroExchange.connect(buyer).createTransaction(
|
| 58 |
+
buyer.address,
|
| 59 |
+
listingId,
|
| 60 |
+
productType,
|
| 61 |
+
quantityKg,
|
| 62 |
+
{ value: transactionAmount }
|
| 63 |
+
)
|
| 64 |
+
).to.be.revertedWith("Seller cannot be buyer");
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it("Should reject if amount is zero", async function () {
|
| 68 |
+
await expect(
|
| 69 |
+
agroExchange.connect(buyer).createTransaction(
|
| 70 |
+
seller.address,
|
| 71 |
+
listingId,
|
| 72 |
+
productType,
|
| 73 |
+
quantityKg,
|
| 74 |
+
{ value: 0 }
|
| 75 |
+
)
|
| 76 |
+
).to.be.revertedWith("Transaction amount must be greater than 0");
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
it("Should reject duplicate listing", async function () {
|
| 80 |
+
await agroExchange.connect(buyer).createTransaction(
|
| 81 |
+
seller.address,
|
| 82 |
+
listingId,
|
| 83 |
+
productType,
|
| 84 |
+
quantityKg,
|
| 85 |
+
{ value: transactionAmount }
|
| 86 |
+
);
|
| 87 |
+
|
| 88 |
+
await expect(
|
| 89 |
+
agroExchange.connect(addr3).createTransaction(
|
| 90 |
+
seller.address,
|
| 91 |
+
listingId,
|
| 92 |
+
productType,
|
| 93 |
+
quantityKg,
|
| 94 |
+
{ value: transactionAmount }
|
| 95 |
+
)
|
| 96 |
+
).to.be.revertedWith("Transaction already exists for this listing");
|
| 97 |
+
});
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
describe("Delivery Confirmation", function () {
|
| 101 |
+
beforeEach(async function () {
|
| 102 |
+
await agroExchange.connect(buyer).createTransaction(
|
| 103 |
+
seller.address,
|
| 104 |
+
listingId,
|
| 105 |
+
productType,
|
| 106 |
+
quantityKg,
|
| 107 |
+
{ value: transactionAmount }
|
| 108 |
+
);
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
it("Should confirm delivery and release funds", async function () {
|
| 112 |
+
const sellerBalanceBefore = await ethers.provider.getBalance(seller.address);
|
| 113 |
+
|
| 114 |
+
await expect(agroExchange.connect(buyer).confirmDelivery(1))
|
| 115 |
+
.to.emit(agroExchange, "DeliveryConfirmed")
|
| 116 |
+
.to.emit(agroExchange, "FundsReleased");
|
| 117 |
+
|
| 118 |
+
const sellerBalanceAfter = await ethers.provider.getBalance(seller.address);
|
| 119 |
+
|
| 120 |
+
// Seller should receive 99% (1% platform fee)
|
| 121 |
+
const expectedAmount = transactionAmount * BigInt(9900) / BigInt(10000);
|
| 122 |
+
expect(sellerBalanceAfter - sellerBalanceBefore).to.equal(expectedAmount);
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it("Should reject if not buyer", async function () {
|
| 126 |
+
await expect(
|
| 127 |
+
agroExchange.connect(seller).confirmDelivery(1)
|
| 128 |
+
).to.be.revertedWith("Only buyer can call this function");
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
it("Should update transaction state to Completed", async function () {
|
| 132 |
+
await agroExchange.connect(buyer).confirmDelivery(1);
|
| 133 |
+
|
| 134 |
+
const txn = await agroExchange.getTransaction(1);
|
| 135 |
+
expect(txn.state).to.equal(4); // Completed state
|
| 136 |
+
});
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
describe("Disputes", function () {
|
| 140 |
+
beforeEach(async function () {
|
| 141 |
+
await agroExchange.connect(buyer).createTransaction(
|
| 142 |
+
seller.address,
|
| 143 |
+
listingId,
|
| 144 |
+
productType,
|
| 145 |
+
quantityKg,
|
| 146 |
+
{ value: transactionAmount }
|
| 147 |
+
);
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
it("Should allow buyer to raise dispute", async function () {
|
| 151 |
+
await expect(
|
| 152 |
+
agroExchange.connect(buyer).raiseDispute(1, "Product not as described")
|
| 153 |
+
)
|
| 154 |
+
.to.emit(agroExchange, "TransactionDisputed")
|
| 155 |
+
.withArgs(1, buyer.address, "Product not as described");
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
it("Should allow seller to raise dispute", async function () {
|
| 159 |
+
await expect(
|
| 160 |
+
agroExchange.connect(seller).raiseDispute(1, "Buyer not responding")
|
| 161 |
+
)
|
| 162 |
+
.to.emit(agroExchange, "TransactionDisputed")
|
| 163 |
+
.withArgs(1, seller.address, "Buyer not responding");
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
it("Should resolve dispute in favor of buyer", async function () {
|
| 167 |
+
await agroExchange.connect(buyer).raiseDispute(1, "Product not as described");
|
| 168 |
+
|
| 169 |
+
const buyerBalanceBefore = await ethers.provider.getBalance(buyer.address);
|
| 170 |
+
|
| 171 |
+
await expect(agroExchange.connect(owner).resolveDispute(1, true))
|
| 172 |
+
.to.emit(agroExchange, "DisputeResolved")
|
| 173 |
+
.to.emit(agroExchange, "TransactionRefunded");
|
| 174 |
+
|
| 175 |
+
const buyerBalanceAfter = await ethers.provider.getBalance(buyer.address);
|
| 176 |
+
expect(buyerBalanceAfter - buyerBalanceBefore).to.equal(transactionAmount);
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
it("Should resolve dispute in favor of seller", async function () {
|
| 180 |
+
await agroExchange.connect(buyer).raiseDispute(1, "Product not as described");
|
| 181 |
+
|
| 182 |
+
const sellerBalanceBefore = await ethers.provider.getBalance(seller.address);
|
| 183 |
+
|
| 184 |
+
await expect(agroExchange.connect(owner).resolveDispute(1, false))
|
| 185 |
+
.to.emit(agroExchange, "DisputeResolved")
|
| 186 |
+
.to.emit(agroExchange, "FundsReleased");
|
| 187 |
+
|
| 188 |
+
const sellerBalanceAfter = await ethers.provider.getBalance(seller.address);
|
| 189 |
+
const expectedAmount = transactionAmount * BigInt(9900) / BigInt(10000);
|
| 190 |
+
expect(sellerBalanceAfter - sellerBalanceBefore).to.equal(expectedAmount);
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
it("Should reject dispute resolution from non-owner", async function () {
|
| 194 |
+
await agroExchange.connect(buyer).raiseDispute(1, "Product not as described");
|
| 195 |
+
|
| 196 |
+
await expect(
|
| 197 |
+
agroExchange.connect(buyer).resolveDispute(1, true)
|
| 198 |
+
).to.be.revertedWith("Only owner can call this function");
|
| 199 |
+
});
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
describe("Cancellation", function () {
|
| 203 |
+
beforeEach(async function () {
|
| 204 |
+
await agroExchange.connect(buyer).createTransaction(
|
| 205 |
+
seller.address,
|
| 206 |
+
listingId,
|
| 207 |
+
productType,
|
| 208 |
+
quantityKg,
|
| 209 |
+
{ value: transactionAmount }
|
| 210 |
+
);
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
it("Should cancel and refund funded transaction", async function () {
|
| 214 |
+
const buyerBalanceBefore = await ethers.provider.getBalance(buyer.address);
|
| 215 |
+
|
| 216 |
+
const tx = await agroExchange.connect(buyer).cancelTransaction(1);
|
| 217 |
+
const receipt = await tx.wait();
|
| 218 |
+
const gasUsed = receipt.gasUsed * tx.gasPrice;
|
| 219 |
+
|
| 220 |
+
const buyerBalanceAfter = await ethers.provider.getBalance(buyer.address);
|
| 221 |
+
|
| 222 |
+
// Account for gas costs
|
| 223 |
+
expect(buyerBalanceAfter + gasUsed - buyerBalanceBefore).to.equal(transactionAmount);
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
it("Should not allow cancellation after dispute", async function () {
|
| 227 |
+
await agroExchange.connect(buyer).raiseDispute(1, "Issue");
|
| 228 |
+
|
| 229 |
+
await expect(
|
| 230 |
+
agroExchange.connect(buyer).cancelTransaction(1)
|
| 231 |
+
).to.be.revertedWith("Cannot cancel transaction in current state");
|
| 232 |
+
});
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
describe("Auto Release", function () {
|
| 236 |
+
beforeEach(async function () {
|
| 237 |
+
await agroExchange.connect(buyer).createTransaction(
|
| 238 |
+
seller.address,
|
| 239 |
+
listingId,
|
| 240 |
+
productType,
|
| 241 |
+
quantityKg,
|
| 242 |
+
{ value: transactionAmount }
|
| 243 |
+
);
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
it("Should reject auto-release before timeout", async function () {
|
| 247 |
+
await expect(
|
| 248 |
+
agroExchange.autoRelease(1)
|
| 249 |
+
).to.be.revertedWith("Auto-release period not yet reached");
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
it("Should auto-release after timeout", async function () {
|
| 253 |
+
// Increase time by 14 days
|
| 254 |
+
await ethers.provider.send("evm_increaseTime", [14 * 24 * 60 * 60]);
|
| 255 |
+
await ethers.provider.send("evm_mine");
|
| 256 |
+
|
| 257 |
+
const sellerBalanceBefore = await ethers.provider.getBalance(seller.address);
|
| 258 |
+
|
| 259 |
+
await expect(agroExchange.autoRelease(1))
|
| 260 |
+
.to.emit(agroExchange, "FundsReleased");
|
| 261 |
+
|
| 262 |
+
const sellerBalanceAfter = await ethers.provider.getBalance(seller.address);
|
| 263 |
+
const expectedAmount = transactionAmount * BigInt(9900) / BigInt(10000);
|
| 264 |
+
expect(sellerBalanceAfter - sellerBalanceBefore).to.equal(expectedAmount);
|
| 265 |
+
});
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
describe("View Functions", function () {
|
| 269 |
+
beforeEach(async function () {
|
| 270 |
+
await agroExchange.connect(buyer).createTransaction(
|
| 271 |
+
seller.address,
|
| 272 |
+
listingId,
|
| 273 |
+
productType,
|
| 274 |
+
quantityKg,
|
| 275 |
+
{ value: transactionAmount }
|
| 276 |
+
);
|
| 277 |
+
});
|
| 278 |
+
|
| 279 |
+
it("Should return transaction details", async function () {
|
| 280 |
+
const txn = await agroExchange.getTransaction(1);
|
| 281 |
+
expect(txn.seller).to.equal(seller.address);
|
| 282 |
+
expect(txn.buyer).to.equal(buyer.address);
|
| 283 |
+
expect(txn.amount).to.equal(transactionAmount);
|
| 284 |
+
expect(txn.listingId).to.equal(listingId);
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
it("Should return user transactions", async function () {
|
| 288 |
+
const buyerTxns = await agroExchange.getUserTransactions(buyer.address);
|
| 289 |
+
expect(buyerTxns.length).to.equal(1);
|
| 290 |
+
expect(buyerTxns[0]).to.equal(1);
|
| 291 |
+
|
| 292 |
+
const sellerTxns = await agroExchange.getUserTransactions(seller.address);
|
| 293 |
+
expect(sellerTxns.length).to.equal(1);
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
it("Should return transaction by listing ID", async function () {
|
| 297 |
+
const txnId = await agroExchange.getTransactionByListing(listingId);
|
| 298 |
+
expect(txnId).to.equal(1);
|
| 299 |
+
});
|
| 300 |
+
});
|
| 301 |
+
|
| 302 |
+
describe("Admin Functions", function () {
|
| 303 |
+
it("Should update platform fee", async function () {
|
| 304 |
+
await agroExchange.connect(owner).updatePlatformFee(200); // 2%
|
| 305 |
+
expect(await agroExchange.platformFeePercent()).to.equal(200);
|
| 306 |
+
});
|
| 307 |
+
|
| 308 |
+
it("Should reject fee update from non-owner", async function () {
|
| 309 |
+
await expect(
|
| 310 |
+
agroExchange.connect(buyer).updatePlatformFee(200)
|
| 311 |
+
).to.be.revertedWith("Only owner can call this function");
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
it("Should reject fee above 5%", async function () {
|
| 315 |
+
await expect(
|
| 316 |
+
agroExchange.connect(owner).updatePlatformFee(600)
|
| 317 |
+
).to.be.revertedWith("Fee cannot exceed 5%");
|
| 318 |
+
});
|
| 319 |
+
|
| 320 |
+
it("Should transfer ownership", async function () {
|
| 321 |
+
await agroExchange.connect(owner).transferOwnership(addr3.address);
|
| 322 |
+
expect(await agroExchange.owner()).to.equal(addr3.address);
|
| 323 |
+
});
|
| 324 |
+
});
|
| 325 |
+
});
|
backend/controllers/appointmentController.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// appointmentController.js
|
| 2 |
+
|
| 3 |
+
import Appointment from "../models/appointmentModel.js";
|
| 4 |
+
import User from "../models/auth.model.js";
|
| 5 |
+
|
| 6 |
+
// Booking an appointment
|
| 7 |
+
export const bookAppointment = async (req, res, io) => {
|
| 8 |
+
try {
|
| 9 |
+
const { expertId } = req.body;
|
| 10 |
+
const farmerId = req.userId; // Get farmerId from the token
|
| 11 |
+
|
| 12 |
+
const appointment = await Appointment.create({ farmerId, expertId });
|
| 13 |
+
const expert = await User.findById(expertId);
|
| 14 |
+
|
| 15 |
+
if (expert.socketId) {
|
| 16 |
+
io.to(expert.socketId).emit('appointmentRequest', {
|
| 17 |
+
appointMentId: appointment._id,
|
| 18 |
+
farmerId: farmerId,
|
| 19 |
+
});
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
res.status(200).json({ message: "Appointment request sent to expert" });
|
| 23 |
+
} catch (error) {
|
| 24 |
+
console.error(error);
|
| 25 |
+
res.status(500).json({ error: "An error occurred while booking the appointment" });
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
// Accept an appointment
|
| 30 |
+
export const acceptAppointment = async (req, res, io) => {
|
| 31 |
+
try {
|
| 32 |
+
const { appointmentId } = req.params;
|
| 33 |
+
const appointment = await Appointment.findByIdAndUpdate(appointmentId, { status: 'accepted' }, { new: true });
|
| 34 |
+
if (!appointment) return res.status(404).json({ error: "Appointment not found" });
|
| 35 |
+
|
| 36 |
+
io.to(appointment.farmerId.toString()).emit('appointmentAccepted', { appointmentId });
|
| 37 |
+
res.status(200).json({ message: "Appointment accepted successfully" });
|
| 38 |
+
} catch (_error) {
|
| 39 |
+
res.status(500).json({ error: "An error occurred while accepting an appointment" });
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// Decline an appointment
|
| 44 |
+
export const declineAppointment = async (req, res, io) => {
|
| 45 |
+
try {
|
| 46 |
+
const { appointmentId } = req.params;
|
| 47 |
+
const appointment = await Appointment.findByIdAndUpdate(appointmentId, { status: 'declined' }, { new: true });
|
| 48 |
+
if (!appointment) return res.status(404).json({ error: "Appointment not found" });
|
| 49 |
+
|
| 50 |
+
io.to(appointment.farmerId.toString()).emit('appointmentDeclined', { appointmentId });
|
| 51 |
+
res.status(200).json({ message: "Appointment declined successfully" });
|
| 52 |
+
} catch (_error) {
|
| 53 |
+
res.status(500).json({ error: "An error occurred while declining the appointment" });
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
// Get all appointments for expert
|
| 58 |
+
export const getAppointmentsForExpert = async (req, res) => {
|
| 59 |
+
try {
|
| 60 |
+
const expertId = req.userId; // Get expertId from the token
|
| 61 |
+
const appointments = await Appointment.find({ expertId }).populate('farmerId', 'name'); // Assuming 'farmerId' contains the farmer's data like name
|
| 62 |
+
if (!appointments) return res.status(404).json({ error: "No appointments found for this expert" });
|
| 63 |
+
|
| 64 |
+
res.status(200).json(appointments);
|
| 65 |
+
} catch (_error) {
|
| 66 |
+
res.status(500).json({ error: "An error occurred while fetching appointments for expert" });
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
// Get all appointments for farmer
|
| 71 |
+
export const getAppointmentsForFarmer = async (req, res) => {
|
| 72 |
+
try {
|
| 73 |
+
const farmerId = req.userId; // Get farmerId from the token
|
| 74 |
+
const appointments = await Appointment.find({ farmerId }).populate('expertId', 'name'); // Assuming 'expertId' contains the expert's data like name
|
| 75 |
+
if (!appointments) return res.status(404).json({ error: "No appointments found for this farmer" });
|
| 76 |
+
|
| 77 |
+
res.status(200).json(appointments);
|
| 78 |
+
} catch (_error) {
|
| 79 |
+
res.status(500).json({ error: "An error occurred while fetching appointments for farmer" });
|
| 80 |
+
}
|
| 81 |
+
};
|
backend/controllers/authController.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import User from "../models/auth.model.js";
|
| 2 |
+
|
| 3 |
+
const normalizeEmail = (email) => email?.trim().toLowerCase() || null;
|
| 4 |
+
|
| 5 |
+
const profileFromRequest = (req) => ({
|
| 6 |
+
_id: req.userId,
|
| 7 |
+
id: req.userId,
|
| 8 |
+
firebaseUid: req.userId,
|
| 9 |
+
email: normalizeEmail(req.userEmail || req.firebaseUser?.email),
|
| 10 |
+
name: req.firebaseUser?.name || req.firebaseUser?.email?.split("@")[0] || "User",
|
| 11 |
+
role: req.userRole || "farmer",
|
| 12 |
+
img: req.firebaseUser?.picture || null,
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* POST /api/auth/sync-user
|
| 17 |
+
* Firebase Auth is the identity provider. Firestore stores the application
|
| 18 |
+
* profile and role; passwords and server-issued JWTs are never persisted.
|
| 19 |
+
*/
|
| 20 |
+
export const syncGoogleUser = async (req, res) => {
|
| 21 |
+
try {
|
| 22 |
+
if (!req.firebaseUser?.uid) return res.status(401).json({ message: "Unauthorized" });
|
| 23 |
+
|
| 24 |
+
const uid = req.firebaseUser.uid;
|
| 25 |
+
const existing = await User.findById(uid);
|
| 26 |
+
const requestedRole = req.body?.role;
|
| 27 |
+
const role = existing?.role || requestedRole || req.userRole || "farmer";
|
| 28 |
+
const profile = {
|
| 29 |
+
...profileFromRequest(req),
|
| 30 |
+
role,
|
| 31 |
+
email: normalizeEmail(req.firebaseUser.email),
|
| 32 |
+
updatedAt: new Date(),
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const user = await User.findByIdAndUpdate(uid, { $set: profile }, { upsert: true, new: true });
|
| 36 |
+
return res.status(200).json({
|
| 37 |
+
message: "User synced",
|
| 38 |
+
role: user?.role || role,
|
| 39 |
+
userId: uid,
|
| 40 |
+
user,
|
| 41 |
+
});
|
| 42 |
+
} catch (error) {
|
| 43 |
+
console.error("syncFirebaseUser error:", error);
|
| 44 |
+
return res.status(500).json({ message: "Unable to sync Firebase user" });
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
// Password creation and verification now happen in Firebase Auth on the client.
|
| 49 |
+
// These routes remain explicit so stale clients receive a clear migration error
|
| 50 |
+
// rather than silently creating a legacy JWT session.
|
| 51 |
+
export const signup = async (_req, res) => res.status(410).json({
|
| 52 |
+
message: "Password signup moved to Firebase Authentication. Please update the app.",
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
export const signin = async (_req, res) => res.status(410).json({
|
| 56 |
+
message: "Password sign-in moved to Firebase Authentication. Please update the app.",
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
export const signout = async (req, res) => {
|
| 60 |
+
res.clearCookie("firebaseToken", { httpOnly: true, secure: true, sameSite: "none", path: "/" });
|
| 61 |
+
return res.status(200).json({ message: "Logged out successfully" });
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
export const getUserProfile = async (req, res) => {
|
| 65 |
+
try {
|
| 66 |
+
if (!req.userId) return res.status(401).json({ message: "Authentication required" });
|
| 67 |
+
const user = await User.findById(req.userId);
|
| 68 |
+
return res.json(user || profileFromRequest(req));
|
| 69 |
+
} catch (error) {
|
| 70 |
+
console.error("getUserProfile error:", error);
|
| 71 |
+
return res.status(500).json({ message: "Internal server error" });
|
| 72 |
+
}
|
| 73 |
+
};
|
backend/controllers/blogRecommendationsController.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 2 |
+
import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import dotenv from 'dotenv';
|
| 4 |
+
|
| 5 |
+
export const getBlogRecommendations = async (req, res) => {
|
| 6 |
+
dotenv.config();
|
| 7 |
+
|
| 8 |
+
const { region: _region } = req.query;
|
| 9 |
+
const lang = extractLanguage(req);
|
| 10 |
+
const langName = getLanguageName(lang);
|
| 11 |
+
const langInstruction = lang !== 'en' ? `\nRespond STRICTLY in ${langName} language.` : '';
|
| 12 |
+
|
| 13 |
+
try {
|
| 14 |
+
const promptText = `
|
| 15 |
+
please provide the following for the experts with new recommendations every time :
|
| 16 |
+
|
| 17 |
+
Suggest 2 topics in 5-6 words that an expert can write about to help farmers address current issues effectively .
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
Keep each point clear, expert-friendly, and should focus on the most current weather problems, crop health problems, or economic conditions of the farmers or any recent concerns.${langInstruction}
|
| 21 |
+
`;
|
| 22 |
+
|
| 23 |
+
const recommendations = await generateAIContent(promptText.trim());
|
| 24 |
+
res.status(200).json({ recommendations });
|
| 25 |
+
} catch (err) {
|
| 26 |
+
console.error("Error fetching expert recommendations: ", err);
|
| 27 |
+
res.status(500).json({ error: "Failed to fetch recommendations" });
|
| 28 |
+
}
|
| 29 |
+
};
|
backend/controllers/cropController.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Crop from '../models/crop.model.js';
|
| 2 |
+
|
| 3 |
+
export const addCrop = async (req, res) => {
|
| 4 |
+
try {
|
| 5 |
+
console.log("User ID:", req.userId); // Log the user ID for debugging
|
| 6 |
+
const newCrop = new Crop({
|
| 7 |
+
...req.body,
|
| 8 |
+
user: req.userId, // Make sure req.userId is set by the middleware
|
| 9 |
+
});
|
| 10 |
+
|
| 11 |
+
const savedCrop = await newCrop.save();
|
| 12 |
+
res.status(201).json(savedCrop);
|
| 13 |
+
} catch (error) {
|
| 14 |
+
res.status(500).json({ message: "Error occurred while adding crop", error });
|
| 15 |
+
}
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export const getAllCrops = async (req, res) => {
|
| 19 |
+
try {
|
| 20 |
+
const crops = await Crop.find({ user: req.userId }).populate("irrigationData");
|
| 21 |
+
res.status(200).json(crops);
|
| 22 |
+
} catch (error) {
|
| 23 |
+
res.status(500).json({ message: error.message });
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
export const updateCrop = async (req, res) => {
|
| 28 |
+
const { id } = req.params;
|
| 29 |
+
const { name, growthProgress, yieldData } = req.body;
|
| 30 |
+
try {
|
| 31 |
+
const crop = await Crop.findOne({ _id: id, user: req.userId });
|
| 32 |
+
if (!crop) return res.status(404).json({ message: "Crop not found" });
|
| 33 |
+
|
| 34 |
+
if (name) crop.name = name;
|
| 35 |
+
if (growthProgress !== undefined) crop.growthProgress = growthProgress;
|
| 36 |
+
|
| 37 |
+
// Ensure yieldData is an array of objects with "month" and "yield"
|
| 38 |
+
if (Array.isArray(yieldData) && yieldData.every(item => item.month && item.yield)) {
|
| 39 |
+
crop.yieldData.push(...yieldData);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const updatedCrop = await crop.save();
|
| 43 |
+
res.status(200).json(updatedCrop);
|
| 44 |
+
} catch (err) {
|
| 45 |
+
res.status(500).json({ message: "Failed to update crop", error: err.message });
|
| 46 |
+
}
|
| 47 |
+
};
|
backend/controllers/cropRotationController.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 2 |
+
import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import dotenv from "dotenv";
|
| 4 |
+
|
| 5 |
+
export const cropRotationRecommendations = async (req, res) => {
|
| 6 |
+
dotenv.config();
|
| 7 |
+
|
| 8 |
+
const { previousCrop, npkDepletion, waterAvailability, soilType, region } =
|
| 9 |
+
req.body;
|
| 10 |
+
|
| 11 |
+
if (
|
| 12 |
+
!previousCrop ||
|
| 13 |
+
!npkDepletion ||
|
| 14 |
+
!waterAvailability ||
|
| 15 |
+
!soilType ||
|
| 16 |
+
!region
|
| 17 |
+
) {
|
| 18 |
+
return res.status(400).json({
|
| 19 |
+
error: "Missing required inputs: previousCrop, npkDepletion, waterAvailability, soilType, region",
|
| 20 |
+
});
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const lang = extractLanguage(req);
|
| 24 |
+
const langName = getLanguageName(lang);
|
| 25 |
+
const langInstruction = lang !== 'en' ? `\n\nRespond STRICTLY in ${langName} language. Translate all fields and values.` : '';
|
| 26 |
+
|
| 27 |
+
try {
|
| 28 |
+
const prompt = `
|
| 29 |
+
You are an expert agricultural crop rotation scientist.
|
| 30 |
+
|
| 31 |
+
Based on the following:
|
| 32 |
+
- Previous Crop: ${previousCrop}
|
| 33 |
+
- NPK Depletion (major nutrient lost): ${npkDepletion}
|
| 34 |
+
- Water Availability: ${waterAvailability}
|
| 35 |
+
- Soil Type: ${soilType}
|
| 36 |
+
- Region: ${region}
|
| 37 |
+
|
| 38 |
+
Suggest the best crop to plant next to:
|
| 39 |
+
- Restore depleted nutrients naturally
|
| 40 |
+
- Increase soil fertility long-term
|
| 41 |
+
- Improve economic profitability
|
| 42 |
+
|
| 43 |
+
Provide the answer ONLY in this strict JSON format:
|
| 44 |
+
|
| 45 |
+
{
|
| 46 |
+
"recommended_crop": "",
|
| 47 |
+
"reasons": ["", "", ""],
|
| 48 |
+
"nutrient_restoration_benefit": "",
|
| 49 |
+
"expected_profitability": "",
|
| 50 |
+
"note": ""
|
| 51 |
+
}${langInstruction}
|
| 52 |
+
`;
|
| 53 |
+
|
| 54 |
+
const recommendation = await generateAIContent(prompt.trim());
|
| 55 |
+
const formattedRecommendation = recommendation
|
| 56 |
+
.replace("```json", "")
|
| 57 |
+
.replace("```", "")
|
| 58 |
+
.trim();
|
| 59 |
+
res.status(200).json({
|
| 60 |
+
recommendation: formattedRecommendation,
|
| 61 |
+
});
|
| 62 |
+
} catch (err) {
|
| 63 |
+
console.error("Error fetching recommendations: ", err);
|
| 64 |
+
res.status(500).json({ error: "Failed to fetch recommendations" });
|
| 65 |
+
}
|
| 66 |
+
};
|
backend/controllers/detectHarvestReadinessController.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import dotenv from "dotenv";
|
| 2 |
+
import { generateAIContentWithVision } from "../utils/aiHelper.js";
|
| 3 |
+
import { extractLanguage, getLanguageName } from "../utils/aiOrchestrator.js";
|
| 4 |
+
import FormData from "form-data";
|
| 5 |
+
|
| 6 |
+
const AI_BACKEND_URL = process.env.AI_BACKEND_URL || "http://localhost:5000";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Try the YOLO harvest readiness model on the AI backend first.
|
| 10 |
+
* Returns the result object or null if the AI backend is unavailable.
|
| 11 |
+
*/
|
| 12 |
+
async function tryYoloModel(fileBuffer, originalname, mimetype) {
|
| 13 |
+
try {
|
| 14 |
+
const form = new FormData();
|
| 15 |
+
form.append("file", fileBuffer, {
|
| 16 |
+
filename: originalname || "image.jpg",
|
| 17 |
+
contentType: mimetype || "image/jpeg",
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
const response = await fetch(`${AI_BACKEND_URL}/harvest_readiness`, {
|
| 21 |
+
method: "POST",
|
| 22 |
+
body: form,
|
| 23 |
+
headers: form.getHeaders(),
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
if (!response.ok) return null;
|
| 27 |
+
const data = await response.json();
|
| 28 |
+
if (data.error) return null;
|
| 29 |
+
return data;
|
| 30 |
+
} catch {
|
| 31 |
+
return null;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export const detectHarvestReadiness = async (req, res) => {
|
| 36 |
+
dotenv.config();
|
| 37 |
+
try {
|
| 38 |
+
if (!req.file) {
|
| 39 |
+
return res.status(400).json({ error: "No image uploaded" });
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Try YOLO model first
|
| 43 |
+
const yoloResult = await tryYoloModel(
|
| 44 |
+
req.file.buffer,
|
| 45 |
+
req.file.originalname,
|
| 46 |
+
req.file.mimetype
|
| 47 |
+
);
|
| 48 |
+
if (yoloResult) {
|
| 49 |
+
return res.status(200).json(yoloResult);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Fallback to Gemini vision AI
|
| 53 |
+
const base64Image = req.file.buffer?.toString("base64");
|
| 54 |
+
const lang = extractLanguage(req);
|
| 55 |
+
const langName = getLanguageName(lang);
|
| 56 |
+
const langInstruction = lang !== 'en' ? `\nRespond STRICTLY in ${langName} language for the "note" field.` : '';
|
| 57 |
+
|
| 58 |
+
const prompt = `
|
| 59 |
+
You are an agricultural expert.
|
| 60 |
+
|
| 61 |
+
Analyze the crop in this image and provide:
|
| 62 |
+
|
| 63 |
+
1. Whether the crop is ready for harvest (Yes/No).
|
| 64 |
+
2. Percentage maturity (0–100%).
|
| 65 |
+
3. Estimated days left for optimal harvest.
|
| 66 |
+
4. Very short explanation (1–2 lines).
|
| 67 |
+
|
| 68 |
+
Return data in strict JSON format:
|
| 69 |
+
{
|
| 70 |
+
"ready": "Yes/No",
|
| 71 |
+
"maturity": 0-100,
|
| 72 |
+
"days_left": number,
|
| 73 |
+
"note": "short text"
|
| 74 |
+
}${langInstruction}
|
| 75 |
+
`;
|
| 76 |
+
|
| 77 |
+
const aiText = await generateAIContentWithVision(prompt, base64Image, req.file.mimetype);
|
| 78 |
+
|
| 79 |
+
const cleanJsonString = aiText.replace("```json", "").replace("```", "").trim();
|
| 80 |
+
|
| 81 |
+
try {
|
| 82 |
+
const result = JSON.parse(cleanJsonString);
|
| 83 |
+
return res.status(200).json(result);
|
| 84 |
+
} catch (_err) {
|
| 85 |
+
console.log("AI did not return valid JSON:", aiText);
|
| 86 |
+
return res.status(500).json({ error: "Failed to parse AI response", raw: aiText });
|
| 87 |
+
}
|
| 88 |
+
} catch (error) {
|
| 89 |
+
console.error("Error detecting harvest readiness:", error.message || error);
|
| 90 |
+
res.status(500).json({ error: "Failed to detect harvest readiness" });
|
| 91 |
+
}
|
| 92 |
+
};
|
backend/controllers/expertDetailsController.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ExpertDetails from '../models/expertDetail.model.js';
|
| 2 |
+
import User from '../models/auth.model.js';
|
| 3 |
+
|
| 4 |
+
// Get Expert Details
|
| 5 |
+
export const getExpertDetails = async (req, res) => {
|
| 6 |
+
try {
|
| 7 |
+
const expertDetails = await ExpertDetails.findOne({ userId: req.params.userId });
|
| 8 |
+
|
| 9 |
+
// If expert details are not found, return default values
|
| 10 |
+
if (!expertDetails) {
|
| 11 |
+
const defaultDetails = {
|
| 12 |
+
expertStats: { successfulAppointments: 0, farmersHelped: 0, experience: 0, rating: 0 },
|
| 13 |
+
appointmentStats: {
|
| 14 |
+
totalAppointments: 0,
|
| 15 |
+
satisfactionRating: 0,
|
| 16 |
+
adviceAreas: { cropManagement: 0, pestControl: 0, irrigation: 0 }
|
| 17 |
+
},
|
| 18 |
+
blogEngagement: { views: 0, comments: 0, likes: 0 }
|
| 19 |
+
};
|
| 20 |
+
return res.status(200).json(defaultDetails);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
res.status(200).json(expertDetails);
|
| 24 |
+
} catch (error) {
|
| 25 |
+
res.status(500).json({ message: 'Server Error', error });
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
// Add Expert Details
|
| 30 |
+
export const addExpertDetails = async (req, res) => {
|
| 31 |
+
try {
|
| 32 |
+
const userId = req.userId; // Use authenticated user's ID
|
| 33 |
+
const { expertStats, appointmentStats, blogEngagement } = req.body;
|
| 34 |
+
|
| 35 |
+
// Check if the user exists and is an expert
|
| 36 |
+
const user = await User.findById(userId);
|
| 37 |
+
if (!user || user.role !== 'expert') {
|
| 38 |
+
return res.status(400).json({ message: 'Invalid expert user ID' });
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Check if expert details already exist
|
| 42 |
+
const existingDetails = await ExpertDetails.findOne({ userId });
|
| 43 |
+
if (existingDetails) {
|
| 44 |
+
return res.status(400).json({ message: 'Expert details already exist' });
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const newExpertDetails = new ExpertDetails({
|
| 48 |
+
userId,
|
| 49 |
+
expertStats,
|
| 50 |
+
appointmentStats,
|
| 51 |
+
blogEngagement,
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
await newExpertDetails.save();
|
| 55 |
+
res.status(201).json(newExpertDetails);
|
| 56 |
+
} catch (error) {
|
| 57 |
+
res.status(500).json({ message: 'Server Error', error });
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
// Update Expert Details
|
| 62 |
+
export const updateExpertDetails = async (req, res) => {
|
| 63 |
+
try {
|
| 64 |
+
// Try to find the expert details for the given userId
|
| 65 |
+
let expertDetails = await ExpertDetails.findOne({ userId: req.params.userId });
|
| 66 |
+
|
| 67 |
+
// If expert details don't exist, create a new document for this user
|
| 68 |
+
if (!expertDetails) {
|
| 69 |
+
expertDetails = new ExpertDetails({
|
| 70 |
+
userId: req.params.userId,
|
| 71 |
+
expertStats: {
|
| 72 |
+
successfulAppointments: 0,
|
| 73 |
+
farmersHelped: 0,
|
| 74 |
+
experience: 0,
|
| 75 |
+
rating: 0
|
| 76 |
+
},
|
| 77 |
+
appointmentStats: {
|
| 78 |
+
totalAppointments: 0,
|
| 79 |
+
satisfactionRating: 0,
|
| 80 |
+
adviceAreas: {
|
| 81 |
+
cropManagement: 0,
|
| 82 |
+
pestControl: 0,
|
| 83 |
+
irrigation: 0
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
blogEngagement: {
|
| 87 |
+
views: 0,
|
| 88 |
+
comments: 0,
|
| 89 |
+
likes: 0
|
| 90 |
+
}
|
| 91 |
+
});
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Update the expert details with the values from the request body, if provided
|
| 95 |
+
const { expertStats, appointmentStats, blogEngagement } = req.body;
|
| 96 |
+
|
| 97 |
+
if (expertStats) {
|
| 98 |
+
expertDetails.expertStats = {
|
| 99 |
+
...expertDetails.expertStats.toObject(),
|
| 100 |
+
...expertStats
|
| 101 |
+
};
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
if (appointmentStats) {
|
| 105 |
+
expertDetails.appointmentStats = {
|
| 106 |
+
...expertDetails.appointmentStats.toObject(),
|
| 107 |
+
...appointmentStats
|
| 108 |
+
};
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
if (blogEngagement) {
|
| 112 |
+
expertDetails.blogEngagement = {
|
| 113 |
+
...expertDetails.blogEngagement.toObject(),
|
| 114 |
+
...blogEngagement
|
| 115 |
+
};
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Save the updated expert details
|
| 119 |
+
await expertDetails.save();
|
| 120 |
+
|
| 121 |
+
// Respond with the updated expert details
|
| 122 |
+
res.status(200).json(expertDetails);
|
| 123 |
+
|
| 124 |
+
} catch (error) {
|
| 125 |
+
// Handle any server errors
|
| 126 |
+
res.status(500).json({ message: 'Server Error', error });
|
| 127 |
+
}
|
| 128 |
+
};
|
backend/controllers/farmerDetailsController.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import FarmerDetails from "../models/farmerDetail.model.js";
|
| 2 |
+
import User from '../models/auth.model.js'
|
| 3 |
+
|
| 4 |
+
//getting farmer details by id
|
| 5 |
+
export const getFarmerDetails = async (req, res) => {
|
| 6 |
+
try {
|
| 7 |
+
const farmerDetails = await FarmerDetails.findOne({ user: req.params.userId });
|
| 8 |
+
if (!farmerDetails) {
|
| 9 |
+
return res.status(404).json({ message: 'Farmer details not found' });
|
| 10 |
+
}
|
| 11 |
+
res.status(200).json(farmerDetails);
|
| 12 |
+
} catch (error) {
|
| 13 |
+
res.status(500).json({ message: 'Server Error', error });
|
| 14 |
+
}
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
//adding farmer details
|
| 18 |
+
export const addFarmerDetails = async(req,res)=>{
|
| 19 |
+
try{
|
| 20 |
+
const userId = req.userId
|
| 21 |
+
const {phone, address, region, climate, cropNames, amountOfLand, otherDetails}= req.body
|
| 22 |
+
|
| 23 |
+
const user = await User.findById(userId)
|
| 24 |
+
if(!user || user.role!=='farmer'){
|
| 25 |
+
return res.status(400).json({message:"Invalid expert user ID"})
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const existingDetails = await FarmerDetails.findOne({userId})
|
| 29 |
+
if(existingDetails){
|
| 30 |
+
return res.status(400).json({message:'Farmer details already exist'})
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const newFarmerDetails = new FarmerDetails({
|
| 34 |
+
user: userId,
|
| 35 |
+
phone,
|
| 36 |
+
address,
|
| 37 |
+
region,
|
| 38 |
+
climate,
|
| 39 |
+
cropNames,
|
| 40 |
+
amountOfLand,
|
| 41 |
+
otherDetails
|
| 42 |
+
})
|
| 43 |
+
await newFarmerDetails.save()
|
| 44 |
+
res.status(201).json(newFarmerDetails)
|
| 45 |
+
|
| 46 |
+
}catch(error){
|
| 47 |
+
res.status(500).json({ message: 'Server Error', error });
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export const updateFarmerDetails = async (req, res) => {
|
| 52 |
+
try {
|
| 53 |
+
// Find the farmer's details based on the userId in the URL
|
| 54 |
+
const farmerDetails = await FarmerDetails.findOne({ user: req.params.userId });
|
| 55 |
+
if (!farmerDetails) {
|
| 56 |
+
return res.status(404).json({ message: "Farmer details not found" });
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Destructure fields from the request body
|
| 60 |
+
const { phone, address, region, climate, cropNames, amountOfLand, otherDetails } = req.body;
|
| 61 |
+
|
| 62 |
+
// Update fields only if they are provided and different from the existing values
|
| 63 |
+
if (phone && phone !== farmerDetails.phone) {
|
| 64 |
+
farmerDetails.phone = phone;
|
| 65 |
+
}
|
| 66 |
+
if (address && address !== farmerDetails.address) {
|
| 67 |
+
farmerDetails.address = address;
|
| 68 |
+
}
|
| 69 |
+
if (region && region !== farmerDetails.region) {
|
| 70 |
+
farmerDetails.region = region;
|
| 71 |
+
}
|
| 72 |
+
if (climate && climate !== farmerDetails.climate) {
|
| 73 |
+
farmerDetails.climate = climate;
|
| 74 |
+
}
|
| 75 |
+
if (cropNames && JSON.stringify(cropNames) !== JSON.stringify(farmerDetails.cropNames)) {
|
| 76 |
+
farmerDetails.cropNames = cropNames;
|
| 77 |
+
}
|
| 78 |
+
if (amountOfLand && amountOfLand !== farmerDetails.amountOfLand) {
|
| 79 |
+
farmerDetails.amountOfLand = amountOfLand;
|
| 80 |
+
}
|
| 81 |
+
if (otherDetails && otherDetails !== farmerDetails.otherDetails) {
|
| 82 |
+
farmerDetails.otherDetails = otherDetails;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Save the updated farmer details
|
| 86 |
+
await farmerDetails.save();
|
| 87 |
+
|
| 88 |
+
res.status(200).json({ message: "Farmer details updated successfully", farmerDetails });
|
| 89 |
+
} catch (error) {
|
| 90 |
+
res.status(500).json({ message: "Error updating farmer details", error });
|
| 91 |
+
}
|
| 92 |
+
};
|
backend/controllers/farmingNewsController.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios'
|
| 2 |
+
|
| 3 |
+
export const getFarmingNews = async(req, res)=>{
|
| 4 |
+
const api_key = process.env.NEWS_API_KEY;
|
| 5 |
+
const url = `https://newsapi.org/v2/everything?q=farming&apiKey=${api_key}`;
|
| 6 |
+
|
| 7 |
+
try{
|
| 8 |
+
const response = await axios.get(url);
|
| 9 |
+
// console.log(response);
|
| 10 |
+
const articles = response.data.articles;
|
| 11 |
+
// console.log(" Articles is : ", articles);
|
| 12 |
+
res.status(200).json(articles);
|
| 13 |
+
}catch(err){
|
| 14 |
+
console.error("Error fetching news : ", err);
|
| 15 |
+
res.status(500).json({message : "Error fetching news"});
|
| 16 |
+
}
|
| 17 |
+
}
|
backend/controllers/geoPestDiseaseHeatmapController.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 2 |
+
import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import dotenv from "dotenv";
|
| 4 |
+
|
| 5 |
+
export const geoPestDiseaseHeatmapRecommendations = async (req, res) => {
|
| 6 |
+
dotenv.config();
|
| 7 |
+
|
| 8 |
+
const { location, cropType, cropStage } = req.body;
|
| 9 |
+
|
| 10 |
+
if (!location) {
|
| 11 |
+
return res.status(400).json({
|
| 12 |
+
error: "Missing required input: location",
|
| 13 |
+
});
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const lang = extractLanguage(req);
|
| 17 |
+
const langName = getLanguageName(lang);
|
| 18 |
+
const langInstruction = lang !== 'en' ? `\n\nRespond STRICTLY in ${langName} language. Translate all fields and values.` : '';
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
const prompt = `
|
| 22 |
+
You are an agricultural pest & disease outbreak prediction expert.
|
| 23 |
+
|
| 24 |
+
Analyze early pest/disease outbreak risks based on:
|
| 25 |
+
- Farm Location: ${location}
|
| 26 |
+
- Crop Type (optional): ${cropType || "Not provided"}
|
| 27 |
+
- Crop Stage (optional): ${cropStage || "Not provided"}
|
| 28 |
+
|
| 29 |
+
Use indicators such as:
|
| 30 |
+
- Satellite vegetation stress signals
|
| 31 |
+
- Humidity + temperature patterns
|
| 32 |
+
- Rainfall + soil moisture
|
| 33 |
+
- Community farmer reports in nearby villages
|
| 34 |
+
- Seasonal pest migration trends
|
| 35 |
+
|
| 36 |
+
Provide ONLY the JSON output in the following format:
|
| 37 |
+
|
| 38 |
+
{
|
| 39 |
+
"risk_level": "Low/Moderate/High/Severe",
|
| 40 |
+
"hotspot_zones": ["", "", ""],
|
| 41 |
+
"likely_threat": "",
|
| 42 |
+
"expected_outbreak_days": 0,
|
| 43 |
+
"preventive_actions": ["", "", ""],
|
| 44 |
+
"note": ""
|
| 45 |
+
}${langInstruction}
|
| 46 |
+
`;
|
| 47 |
+
|
| 48 |
+
const recommendation = await generateAIContent(prompt.trim());
|
| 49 |
+
const formattedRecommendation = recommendation
|
| 50 |
+
.replace("```json", "")
|
| 51 |
+
.replace("```", "")
|
| 52 |
+
.trim();
|
| 53 |
+
res.status(200).json({
|
| 54 |
+
recommendation: formattedRecommendation,
|
| 55 |
+
});
|
| 56 |
+
} catch (err) {
|
| 57 |
+
console.error("Error fetching recommendations: ", err);
|
| 58 |
+
res.status(500).json({ error: "Failed to fetch recommendations" });
|
| 59 |
+
}
|
| 60 |
+
};
|
backend/controllers/getExpertsController.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import User from '../models/auth.model.js'
|
| 2 |
+
|
| 3 |
+
// controller to get all expert user
|
| 4 |
+
export const getExperts = async(req, res)=>{
|
| 5 |
+
try{
|
| 6 |
+
const experts = await User.find({role: 'expert'});
|
| 7 |
+
res.status(200).json(experts);
|
| 8 |
+
}catch(err){
|
| 9 |
+
res.status(500).json({message : "Failed to fetch the user details", err});
|
| 10 |
+
}
|
| 11 |
+
}
|
backend/controllers/getLoanEligibilityReportController.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 2 |
+
import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import dotenv from "dotenv";
|
| 4 |
+
|
| 5 |
+
export const getLoanEligibilityReport = async (req, res) => {
|
| 6 |
+
dotenv.config();
|
| 7 |
+
|
| 8 |
+
const {
|
| 9 |
+
location,
|
| 10 |
+
landSize,
|
| 11 |
+
landType,
|
| 12 |
+
cropType,
|
| 13 |
+
cropStage,
|
| 14 |
+
pastYield,
|
| 15 |
+
existingLoans,
|
| 16 |
+
} = req.body;
|
| 17 |
+
|
| 18 |
+
if (!location || !landSize || !landType || !cropType || !cropStage) {
|
| 19 |
+
return res.status(400).json({
|
| 20 |
+
error: "Missing required inputs: location, landSize, landType, cropType, cropStage",
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const lang = extractLanguage(req);
|
| 25 |
+
const langName = getLanguageName(lang);
|
| 26 |
+
const langInstruction = lang !== 'en' ? `\n\nRespond STRICTLY in ${langName} language. Translate all fields and values.` : '';
|
| 27 |
+
|
| 28 |
+
try {
|
| 29 |
+
const prompt = `
|
| 30 |
+
You are an expert agricultural financial analyst who evaluates farmer credit eligibility based on farm potential and financial risk.
|
| 31 |
+
|
| 32 |
+
Evaluate the farmer using:
|
| 33 |
+
- Farm Location: ${location}
|
| 34 |
+
- Land Size: ${landSize}
|
| 35 |
+
- Land Type: ${landType}
|
| 36 |
+
- Crop Type: ${cropType}
|
| 37 |
+
- Crop Stage: ${cropStage}
|
| 38 |
+
- Past Yield (optional): ${pastYield || "Not provided"}
|
| 39 |
+
- Existing Loans (optional): ${existingLoans || "Not provided"}
|
| 40 |
+
|
| 41 |
+
Consider:
|
| 42 |
+
- Crop yield prediction
|
| 43 |
+
- Soil health and farm productivity potential
|
| 44 |
+
- Market price forecast & demand trends
|
| 45 |
+
- Climate risk profile (drought/flood probability)
|
| 46 |
+
- Irrigation access and fertilizer usage (assume based on crop & region if not given)
|
| 47 |
+
- Cropping pattern stability
|
| 48 |
+
|
| 49 |
+
Provide the output ONLY in the following JSON format:
|
| 50 |
+
|
| 51 |
+
{
|
| 52 |
+
"loan_approval_probability": 0,
|
| 53 |
+
"eligible_loan_amount_range": "",
|
| 54 |
+
"risk_category": "",
|
| 55 |
+
"expected_repayment_capacity": "",
|
| 56 |
+
"recommendations": ["", "", ""],
|
| 57 |
+
"note": ""
|
| 58 |
+
}${langInstruction}
|
| 59 |
+
`;
|
| 60 |
+
|
| 61 |
+
const recommendation = await generateAIContent(prompt.trim());
|
| 62 |
+
const formattedRecommendation = recommendation
|
| 63 |
+
.replace("```json", "")
|
| 64 |
+
.replace("```", "")
|
| 65 |
+
.trim();
|
| 66 |
+
res.status(200).json({
|
| 67 |
+
recommendation: formattedRecommendation,
|
| 68 |
+
});
|
| 69 |
+
} catch (err) {
|
| 70 |
+
console.error("Error fetching recommendations: ", err);
|
| 71 |
+
res.status(500).json({ error: "Failed to fetch recommendations" });
|
| 72 |
+
}
|
| 73 |
+
};
|
backend/controllers/irrigationController.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// controllers/irrigationController.js
|
| 2 |
+
import Irrigation from '../models/irrigation.model.js';
|
| 3 |
+
|
| 4 |
+
export const addIrrigationData = async (req, res) => {
|
| 5 |
+
const { cropId } = req.params;
|
| 6 |
+
const { month, waterUsage, forecastedUsage } = req.body;
|
| 7 |
+
|
| 8 |
+
try {
|
| 9 |
+
|
| 10 |
+
const userId = req.userId;
|
| 11 |
+
// Create and save new irrigation data associated with the crop
|
| 12 |
+
const irrigationData = new Irrigation({
|
| 13 |
+
crop: cropId,
|
| 14 |
+
user: userId,
|
| 15 |
+
month,
|
| 16 |
+
waterUsage,
|
| 17 |
+
forecastedUsage,
|
| 18 |
+
});
|
| 19 |
+
await irrigationData.save();
|
| 20 |
+
|
| 21 |
+
res.status(201).json({ message: 'Irrigation data added successfully', irrigationData });
|
| 22 |
+
} catch (error) {
|
| 23 |
+
res.status(500).json({ message: 'Failed to add irrigation data', error });
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
// Optional: Controller to get all irrigation data for a specific crop
|
| 28 |
+
export const getAllIrrigationDataByCrop = async (req, res) => {
|
| 29 |
+
const { cropId } = req.params;
|
| 30 |
+
try {
|
| 31 |
+
const userId = req.userId;
|
| 32 |
+
const irrigationData = await Irrigation.find({ crop: cropId, user: userId });
|
| 33 |
+
res.status(200).json(irrigationData);
|
| 34 |
+
} catch (error) {
|
| 35 |
+
res.status(500).json({ message: 'Failed to retrieve irrigation data', error });
|
| 36 |
+
}
|
| 37 |
+
};
|
backend/controllers/marketPredictionController.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 2 |
+
import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import dotenv from "dotenv";
|
| 4 |
+
|
| 5 |
+
export const marketPredictionRecommendations = async (req, res) => {
|
| 6 |
+
dotenv.config();
|
| 7 |
+
|
| 8 |
+
const {
|
| 9 |
+
cropType,
|
| 10 |
+
region,
|
| 11 |
+
currentPrice,
|
| 12 |
+
mandiOptions,
|
| 13 |
+
season,
|
| 14 |
+
marketArrivals,
|
| 15 |
+
} = req.body;
|
| 16 |
+
|
| 17 |
+
if (
|
| 18 |
+
!cropType ||
|
| 19 |
+
!region ||
|
| 20 |
+
!currentPrice ||
|
| 21 |
+
!mandiOptions ||
|
| 22 |
+
!season ||
|
| 23 |
+
!marketArrivals
|
| 24 |
+
) {
|
| 25 |
+
return res.status(400).json({
|
| 26 |
+
error: "Missing required inputs: cropType, region, currentPrice, mandiOptions, season, marketArrivals",
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const lang = extractLanguage(req);
|
| 31 |
+
const langName = getLanguageName(lang);
|
| 32 |
+
const langInstruction = lang !== 'en' ? `\n\nRespond STRICTLY in ${langName} language. Translate all fields and values.` : '';
|
| 33 |
+
|
| 34 |
+
try {
|
| 35 |
+
const prompt = `
|
| 36 |
+
You are an agricultural market economist specializing in crop price forecasting.
|
| 37 |
+
|
| 38 |
+
Analyze and predict crop selling strategy based on:
|
| 39 |
+
- Crop: ${cropType}
|
| 40 |
+
- Region: ${region}
|
| 41 |
+
- Current Price: ${currentPrice}
|
| 42 |
+
- Available Mandis/Markets: ${mandiOptions}
|
| 43 |
+
- Current Season/Festival Impact: ${season}
|
| 44 |
+
- Market Arrivals (supply level): ${marketArrivals}
|
| 45 |
+
|
| 46 |
+
Consider:
|
| 47 |
+
- Historical mandi data
|
| 48 |
+
- Demand–supply trends
|
| 49 |
+
- Seasonal/Festival inflation
|
| 50 |
+
- Weather influence on supply
|
| 51 |
+
|
| 52 |
+
Provide ONLY this JSON output:
|
| 53 |
+
|
| 54 |
+
{
|
| 55 |
+
"predicted_price_next_week": "₹value per quintal/kg",
|
| 56 |
+
"sell_now": "Yes/No",
|
| 57 |
+
"best_market": "",
|
| 58 |
+
"price_trend": "Rising/Stable/Falling",
|
| 59 |
+
"expected_change_percent": 0,
|
| 60 |
+
"note": ""
|
| 61 |
+
}${langInstruction}
|
| 62 |
+
`;
|
| 63 |
+
|
| 64 |
+
const recommendation = await generateAIContent(prompt.trim());
|
| 65 |
+
const formattedRecommendation = recommendation
|
| 66 |
+
.replace("```json", "")
|
| 67 |
+
.replace("```", "")
|
| 68 |
+
.trim();
|
| 69 |
+
res.status(200).json({
|
| 70 |
+
recommendation: formattedRecommendation,
|
| 71 |
+
});
|
| 72 |
+
} catch (err) {
|
| 73 |
+
console.error("Error fetching recommendations: ", err);
|
| 74 |
+
res.status(500).json({ error: "Failed to fetch recommendations" });
|
| 75 |
+
}
|
| 76 |
+
};
|
backend/controllers/notificationsController.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 2 |
+
import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import dotenv from 'dotenv';
|
| 4 |
+
import { Notification } from "../utils/firestoreCollections.js";
|
| 5 |
+
|
| 6 |
+
export const getFarmingAlerts = async (req, res) => {
|
| 7 |
+
dotenv.config();
|
| 8 |
+
|
| 9 |
+
const { region } = req.query;
|
| 10 |
+
const lang = extractLanguage(req);
|
| 11 |
+
const langName = getLanguageName(lang);
|
| 12 |
+
const langInstruction = lang !== 'en' ? `\nRespond STRICTLY in ${langName} language.` : '';
|
| 13 |
+
|
| 14 |
+
try {
|
| 15 |
+
const promptText = `
|
| 16 |
+
Please provide a maximum of 2 short and recent farming alerts or notifications in max 5-6 words related to farming weather and conditions, specifically for the region of ${region}.
|
| 17 |
+
|
| 18 |
+
Focus on:
|
| 19 |
+
- Important weather-related alerts relevant to farming today.
|
| 20 |
+
- Any immediate farming precautions or actions farmers should take.
|
| 21 |
+
|
| 22 |
+
Keep each alert clear, brief, and farmer-friendly. Thank you!${langInstruction}
|
| 23 |
+
`;
|
| 24 |
+
|
| 25 |
+
const alerts = await generateAIContent(promptText.trim());
|
| 26 |
+
res.status(200).json({ alerts });
|
| 27 |
+
} catch (err) {
|
| 28 |
+
console.error("Error fetching farming alerts: ", err);
|
| 29 |
+
res.status(500).json({ error: "Failed to fetch alerts" });
|
| 30 |
+
}
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
export const listNotifications = async (req, res) => {
|
| 34 |
+
try {
|
| 35 |
+
const userId = req.user?._id;
|
| 36 |
+
|
| 37 |
+
if (!userId) {
|
| 38 |
+
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const notifications = await Notification.find({ userId }).sort('-createdAt').limit(20).lean();
|
| 42 |
+
const unread = notifications.filter((n) => !n.read).length;
|
| 43 |
+
|
| 44 |
+
return res.status(200).json({
|
| 45 |
+
success: true,
|
| 46 |
+
data: {
|
| 47 |
+
notifications,
|
| 48 |
+
unread,
|
| 49 |
+
},
|
| 50 |
+
});
|
| 51 |
+
} catch (err) {
|
| 52 |
+
return res.status(500).json({ success: false, error: err.message });
|
| 53 |
+
}
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
export const seedNotification = async (req, res) => {
|
| 57 |
+
try {
|
| 58 |
+
const userId = req.user?._id;
|
| 59 |
+
if (!userId) {
|
| 60 |
+
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const { title, message, type = 'general', link } = req.body || {};
|
| 64 |
+
if (!title || !message) {
|
| 65 |
+
return res.status(400).json({ success: false, error: 'title and message are required' });
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const created = await Notification.create({ userId, title, message, type, link });
|
| 69 |
+
return res.status(201).json({ success: true, data: created });
|
| 70 |
+
} catch (err) {
|
| 71 |
+
return res.status(500).json({ success: false, error: err.message });
|
| 72 |
+
}
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
export const markNotificationRead = async (req, res) => {
|
| 76 |
+
try {
|
| 77 |
+
const userId = req.user?._id;
|
| 78 |
+
const { id } = req.params;
|
| 79 |
+
if (!userId) {
|
| 80 |
+
return res.status(401).json({ success: false, error: 'Unauthorized' });
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
await Notification.updateOne({ _id: id, userId }, { $set: { read: true } });
|
| 84 |
+
return res.status(200).json({ success: true });
|
| 85 |
+
} catch (err) {
|
| 86 |
+
return res.status(500).json({ success: false, error: err.message });
|
| 87 |
+
}
|
| 88 |
+
};
|
backend/controllers/pestOutbreakController.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 2 |
+
import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import dotenv from "dotenv";
|
| 4 |
+
|
| 5 |
+
export const pestOutbreakRecommendations = async (req, res) => {
|
| 6 |
+
dotenv.config();
|
| 7 |
+
|
| 8 |
+
const { region, weather, cropType, communityReports } = req.body;
|
| 9 |
+
|
| 10 |
+
if (!region || !weather || !cropType || !communityReports) {
|
| 11 |
+
return res.status(400).json({
|
| 12 |
+
error: "Missing required inputs: region, weather, cropType, communityReports",
|
| 13 |
+
});
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const lang = extractLanguage(req);
|
| 17 |
+
const langName = getLanguageName(lang);
|
| 18 |
+
const langInstruction = lang !== 'en' ? `\n\nRespond STRICTLY in ${langName} language. Translate all fields and values.` : '';
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
const prompt = `
|
| 22 |
+
You are an agricultural pest outbreak prediction expert.
|
| 23 |
+
|
| 24 |
+
Analyze the risk of pest infestation using:
|
| 25 |
+
- Region: ${region}
|
| 26 |
+
- Weather forecast: ${weather}
|
| 27 |
+
- Crop type: ${cropType}
|
| 28 |
+
- Community pest reports (last 7 days): ${communityReports}
|
| 29 |
+
|
| 30 |
+
Provide:
|
| 31 |
+
1. Whether there is a risk of pest outbreak (Yes/No)
|
| 32 |
+
2. Likely pest that may attack (e.g., stem borer, aphids, bollworm, etc.)
|
| 33 |
+
3. Risk level (%) based on severity and probability
|
| 34 |
+
4. Expected time window (days until possible outbreak)
|
| 35 |
+
5. Preventive actions farmers should take immediately (bullet points)
|
| 36 |
+
6. A short note (1–2 lines of advice)
|
| 37 |
+
|
| 38 |
+
Respond ONLY in this JSON format:
|
| 39 |
+
|
| 40 |
+
{
|
| 41 |
+
"outbreak_risk": "",
|
| 42 |
+
"likely_pest": "",
|
| 43 |
+
"risk_level_percent": 0,
|
| 44 |
+
"expected_days": 0,
|
| 45 |
+
"preventive_actions": ["", "", ""],
|
| 46 |
+
"note": ""
|
| 47 |
+
}${langInstruction}
|
| 48 |
+
`;
|
| 49 |
+
|
| 50 |
+
const recommendation = await generateAIContent(prompt.trim());
|
| 51 |
+
const formattedRecommendation = recommendation
|
| 52 |
+
.replace("```json", "")
|
| 53 |
+
.replace("```", "")
|
| 54 |
+
.trim();
|
| 55 |
+
res.status(200).json({
|
| 56 |
+
recommendation: formattedRecommendation,
|
| 57 |
+
});
|
| 58 |
+
} catch (err) {
|
| 59 |
+
console.error("Error fetching recommendations: ", err);
|
| 60 |
+
res.status(500).json({ error: "Failed to fetch recommendations" });
|
| 61 |
+
}
|
| 62 |
+
};
|
backend/controllers/postController.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Post from '../models/post.model.js';
|
| 2 |
+
|
| 3 |
+
// creating a post
|
| 4 |
+
export const createPost = async(req, res)=>{
|
| 5 |
+
try{
|
| 6 |
+
const {title, content} = req.body;
|
| 7 |
+
const post = new Post({title, content, author: req.userId});
|
| 8 |
+
await post.save();
|
| 9 |
+
res.status(201).json({message: "Post saved successfully", post});
|
| 10 |
+
}catch(err){
|
| 11 |
+
res.status(500).json({message: "Something went wrong", err});
|
| 12 |
+
}
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Get all posts for loggedin user
|
| 16 |
+
export const getAllPost = async(req, res)=>{
|
| 17 |
+
try{
|
| 18 |
+
const posts = await Post.find().populate('author', 'username');
|
| 19 |
+
res.status(200).json(posts);
|
| 20 |
+
}catch(err){
|
| 21 |
+
res.status(500).json({message: "Error fetching all post", err});
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Get posts of a user
|
| 26 |
+
export const getPostsByUser = async (req, res) => {
|
| 27 |
+
try {
|
| 28 |
+
// Extract userId from the authenticated user (JWT token)
|
| 29 |
+
const userId = req.userId; // Assuming the userId is decoded and set in the token verification middleware
|
| 30 |
+
|
| 31 |
+
if (!userId) {
|
| 32 |
+
return res.status(400).json({ message: 'User ID is missing or invalid' });
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Find posts based on the userId
|
| 36 |
+
const posts = await Post.find({ author: userId }).populate('author', 'username');
|
| 37 |
+
if (posts.length === 0) {
|
| 38 |
+
return res.status(404).json({ message: 'No posts found for this user' });
|
| 39 |
+
}
|
| 40 |
+
res.status(200).json(posts);
|
| 41 |
+
} catch (err) {
|
| 42 |
+
console.error('Error fetching user posts:', err);
|
| 43 |
+
res.status(500).json({ message: 'Error fetching user posts', error: err.message });
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
export const getPostById = async(req, res)=>{
|
| 48 |
+
try{
|
| 49 |
+
const {id} = req.params;
|
| 50 |
+
const post = await Post.findById(id).populate('author', 'username');
|
| 51 |
+
if(!post){
|
| 52 |
+
return res.status(404).json({message: 'Post not found'});
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
res.status(200).json(post);
|
| 56 |
+
}catch(err){
|
| 57 |
+
res.status(500).json({message: 'Error fetching the post', error: err.message})
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
// update post
|
| 64 |
+
export const updatePost = async(req, res)=>{
|
| 65 |
+
try{
|
| 66 |
+
const {id} = req.params;
|
| 67 |
+
const {title, content} = req.body;
|
| 68 |
+
const post = await Post.findByIdAndUpdate({
|
| 69 |
+
_id: id, author: req.userId
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
title, content
|
| 73 |
+
},{
|
| 74 |
+
new: true
|
| 75 |
+
});
|
| 76 |
+
if(!post){
|
| 77 |
+
return res.status(404).json({message: "Post not found or you are not authorized"});
|
| 78 |
+
}
|
| 79 |
+
res.status(200).json({message: "Post updated successfully", post});
|
| 80 |
+
}catch(err){
|
| 81 |
+
res.status(500).json({message: "Failed to update post", err});
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Delete post
|
| 86 |
+
export const deletePost = async(req, res)=>{
|
| 87 |
+
try{
|
| 88 |
+
const {id} = req.params;
|
| 89 |
+
const post = await Post.findByIdAndDelete({_id: id, author: req.userId});
|
| 90 |
+
if(!post) res.status(404).json({message: "Post not found or you are not authorized"});
|
| 91 |
+
res.status(200).json({message: 'Post deleted successfully'});
|
| 92 |
+
}catch(err){
|
| 93 |
+
res.status(500).json({message: "Failed to delete post", err});
|
| 94 |
+
}
|
| 95 |
+
}
|
backend/controllers/recommendationController.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import dotenv from 'dotenv';
|
| 2 |
+
import { extractLanguage } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 4 |
+
|
| 5 |
+
export const getRecommendations = async (req, res) => {
|
| 6 |
+
dotenv.config();
|
| 7 |
+
|
| 8 |
+
const { climate, soilType, cropType, cropInfo, weatherDetails, cropConditions } = req.body;
|
| 9 |
+
const lang = extractLanguage(req);
|
| 10 |
+
|
| 11 |
+
try {
|
| 12 |
+
// Construct a detailed prompt for the API based on the farmer's inputs
|
| 13 |
+
const promptText = `
|
| 14 |
+
Please provide farming recommendations based on the following information:
|
| 15 |
+
|
| 16 |
+
1. **Climate**: ${climate}
|
| 17 |
+
2. **Soil Type**: ${soilType}
|
| 18 |
+
3. **Crop Type**: ${cropType}
|
| 19 |
+
4. **Information about the Crop**: ${cropInfo}
|
| 20 |
+
5. **Today's Weather**: ${weatherDetails}
|
| 21 |
+
6. **Crop Conditions**: ${cropConditions}
|
| 22 |
+
|
| 23 |
+
Based on this information, please suggest:
|
| 24 |
+
- Suitable farming practices for today.
|
| 25 |
+
- Care tips for the specified crop considering the current weather and soil conditions.
|
| 26 |
+
- Any precautions to take given today's weather and crop requirements.
|
| 27 |
+
|
| 28 |
+
Make the recommendations clear and easy to understand for farmers. Thank you!
|
| 29 |
+
${lang !== 'en' ? `\n\nRespond STRICTLY in ${req.langName || 'the user\'s preferred language'}.` : ''}
|
| 30 |
+
`;
|
| 31 |
+
|
| 32 |
+
const recommendation = await generateAIContent(promptText.trim());
|
| 33 |
+
res.status(200).json({ recommendation });
|
| 34 |
+
} catch (err) {
|
| 35 |
+
console.error("Error fetching recommendations: ", err);
|
| 36 |
+
res.status(500).json({ error: "Failed to fetch recommendations" });
|
| 37 |
+
}
|
| 38 |
+
};
|
backend/controllers/recordController.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Record from "../models/record.model.js";
|
| 2 |
+
import MonthlySummary from'../models/monthlySummary.model.js'
|
| 3 |
+
|
| 4 |
+
export const addRecord = async(req,res)=>{
|
| 5 |
+
try {
|
| 6 |
+
console.log(req.body);
|
| 7 |
+
const { date, expenditure, earnings } = req.body;
|
| 8 |
+
const parsedDate = new Date(date);
|
| 9 |
+
const month = parsedDate.getMonth() + 1; // JS months are 0-indexed, so add 1
|
| 10 |
+
const year = parsedDate.getFullYear();
|
| 11 |
+
const userId = req.userId;
|
| 12 |
+
|
| 13 |
+
const record = new Record({
|
| 14 |
+
date: parsedDate,
|
| 15 |
+
expenditure,
|
| 16 |
+
earnings,
|
| 17 |
+
month,
|
| 18 |
+
year,
|
| 19 |
+
user: userId,
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
await record.save();
|
| 23 |
+
res.status(201).json({ message: 'Record added successfully' });
|
| 24 |
+
} catch (error) {
|
| 25 |
+
res.status(500).json({ error: 'Failed to add record', details: error.message });
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export const getMonthlySummary = async (req, res) => {
|
| 30 |
+
try {
|
| 31 |
+
const { year } = req.params;
|
| 32 |
+
const userId = req.userId;
|
| 33 |
+
const summaries = await MonthlySummary.find({ year, user: userId });
|
| 34 |
+
|
| 35 |
+
res.status(200).json(summaries);
|
| 36 |
+
} catch (_error) {
|
| 37 |
+
res.status(500).json({ error: 'Failed to retrieve monthly summaries' });
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
export const calculateMonthlySummary = async(req,res)=>{
|
| 42 |
+
try {
|
| 43 |
+
const { month, year } = req.body;
|
| 44 |
+
const userId = req.userId;
|
| 45 |
+
|
| 46 |
+
const records = await Record.find({ month, year, user: userId });
|
| 47 |
+
|
| 48 |
+
const totalEarnings = records.reduce((sum, record) => sum + record.earnings, 0);
|
| 49 |
+
const totalExpenditure = records.reduce((sum, record) => sum + record.expenditure, 0);
|
| 50 |
+
const revenue = totalEarnings - totalExpenditure;
|
| 51 |
+
|
| 52 |
+
// Check if a summary already exists for this month and year
|
| 53 |
+
let monthlySummary = await MonthlySummary.findOne({ month, year, user: userId });
|
| 54 |
+
if (monthlySummary) {
|
| 55 |
+
// Update existing summary
|
| 56 |
+
monthlySummary.totalEarnings = totalEarnings;
|
| 57 |
+
monthlySummary.totalExpenditure = totalExpenditure;
|
| 58 |
+
monthlySummary.revenue = revenue;
|
| 59 |
+
} else {
|
| 60 |
+
// Create a new summary
|
| 61 |
+
monthlySummary = new MonthlySummary({
|
| 62 |
+
month,
|
| 63 |
+
year,
|
| 64 |
+
totalEarnings,
|
| 65 |
+
totalExpenditure,
|
| 66 |
+
revenue,
|
| 67 |
+
user: userId,
|
| 68 |
+
});
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
await monthlySummary.save();
|
| 72 |
+
res.status(200).json({ message: 'Monthly summary calculated and saved successfully', monthlySummary });
|
| 73 |
+
} catch (_error) {
|
| 74 |
+
res.status(500).json({ error: 'Failed to calculate monthly summary' });
|
| 75 |
+
}
|
| 76 |
+
}
|
backend/controllers/soilHealthController.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { generateAIContent } from '../utils/aiHelper.js';
|
| 2 |
+
import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
|
| 3 |
+
import dotenv from "dotenv";
|
| 4 |
+
|
| 5 |
+
export const soilHealthRecommendations = async (req, res) => {
|
| 6 |
+
dotenv.config();
|
| 7 |
+
|
| 8 |
+
const {
|
| 9 |
+
soilPH,
|
| 10 |
+
organicMatter,
|
| 11 |
+
nitrogen,
|
| 12 |
+
phosphorus,
|
| 13 |
+
potassium,
|
| 14 |
+
salinity,
|
| 15 |
+
cropType,
|
| 16 |
+
} = req.body;
|
| 17 |
+
|
| 18 |
+
if (
|
| 19 |
+
soilPH === undefined ||
|
| 20 |
+
organicMatter === undefined ||
|
| 21 |
+
nitrogen === undefined ||
|
| 22 |
+
phosphorus === undefined ||
|
| 23 |
+
potassium === undefined ||
|
| 24 |
+
salinity === undefined ||
|
| 25 |
+
!cropType
|
| 26 |
+
) {
|
| 27 |
+
return res.status(400).json({
|
| 28 |
+
error: "Missing required inputs: soilPH, organicMatter, nitrogen, phosphorus, potassium, salinity, cropType",
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const lang = extractLanguage(req);
|
| 33 |
+
const langName = getLanguageName(lang);
|
| 34 |
+
const langInstruction = lang !== 'en' ? `\n\nRespond STRICTLY in ${langName} language. Translate all fields and values.` : '';
|
| 35 |
+
|
| 36 |
+
try {
|
| 37 |
+
const prompt = `
|
| 38 |
+
You are an expert soil scientist.
|
| 39 |
+
|
| 40 |
+
Based on the soil data below:
|
| 41 |
+
- pH: ${soilPH}
|
| 42 |
+
- Organic Matter (%): ${organicMatter}
|
| 43 |
+
- Nitrogen (N): ${nitrogen}
|
| 44 |
+
- Phosphorus (P): ${phosphorus}
|
| 45 |
+
- Potassium (K): ${potassium}
|
| 46 |
+
- Salinity (EC): ${salinity}
|
| 47 |
+
- Crop Type: ${cropType}
|
| 48 |
+
|
| 49 |
+
Provide:
|
| 50 |
+
1. The main current soil issue (1 short line)
|
| 51 |
+
2. Recommended amendments (bullet list; include lime, gypsum, compost, manure, biofertilizer, etc. if relevant)
|
| 52 |
+
3. NPK balancing recommendation (for example: "increase nitrogen slightly", "reduce phosphorus", etc.)
|
| 53 |
+
4. Estimated time for improvement (e.g., "2–4 weeks", "1–2 months")
|
| 54 |
+
5. A short explanation
|
| 55 |
+
|
| 56 |
+
⚠️ Respond ONLY in valid JSON with this structure:
|
| 57 |
+
|
| 58 |
+
{
|
| 59 |
+
"current_issue": "",
|
| 60 |
+
"recommended_amendments": ["", "", ""],
|
| 61 |
+
"npk_adjustment": "",
|
| 62 |
+
"expected_improvement_time": "",
|
| 63 |
+
"note": ""
|
| 64 |
+
}${langInstruction}
|
| 65 |
+
`;
|
| 66 |
+
|
| 67 |
+
const recommendation = await generateAIContent(prompt.trim());
|
| 68 |
+
const formattedRecommendation = recommendation.replace("```json", "").replace("```", "").trim();
|
| 69 |
+
res.status(200).json({ recommendation: formattedRecommendation });
|
| 70 |
+
} catch (err) {
|
| 71 |
+
console.error("Error fetching recommendations: ", err);
|
| 72 |
+
res.status(500).json({ error: "Failed to fetch recommendations" });
|
| 73 |
+
}
|
| 74 |
+
};
|