gh-action-hf-sync commited on
Commit
cfbb235
·
0 Parent(s):

sync: backend from github@cc8365e9

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .space.yaml +9 -0
  2. Dockerfile +87 -0
  3. README.md +333 -0
  4. ai-backend/.gitignore +4 -0
  5. ai-backend/Dockerfile +45 -0
  6. ai-backend/app.py +1609 -0
  7. ai-backend/forecast_model.py +354 -0
  8. ai-backend/model/.gitkeep +0 -0
  9. ai-backend/model_utils.py +169 -0
  10. ai-backend/price-forecast/__init__.py +1 -0
  11. ai-backend/requirements.txt +34 -0
  12. ai-backend/src/__init__.py +0 -0
  13. ai-backend/src/error_handlers.py +132 -0
  14. ai-backend/src/logging_config.py +60 -0
  15. ai-backend/src/models/__init__.py +0 -0
  16. ai-backend/src/models/manager.py +393 -0
  17. ai-backend/src/utils/__init__.py +0 -0
  18. ai-backend/src/utils/retry_utils.py +165 -0
  19. ai-backend/tests/conftest.py +187 -0
  20. ai-backend/tests/test_api_integration.py +552 -0
  21. ai-backend/tests/test_endpoints.py +464 -0
  22. ai-backend/tests/test_models.py +328 -0
  23. backend/Dockerfile +55 -0
  24. backend/FIREBASE_FIRESTORE_SETUP.md +34 -0
  25. backend/contracts/AgroExchange.sol +383 -0
  26. backend/contracts/hardhat.config.js +35 -0
  27. backend/contracts/package-lock.json +0 -0
  28. backend/contracts/package.json +19 -0
  29. backend/contracts/scripts/deploy.js +40 -0
  30. backend/contracts/test/AgroExchange.test.js +325 -0
  31. backend/controllers/appointmentController.js +81 -0
  32. backend/controllers/authController.js +73 -0
  33. backend/controllers/blogRecommendationsController.js +29 -0
  34. backend/controllers/cropController.js +47 -0
  35. backend/controllers/cropRotationController.js +66 -0
  36. backend/controllers/detectHarvestReadinessController.js +92 -0
  37. backend/controllers/expertDetailsController.js +128 -0
  38. backend/controllers/farmerDetailsController.js +92 -0
  39. backend/controllers/farmingNewsController.js +17 -0
  40. backend/controllers/geoPestDiseaseHeatmapController.js +60 -0
  41. backend/controllers/getExpertsController.js +11 -0
  42. backend/controllers/getLoanEligibilityReportController.js +73 -0
  43. backend/controllers/irrigationController.js +37 -0
  44. backend/controllers/marketPredictionController.js +76 -0
  45. backend/controllers/notificationsController.js +88 -0
  46. backend/controllers/pestOutbreakController.js +62 -0
  47. backend/controllers/postController.js +95 -0
  48. backend/controllers/recommendationController.js +38 -0
  49. backend/controllers/recordController.js +76 -0
  50. 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
+ ![Alt Landing Page](frontend/src/assets/LandingPage.png)
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
+ };