gh-action-hf-auto commited on
Commit
8a6248c
·
0 Parent(s):

auto: sync backend from github@32fb9685

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 +331 -0
  4. ai-backend/.gitignore +4 -0
  5. ai-backend/Dockerfile +45 -0
  6. ai-backend/app.py +1570 -0
  7. ai-backend/forecast_model.py +354 -0
  8. ai-backend/model/.gitkeep +0 -0
  9. ai-backend/model_utils.py +201 -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 +396 -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/.eslintrc.json +16 -0
  24. backend/Dockerfile +55 -0
  25. backend/contracts/AgroExchange.sol +383 -0
  26. backend/contracts/hardhat.config.js +35 -0
  27. backend/contracts/package.json +19 -0
  28. backend/contracts/scripts/deploy.js +40 -0
  29. backend/contracts/test/AgroExchange.test.js +325 -0
  30. backend/controllers/appointmentController.js +81 -0
  31. backend/controllers/authController.js +194 -0
  32. backend/controllers/blogRecommendationsController.js +29 -0
  33. backend/controllers/cropController.js +47 -0
  34. backend/controllers/cropRotationController.js +66 -0
  35. backend/controllers/detectHarvestReadinessController.js +92 -0
  36. backend/controllers/expertDetailsController.js +128 -0
  37. backend/controllers/farmerDetailsController.js +92 -0
  38. backend/controllers/farmingNewsController.js +17 -0
  39. backend/controllers/geoPestDiseaseHeatmapController.js +60 -0
  40. backend/controllers/getExpertsController.js +11 -0
  41. backend/controllers/getLoanEligibilityReportController.js +73 -0
  42. backend/controllers/irrigationController.js +37 -0
  43. backend/controllers/marketPredictionController.js +76 -0
  44. backend/controllers/notificationsController.js +99 -0
  45. backend/controllers/pestOutbreakController.js +62 -0
  46. backend/controllers/postController.js +95 -0
  47. backend/controllers/recommendationController.js +38 -0
  48. backend/controllers/recordController.js +76 -0
  49. backend/controllers/soilHealthController.js +74 -0
  50. backend/controllers/taskController.js +107 -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:20-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,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Agromind Backend
3
+ emoji: 🚀
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ # AgroMind: Where Farmers Meet AI & Technology for a Greener Future! 🌾
11
+
12
+ ![Alt Landing Page](frontend/src/assets/LandingPage.png)
13
+
14
+ **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.
15
+
16
+ ## 🚀 Key Features
17
+
18
+ ### Core Features
19
+ - **Expert Consultations** - Real-time video calls and chat with agricultural experts
20
+ - **AI-Powered Recommendations** - Crop, fertilizer, and yield predictions
21
+ - **Task Management** - Goal-based scheduling and tracking
22
+ - **Weather Alerts** - Real-time weather updates and recommendations
23
+ - **Revenue Tracking** - Income and expense management
24
+
25
+ ### New Features (v2.0)
26
+
27
+ | Feature | Description |
28
+ |---------|-------------|
29
+ | **Value Chain Marketplace** | Connect farmers, processors, and buyers for oilseed by-products |
30
+ | **Hedging Platform** | Virtual hedging, price risk management, forward contracts |
31
+ | **Crop Economics** | Comparative crop analysis, govt schemes, profitability simulation |
32
+ | **Oil Palm Advisory** | Farmer profiling, ROI projections, gestation support tracking |
33
+ | **Yield Optimization** | AI-driven yield predictions with intervention suggestions |
34
+ | **Tariff Simulator** | Model impact of customs duty changes on prices |
35
+ | **Millets Marketplace** | Specialized marketplace with traceability and offline support |
36
+ | **CRM Machine Tracking** | Real-time tracking of crop residue management machines |
37
+ | **CROPIC** | AI-based crop damage assessment for insurance |
38
+
39
+ ## 🏗️ Architecture
40
+
41
+ ```
42
+ ┌─────────────────────────────────────────────────────────────┐
43
+ │ Frontend │
44
+ │ (React.js + Vite) │
45
+ └─────────────────────┬───────────────────────────────────────┘
46
+
47
+ ┌───────────────┼───────────────┐
48
+ ▼ ▼ ▼
49
+ ┌──────────┐ ┌──────────┐ ┌──────────────┐
50
+ │ Backend │ │ AI Backend│ │ Smart Contracts│
51
+ │ Node.js │ │ Python │ │ Solidity │
52
+ └────┬─────┘ └────┬─────┘ └──────────────┘
53
+ │ │
54
+ └──────┬───────┘
55
+
56
+ ┌──────────┐
57
+ │ MongoDB │
58
+ │ Redis │
59
+ └──────────┘
60
+ ```
61
+
62
+ ## 📦 Quick Start
63
+
64
+ ### Prerequisites
65
+ - Node.js 20+
66
+ - Python 3.11+
67
+ - Docker & Docker Compose (recommended)
68
+ - MongoDB
69
+
70
+ ### Local Development with Docker
71
+
72
+ ```bash
73
+ # Clone repository
74
+ git clone https://github.com/Anamitra-Sarkar/AgroMind.git
75
+ cd AgroMind
76
+
77
+ # Copy environment file
78
+ cp .env.sample .env
79
+
80
+ # Start all services
81
+ docker-compose up -d
82
+
83
+ # Access applications
84
+ # Frontend: http://localhost:5173
85
+ # Backend: http://localhost:8000
86
+ # AI Backend: http://localhost:5000
87
+ ```
88
+
89
+ ### Manual Setup
90
+
91
+ ```bash
92
+ # Backend
93
+ cd backend && npm install && npm run dev
94
+
95
+ # AI Backend
96
+ cd ai-backend && pip install -r requirements.txt && python app.py
97
+
98
+ # Frontend
99
+ cd frontend && npm install && npm run dev
100
+ ```
101
+
102
+ ## 🔧 Environment Variables
103
+
104
+ See [.env.sample](.env.sample) for all required environment variables.
105
+
106
+ ### Key Variables
107
+
108
+ | Variable | Description |
109
+ |----------|-------------|
110
+ | `MONGO_URL` | MongoDB connection string |
111
+ | `JWT_KEY` | JWT signing key |
112
+ | `FRONTEND_URL` | Frontend URL for CORS |
113
+ | `AI_BACKEND_URL` | AI backend URL |
114
+ | `OPENWEATHER_API_KEY` | OpenWeather API key |
115
+ | `GEMINI_API_KEY` | Google Gemini AI key |
116
+
117
+ ## 🛠️ Technology Stack
118
+
119
+ ### Frontend
120
+ - React.js 18 + Vite
121
+ - TailwindCSS + Material-UI
122
+ - Chart.js + Recharts
123
+ - Socket.IO Client
124
+ - i18next (internationalization)
125
+
126
+ ### Backend
127
+ - Node.js + Express.js
128
+ - MongoDB + Mongoose
129
+ - Redis (caching)
130
+ - Socket.IO
131
+ - JWT Authentication
132
+
133
+ ### AI Backend
134
+ - Python + Flask
135
+ - PyTorch + scikit-learn
136
+ - LightGBM (price forecasting)
137
+ - ResNet (image classification)
138
+
139
+ ### Infrastructure
140
+ - Docker + Docker Compose
141
+ - GitHub Actions CI/CD
142
+ - Vercel (frontend hosting)
143
+ - Hugging Face Spaces (backend hosting)
144
+ - Prometheus + Grafana (monitoring)
145
+
146
+ ## 📚 API Documentation
147
+
148
+ ### Backend API Endpoints
149
+
150
+ | Endpoint | Description |
151
+ |----------|-------------|
152
+ | `/api/auth/*` | Authentication |
153
+ | `/api/valuechain/*` | Marketplace |
154
+ | `/api/hedging/*` | Hedging platform |
155
+ | `/api/crop-economics/*` | Crop comparison |
156
+ | `/api/oilpalm/*` | Oil palm advisory |
157
+ | `/api/crm/*` | Machine tracking |
158
+ | `/api/millets/*` | Millets marketplace |
159
+
160
+ ### AI Backend Endpoints
161
+
162
+ | Endpoint | Description |
163
+ |----------|-------------|
164
+ | `/ai/price-forecast` | Price predictions |
165
+ | `/ai/yield-predict` | Yield predictions |
166
+ | `/ai/tariff-simulate` | Tariff impact simulation |
167
+ | `/ai/cropic/analyze` | Crop damage analysis |
168
+ | `/crop_recommendation` | Crop recommendations |
169
+ | `/fertilizer_prediction` | Fertilizer suggestions |
170
+
171
+ 📄 Full API documentation: [docs/postman_collection.json](docs/postman_collection.json)
172
+
173
+ ## 🧪 Testing
174
+
175
+ ```bash
176
+ # Backend tests
177
+ cd backend && npm test
178
+
179
+ # AI Backend tests
180
+ cd ai-backend && pytest tests/ -v
181
+
182
+ # Frontend tests
183
+ cd frontend && npm test
184
+
185
+ # E2E tests
186
+ cd frontend && npm run cypress:open
187
+
188
+ # Smart contract tests
189
+ cd backend/contracts && npm test
190
+ ```
191
+
192
+ ### AI Backend Testing Details
193
+
194
+ The AI backend now includes comprehensive test coverage with:
195
+
196
+ **Test Coverage:**
197
+ - Unit tests for model predictions (`tests/test_models.py`) - 15 passing, 5 skipped
198
+ - Integration tests for all API endpoints (`tests/test_api_integration.py`)
199
+ - Existing validation tests (`tests/test_endpoints.py`)
200
+
201
+ **Test Features:**
202
+ - Mocked models for fast, deterministic tests (no HF downloads)
203
+ - Retry logic testing with transient failures
204
+ - Error handling and exception logging validation
205
+ - Content-Type and JSON payload validation
206
+ - Model loading and caching tests
207
+
208
+ **Run with Coverage:**
209
+ ```bash
210
+ cd ai-backend
211
+ pytest tests/ -v --cov=. --cov-report=html
212
+ ```
213
+
214
+ For detailed testing documentation and sample payloads, see [docs/TESTING.md](docs/TESTING.md).
215
+
216
+ ## 🚀 Deployment
217
+
218
+ ### Frontend → Vercel
219
+
220
+ ```bash
221
+ cd frontend
222
+ vercel --prod
223
+ ```
224
+
225
+ ### Hugging Face Spaces → Backend only
226
+
227
+ To push only the `backend` and `ai-backend` folders (avoid large frontend/binary files), use the helper script:
228
+
229
+ Example:
230
+
231
+ ```bash
232
+ chmod +x scripts/push_to_hf.sh
233
+ ./scripts/push_to_hf.sh https://huggingface.co/spaces/<username>/<repo>
234
+ ```
235
+
236
+ This creates a temporary git repo containing only `backend` and `ai-backend` and force-pushes `main` to the provided remote.
237
+
238
+ ### GitHub Action (recommended)
239
+
240
+ 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.
241
+
242
+ 1. Add a repository secret named `HF_TOKEN` containing a Hugging Face token with repo write access.
243
+ 2. Run the workflow manually from the Actions tab and provide the `hf_repo` input (e.g. `username/Agromind-backend`).
244
+
245
+ Workflow options:
246
+ - **hf_branch**: target branch on the Hugging Face repo (default `main`).
247
+ - **force**: set to `true` to force-push the target branch (default `false`). Avoid force-push unless you intentionally want to overwrite history.
248
+ - **dry_run**: set to `true` to prepare the temporary repo and list files without pushing (default `false`).
249
+
250
+ Recommended safe flow:
251
+ 1. Run with `dry_run=true` to verify what will be pushed.
252
+ 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`.
253
+
254
+ The workflow file is `.github/workflows/push_to_hf.yml`.
255
+
256
+
257
+ ### Backend → Hugging Face Spaces
258
+
259
+ See [docs/deploy.md](docs/deploy.md) for detailed deployment instructions.
260
+
261
+ ### Required GitHub Secrets
262
+
263
+ ```
264
+ VERCEL_TOKEN
265
+ VERCEL_ORG_ID
266
+ VERCEL_PROJECT_ID
267
+ HF_TOKEN
268
+ HF_BACKEND_SPACE_ID
269
+ HF_AI_BACKEND_SPACE_ID
270
+ ```
271
+
272
+ ## 📁 Project Structure
273
+
274
+ ```
275
+ AgroMind/
276
+ ├── frontend/ # React frontend
277
+ ├── backend/ # Node.js backend
278
+ │ ├── routes/ # API routes
279
+ │ ├── controllers/ # Route handlers
280
+ │ ├── models/ # MongoDB models
281
+ │ ├── middleware/ # Express middleware
282
+ │ ├── socket/ # Socket.IO handlers
283
+ │ └── contracts/ # Smart contracts
284
+ ├── ai-backend/ # Python AI backend
285
+ │ ├── model/ # ML models
286
+ │ └── tests/ # Python tests
287
+ ├── docs/ # Documentation
288
+ ├── config/ # Configuration files
289
+ ├── scripts/ # Utility scripts
290
+ └── docker-compose.yml # Local development
291
+ ```
292
+
293
+ ## 📖 Documentation
294
+
295
+ - [Architecture](docs/architecture.md) - System design and diagrams
296
+ - [Deployment](docs/deploy.md) - Deployment instructions
297
+ - [Security](docs/security.md) - Security checklist
298
+ - [ML Models](docs/models.md) - Model documentation
299
+ - [Local Setup](docs/run_locally.md) - Local development guide
300
+
301
+ ## 🔐 Security
302
+
303
+ - JWT-based authentication
304
+ - Rate limiting and CORS
305
+ - Input validation and sanitization
306
+ - Encrypted data storage
307
+ - Regular dependency scanning
308
+
309
+ See [docs/security.md](docs/security.md) for the full security checklist.
310
+
311
+ ## 🤝 Contributing
312
+
313
+ We welcome contributions! Please:
314
+
315
+ 1. Fork the repository
316
+ 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
317
+ 3. Commit changes (`git commit -m 'Add amazing feature'`)
318
+ 4. Push to branch (`git push origin feature/amazing-feature`)
319
+ 5. Open a Pull Request
320
+
321
+ ## 📄 License
322
+
323
+ This project is licensed under the ISC License.
324
+
325
+ ## 📞 Support
326
+
327
+ For support, email support@agromind.app or join our community.
328
+
329
+ ---
330
+
331
+ **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,1570 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/agromind-plant-disease-nfnet
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/agromind-plant-disease-nfnet 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
+ top_label, confidence, topk = predict(model, pil_img, labels, device, topk=5)
172
+
173
+ # Try to find remedies in a robust way to handle label-format differences
174
+ def find_remedy(label, remedies_dict):
175
+ if not remedies_dict:
176
+ return None
177
+ # direct match
178
+ if label in remedies_dict:
179
+ return remedies_dict[label]
180
+ # try common normalization variants
181
+ variants = set()
182
+ variants.add(label.replace('__', '___'))
183
+ variants.add(label.replace('___', '__'))
184
+ variants.add(label.replace('(', '').replace(')', ''))
185
+ variants.add(label.replace(' ', '_'))
186
+ variants.add(label.replace('-', '_'))
187
+ variants.add(label.lower())
188
+ variants.add(label.replace('__', ' ').lower())
189
+ for v in variants:
190
+ if v in remedies_dict:
191
+ return remedies_dict[v]
192
+ # try case-insensitive match
193
+ for k in remedies_dict.keys():
194
+ if k.lower() == label.lower():
195
+ return remedies_dict[k]
196
+ return None
197
+
198
+ remedy = find_remedy(top_label, remedies)
199
+ response = {
200
+ "label": top_label,
201
+ "confidence": confidence,
202
+ "remedies": remedy,
203
+ "topk": [{"label": l, "confidence": float(c)} for l, c in topk]
204
+ }
205
+ return jsonify(response)
206
+ except Exception as e:
207
+ return jsonify({"error": str(e)}), 500
208
+
209
+
210
+ @app.route("/crop_recommendation", methods=["POST"])
211
+ def crop_recommendation():
212
+ """Crop recommendation endpoint with robust error handling and logging."""
213
+ request_start = _time.time()
214
+
215
+ # Validate Content-Type
216
+ is_valid, error_response = validate_content_type(request)
217
+ if not is_valid:
218
+ return jsonify(error_response), error_response['status']
219
+
220
+ # Lazy-load from Arko007/agromind-crop-recommendation on first call (cached after)
221
+ crop_predict_model = model_manager.get_model('crop_model', auto_load=True)
222
+ crop_predict_sc = model_manager.get_model('crop_standard_scaler', auto_load=True)
223
+ crop_predict_ms = model_manager.get_model('crop_minmax_scaler', auto_load=True)
224
+ # Check model availability
225
+ if crop_predict_model is None or crop_predict_sc is None or crop_predict_ms is None:
226
+ logger.error("Crop recommendation: Model not loaded")
227
+ return jsonify({
228
+ "error": "Crop prediction model not loaded",
229
+ "message": "Service temporarily unavailable. Please try again later."
230
+ }), 500
231
+
232
+ try:
233
+ # Validate JSON payload
234
+ required_fields = ['N', 'P', 'K', 'temperature', 'humidity', 'ph', 'rainfall']
235
+ is_valid, result = validate_json_payload(request, required_fields)
236
+ if not is_valid:
237
+ return jsonify(result), result['status']
238
+
239
+ data = result
240
+ logger.info(f"Crop recommendation request: N={data.get('N')}, P={data.get('P')}, K={data.get('K')}")
241
+
242
+ # Extract and validate features
243
+ try:
244
+ N = float(data['N'])
245
+ P = float(data['P'])
246
+ K = float(data['K'])
247
+ temp = float(data['temperature'])
248
+ humidity = float(data['humidity'])
249
+ ph = float(data['ph'])
250
+ rainfall = float(data['rainfall'])
251
+ except (ValueError, TypeError) as e:
252
+ logger.warning(f"Invalid data type in crop recommendation: {e}")
253
+ return jsonify({
254
+ "error": "Invalid data type",
255
+ "message": f"All numeric fields must be valid numbers: {str(e)}"
256
+ }), 400
257
+
258
+ # Validate ranges
259
+ if not (0 <= N <= 100 and 0 <= P <= 100 and 0 <= K <= 100):
260
+ return jsonify({"error": "N, P, K values must be between 0 and 100"}), 400
261
+ if not (-10 <= temp <= 50):
262
+ return jsonify({"error": "Temperature must be between -10 and 50°C"}), 400
263
+ if not (0 <= humidity <= 100):
264
+ return jsonify({"error": "Humidity must be between 0 and 100%"}), 400
265
+ if not (0 <= ph <= 14):
266
+ return jsonify({"error": "pH must be between 0 and 14"}), 400
267
+ if not (0 <= rainfall <= 500):
268
+ return jsonify({"error": "Rainfall must be between 0 and 500mm"}), 400
269
+
270
+ # Prepare features for prediction
271
+ feature_list = [N, P, K, temp, humidity, ph, rainfall]
272
+ single_pred = np.array(feature_list).reshape(1, -1)
273
+
274
+ # Make prediction with retry wrapper
275
+ @retry_model_inference(max_attempts=2)
276
+ def make_prediction():
277
+ scaled_features = crop_predict_ms.transform(single_pred)
278
+ final_features = crop_predict_sc.transform(scaled_features)
279
+ return crop_predict_model.predict(final_features)
280
+
281
+ prediction = make_prediction()
282
+
283
+ # Get crop name
284
+ if prediction[0] in crop_dict:
285
+ crop = crop_dict[prediction[0]]
286
+ result = f"{crop} is the best crop to be cultivated right there."
287
+
288
+ elapsed_ms = int((_time.time() - request_start) * 1000)
289
+ logger.info(f"Crop recommendation successful: {crop} (took {elapsed_ms}ms)")
290
+
291
+ return jsonify({
292
+ "success": True,
293
+ "crop": crop,
294
+ "message": result,
295
+ "prediction_id": int(prediction[0]),
296
+ "input_data": {
297
+ "nitrogen": N,
298
+ "phosphorus": P,
299
+ "potassium": K,
300
+ "temperature": temp,
301
+ "humidity": humidity,
302
+ "ph": ph,
303
+ "rainfall": rainfall
304
+ }
305
+ }), 200
306
+ else:
307
+ logger.warning(f"Crop recommendation: Unknown prediction ID {prediction[0]}")
308
+ return jsonify({
309
+ "success": False,
310
+ "message": "Could not determine the best crop with the provided data."
311
+ }), 200
312
+
313
+ except Exception as e:
314
+ elapsed_ms = int((_time.time() - request_start) * 1000)
315
+ log_exception(logger, e, f"Crop recommendation failed after {elapsed_ms}ms")
316
+ return jsonify({
317
+ "error": "Internal server error",
318
+ "message": "An unexpected error occurred during prediction. Please try again later."
319
+ }), 500
320
+
321
+
322
+ @app.route('/fertilizer_prediction', methods=['POST'])
323
+ def fertilizer_prediction():
324
+ # Lazy-load from Arko007/agromind-fertilizer-prediction on first call
325
+ classifier_model = model_manager.get_model('fertilizer_classifier', auto_load=True)
326
+ label_encoder = model_manager.get_model('fertilizer_label_encoder', auto_load=True)
327
+ if classifier_model is None or label_encoder is None:
328
+ return jsonify({"error": "Fertilizer prediction model not loaded"}), 503
329
+ try:
330
+ # Get JSON data from request
331
+ data = request.get_json()
332
+
333
+ # Validate required fields
334
+ required_fields = ['temperature', 'humidity', 'moisture', 'soil_type', 'crop_type', 'nitrogen', 'potassium', 'phosphorus']
335
+ for field in required_fields:
336
+ if field not in data:
337
+ return jsonify({
338
+ "error": f"Missing required field: {field}"
339
+ }), 400
340
+
341
+ # Extract features
342
+ temp = int(data['temperature'])
343
+ humi = int(data['humidity'])
344
+ mois = int(data['moisture'])
345
+ soil_type = data['soil_type']
346
+ crop_type = data['crop_type']
347
+ nitro = int(data['nitrogen'])
348
+ pota = int(data['potassium'])
349
+ phosp = int(data['phosphorus'])
350
+
351
+ # Validate soil type
352
+ if soil_type not in soil_mapping:
353
+ return jsonify({
354
+ "error": f"Invalid soil_type. Must be one of: {list(soil_mapping.keys())}"
355
+ }), 400
356
+
357
+ # Validate crop type
358
+ if crop_type not in crop_mapping:
359
+ return jsonify({
360
+ "error": f"Invalid crop_type. Must be one of: {list(crop_mapping.keys())}"
361
+ }), 400
362
+
363
+ # Validate ranges
364
+ if not (0 <= temp <= 100):
365
+ return jsonify({"error": "Temperature must be between 0 and 100"}), 400
366
+ if not (0 <= humi <= 100):
367
+ return jsonify({"error": "Humidity must be between 0 and 100"}), 400
368
+ if not (0 <= mois <= 100):
369
+ return jsonify({"error": "Moisture must be between 0 and 100"}), 400
370
+ if not (0 <= nitro <= 100):
371
+ return jsonify({"error": "Nitrogen must be between 0 and 100"}), 400
372
+ if not (0 <= pota <= 100):
373
+ return jsonify({"error": "Potassium must be between 0 and 100"}), 400
374
+ if not (0 <= phosp <= 100):
375
+ return jsonify({"error": "Phosphorus must be between 0 and 100"}), 400
376
+
377
+ # Convert categorical inputs to numerical values
378
+ soil_encoded = soil_mapping[soil_type]
379
+ crop_encoded = crop_mapping[crop_type]
380
+
381
+ # Prepare input for prediction
382
+ input_data = [temp, humi, mois, soil_encoded, crop_encoded, nitro, pota, phosp]
383
+ input_array = np.array(input_data).reshape(1, -1)
384
+
385
+ # Make prediction
386
+ result_index = classifier_model.predict(input_array)
387
+ result_label = label_encoder.inverse_transform(result_index)
388
+
389
+ return jsonify({
390
+ "success": True,
391
+ "fertilizer": result_label[0],
392
+ "message": f"Predicted fertilizer is {result_label[0]}",
393
+ "input_data": {
394
+ "temperature": temp,
395
+ "humidity": humi,
396
+ "moisture": mois,
397
+ "soil_type": soil_type,
398
+ "crop_type": crop_type,
399
+ "nitrogen": nitro,
400
+ "potassium": pota,
401
+ "phosphorus": phosp
402
+ }
403
+ }), 200
404
+
405
+ except ValueError as e:
406
+ return jsonify({
407
+ "error": f"Invalid data type: {str(e)}"
408
+ }), 400
409
+ except Exception as e:
410
+ return jsonify({
411
+ "error": f"An error occurred: {str(e)}"
412
+ }), 500
413
+
414
+
415
+ @app.route('/loan_prediction', methods=['POST'])
416
+ def loan_prediction():
417
+ # Lazy-load from Arko007/agromind-loan-prediction on first call
418
+ price_model = model_manager.get_model('loan_price_model', auto_load=True)
419
+ approval_model = model_manager.get_model('loan_approval_model', auto_load=True)
420
+ if price_model is None or approval_model is None:
421
+ return jsonify({"error": "Loan prediction model not loaded"}), 503
422
+ try:
423
+ data = request.get_json()
424
+
425
+ # Validate required fields
426
+ required_fields = ['area', 'land_contour', 'distance_from_road', 'soil_type', 'income', 'loan_request']
427
+ for field in required_fields:
428
+ if field not in data:
429
+ return jsonify({'error': f'Missing required field: {field}'}), 400
430
+
431
+ # Extract features
432
+ area = float(data['area'])
433
+ land_contour = data['land_contour']
434
+ distance_from_road = float(data['distance_from_road'])
435
+ soil_type = data['soil_type']
436
+ income = float(data['income'])
437
+ loan_request = float(data['loan_request'])
438
+
439
+ # Prepare input for prediction
440
+ input_data_price = pd.DataFrame({
441
+ 'area': [area],
442
+ 'distance_from_road': [distance_from_road],
443
+ 'income': [income],
444
+ 'land_contour_hilly': [1 if land_contour == 'hilly' else 0],
445
+ 'land_contour_sloping': [1 if land_contour == 'sloping' else 0],
446
+ 'soil_type_clay': [1 if soil_type == 'clay' else 0],
447
+ 'soil_type_sandy': [1 if soil_type == 'sandy' else 0],
448
+ 'soil_type_silty': [1 if soil_type == 'silty' else 0]
449
+ })
450
+
451
+ # Add missing columns with value 0
452
+ for column in price_model.feature_names_in_:
453
+ if column not in input_data_price.columns:
454
+ input_data_price[column] = 0
455
+
456
+ # Ensure column order matches training data
457
+ input_data_price = input_data_price[price_model.feature_names_in_]
458
+
459
+ # Predict farm price
460
+ predicted_price = float(price_model.predict(input_data_price)[0])
461
+
462
+ # Determine loan value
463
+ loan_value = predicted_price if predicted_price <= 500000 else predicted_price * 0.85
464
+
465
+ # Calculate loan approval probability
466
+ if loan_request <= loan_value:
467
+ approval_probability = 1.0
468
+ else:
469
+ diff_ratio = (loan_request - loan_value) / loan_value
470
+ approval_probability = float(np.exp(-5 * diff_ratio))
471
+
472
+ # Return prediction results
473
+ return jsonify({
474
+ 'success': True,
475
+ 'predicted_price': round(predicted_price, 2),
476
+ 'loan_value': round(loan_value, 2),
477
+ 'approval_probability': round(approval_probability * 100, 2),
478
+ 'loan_request': loan_request,
479
+ 'recommendation': 'Approved' if approval_probability >= 0.5 else 'Denied'
480
+ }), 200
481
+
482
+ except ValueError as e:
483
+ return jsonify({
484
+ "error": f"Invalid data type: {str(e)}"
485
+ }), 400
486
+ except Exception as e:
487
+ return jsonify({
488
+ "error": f"An error occurred: {str(e)}"
489
+ }), 500
490
+
491
+
492
+ # =============================================================================
493
+ # Price Forecasting Endpoint
494
+ # =============================================================================
495
+
496
+ # Import price forecast module
497
+ try:
498
+ import sys
499
+ sys.path.insert(0, os.path.dirname(__file__))
500
+ from forecast_model import forecast_prices
501
+ FORECAST_AVAILABLE = True
502
+ except ImportError as e:
503
+ print(f"Price forecast module not available: {e}")
504
+ FORECAST_AVAILABLE = False
505
+
506
+
507
+ @app.route('/ai/price-forecast', methods=['POST'])
508
+ def price_forecast():
509
+ """
510
+ Price forecasting endpoint for commodities
511
+
512
+ Request body:
513
+ {
514
+ "historical_prices": [{"date": "2024-01-01", "price": 100, "volume": 1000}, ...],
515
+ "location": {"lat": 19.0, "lng": 73.0, "state": "Maharashtra"},
516
+ "commodity_type": "groundnut",
517
+ "global_indices": {"crude_oil": 80, "soybean": 1200, "usd_inr": 83},
518
+ "forecast_days": 30
519
+ }
520
+ """
521
+ if not FORECAST_AVAILABLE:
522
+ return jsonify({
523
+ "success": False,
524
+ "error": "Price forecast module not available"
525
+ }), 503
526
+
527
+ try:
528
+ data = request.get_json()
529
+
530
+ if not data:
531
+ return jsonify({
532
+ "success": False,
533
+ "error": "No data provided"
534
+ }), 400
535
+
536
+ # Validate required fields
537
+ historical_prices = data.get('historical_prices', [])
538
+ if not historical_prices or len(historical_prices) < 5:
539
+ return jsonify({
540
+ "success": False,
541
+ "error": "Need at least 5 historical price points"
542
+ }), 400
543
+
544
+ # Extract optional parameters
545
+ location = data.get('location')
546
+ commodity_type = data.get('commodity_type', 'oilseed')
547
+ global_indices = data.get('global_indices')
548
+ forecast_days = data.get('forecast_days', 30)
549
+
550
+ # Validate forecast_days
551
+ if forecast_days not in [7, 30, 90]:
552
+ forecast_days = 30
553
+
554
+ # Generate forecast
555
+ result = forecast_prices(
556
+ historical_prices=historical_prices,
557
+ location=location,
558
+ commodity_type=commodity_type,
559
+ global_indices=global_indices,
560
+ forecast_days=forecast_days
561
+ )
562
+
563
+ if result.get('success'):
564
+ return jsonify(result), 200
565
+ else:
566
+ return jsonify(result), 400
567
+
568
+ except ValueError as e:
569
+ return jsonify({
570
+ "success": False,
571
+ "error": f"Invalid data: {str(e)}"
572
+ }), 400
573
+ except Exception as e:
574
+ return jsonify({
575
+ "success": False,
576
+ "error": f"Forecast error: {str(e)}"
577
+ }), 500
578
+
579
+
580
+ # =============================================================================
581
+ # Yield Prediction Endpoint
582
+ # =============================================================================
583
+
584
+ @app.route('/ai/yield-predict', methods=['POST'])
585
+ def yield_prediction():
586
+ """
587
+ Yield prediction endpoint
588
+
589
+ Request body:
590
+ {
591
+ "crop_type": "groundnut",
592
+ "location": {"lat": 19.0, "lng": 73.0, "state": "Maharashtra"},
593
+ "soil_data": {"nitrogen": 50, "phosphorus": 30, "potassium": 40, "ph": 6.5},
594
+ "weather_data": {"rainfall": 800, "temperature": 28, "humidity": 65},
595
+ "area_hectares": 5
596
+ }
597
+ """
598
+ try:
599
+ data = request.get_json()
600
+
601
+ if not data:
602
+ return jsonify({
603
+ "success": False,
604
+ "error": "No data provided"
605
+ }), 400
606
+
607
+ crop_type = data.get('crop_type', 'groundnut')
608
+ location = data.get('location', {})
609
+ soil_data = data.get('soil_data', {})
610
+ weather_data = data.get('weather_data', {})
611
+ area_hectares = data.get('area_hectares', 1)
612
+
613
+ # Simple yield estimation based on factors
614
+ # In production, this would use a trained ML model
615
+
616
+ # Base yield per hectare (kg/ha) by crop
617
+ base_yields = {
618
+ 'groundnut': 1800,
619
+ 'sunflower': 1200,
620
+ 'soybean': 2000,
621
+ 'mustard': 1100,
622
+ 'sesame': 600,
623
+ 'castor': 1500,
624
+ 'linseed': 800
625
+ }
626
+
627
+ base_yield = base_yields.get(crop_type.lower(), 1500)
628
+
629
+ # Adjust for soil quality
630
+ soil_factor = 1.0
631
+ if soil_data:
632
+ n = soil_data.get('nitrogen', 50)
633
+ p = soil_data.get('phosphorus', 30)
634
+ k = soil_data.get('potassium', 40)
635
+ ph = soil_data.get('ph', 6.5)
636
+
637
+ # Optimal ranges adjustment
638
+ if 40 <= n <= 60 and 25 <= p <= 40 and 30 <= k <= 50:
639
+ soil_factor = 1.1
640
+ elif n < 20 or p < 15 or k < 20:
641
+ soil_factor = 0.8
642
+
643
+ # pH adjustment
644
+ if 6.0 <= ph <= 7.5:
645
+ soil_factor *= 1.05
646
+ elif ph < 5.5 or ph > 8.0:
647
+ soil_factor *= 0.85
648
+
649
+ # Adjust for weather
650
+ weather_factor = 1.0
651
+ if weather_data:
652
+ rainfall = weather_data.get('rainfall', 700)
653
+ temp = weather_data.get('temperature', 28)
654
+
655
+ # Rainfall adjustment
656
+ if 600 <= rainfall <= 1000:
657
+ weather_factor = 1.1
658
+ elif rainfall < 400 or rainfall > 1500:
659
+ weather_factor = 0.75
660
+
661
+ # Temperature adjustment
662
+ if 25 <= temp <= 32:
663
+ weather_factor *= 1.05
664
+ elif temp < 20 or temp > 38:
665
+ weather_factor *= 0.85
666
+
667
+ # Calculate predicted yield
668
+ predicted_yield_per_ha = base_yield * soil_factor * weather_factor
669
+ total_yield = predicted_yield_per_ha * area_hectares
670
+
671
+ # Calculate confidence based on data completeness
672
+ confidence = 0.7
673
+ if soil_data:
674
+ confidence += 0.1
675
+ if weather_data:
676
+ confidence += 0.1
677
+ if location:
678
+ confidence += 0.05
679
+
680
+ # Generate recommendations
681
+ interventions = []
682
+ if soil_factor < 1.0:
683
+ interventions.append({
684
+ "type": "fertilization",
685
+ "priority": "high",
686
+ "recommendation": "Apply balanced NPK fertilizer to improve soil nutrient levels"
687
+ })
688
+ if weather_factor < 1.0:
689
+ interventions.append({
690
+ "type": "irrigation",
691
+ "priority": "medium",
692
+ "recommendation": "Consider supplemental irrigation during dry spells"
693
+ })
694
+
695
+ return jsonify({
696
+ "success": True,
697
+ "data": {
698
+ "crop_type": crop_type,
699
+ "area_hectares": area_hectares,
700
+ "predicted_yield_kg_per_ha": round(predicted_yield_per_ha, 2),
701
+ "total_predicted_yield_kg": round(total_yield, 2),
702
+ "confidence": round(confidence, 2),
703
+ "factors": {
704
+ "soil_factor": round(soil_factor, 2),
705
+ "weather_factor": round(weather_factor, 2)
706
+ },
707
+ "interventions": interventions,
708
+ "feature_importance": {
709
+ "soil_nutrients": 0.35,
710
+ "rainfall": 0.25,
711
+ "temperature": 0.15,
712
+ "location": 0.15,
713
+ "crop_variety": 0.10
714
+ }
715
+ }
716
+ }), 200
717
+
718
+ except Exception as e:
719
+ return jsonify({
720
+ "success": False,
721
+ "error": f"Prediction error: {str(e)}"
722
+ }), 500
723
+
724
+
725
+ # =============================================================================
726
+ # Tariff Impact Simulation Endpoint
727
+ # =============================================================================
728
+
729
+ @app.route('/ai/tariff-simulate', methods=['POST'])
730
+ def tariff_simulation():
731
+ """
732
+ Simulate impact of customs duty changes on imports and prices
733
+
734
+ Request body:
735
+ {
736
+ "tariff_pct": 35,
737
+ "period": "6_months",
738
+ "global_price_shock": 0
739
+ }
740
+ """
741
+ try:
742
+ data = request.get_json() or {}
743
+
744
+ tariff_pct = data.get('tariff_pct', 35)
745
+ period = data.get('period', '6_months')
746
+ global_price_shock = data.get('global_price_shock', 0) # % change in global prices
747
+
748
+ # Base parameters (simplified model)
749
+ base_import_volume = 15000000 # 15 million tonnes
750
+ base_domestic_price = 120 # INR per kg
751
+ base_farmer_price = 95
752
+ base_consumer_price = 145
753
+
754
+ # Elasticities (simplified)
755
+ import_elasticity = -0.8 # How much imports change with price
756
+ domestic_price_elasticity = 0.3 # How domestic price changes with reduced imports
757
+ pass_through_farmer = 0.6 # How much of price change reaches farmers
758
+ pass_through_consumer = 0.8 # How much reaches consumers
759
+
760
+ # Current tariff baseline
761
+ current_tariff = 35
762
+ tariff_change = tariff_pct - current_tariff
763
+
764
+ # Calculate impacts
765
+ # Higher tariff -> lower imports -> higher domestic prices
766
+
767
+ # Import volume change
768
+ effective_price_change = (tariff_change / 100) + (global_price_shock / 100)
769
+ import_volume_change = effective_price_change * import_elasticity * 100
770
+ new_import_volume = base_import_volume * (1 + import_volume_change / 100)
771
+ new_import_volume = max(new_import_volume, 0)
772
+
773
+ # Domestic price change
774
+ supply_reduction = (base_import_volume - new_import_volume) / base_import_volume
775
+ domestic_price_change = supply_reduction * domestic_price_elasticity * 100
776
+ new_domestic_price = base_domestic_price * (1 + domestic_price_change / 100)
777
+
778
+ # Farmer and consumer prices
779
+ farmer_price_change = domestic_price_change * pass_through_farmer
780
+ consumer_price_change = domestic_price_change * pass_through_consumer
781
+
782
+ new_farmer_price = base_farmer_price * (1 + farmer_price_change / 100)
783
+ new_consumer_price = base_consumer_price * (1 + consumer_price_change / 100)
784
+
785
+ # Sensitivity analysis
786
+ sensitivity_table = []
787
+ for sensitivity_tariff in [25, 30, 35, 40, 45, 50]:
788
+ sens_change = sensitivity_tariff - current_tariff
789
+ sens_import_change = (sens_change / 100) * import_elasticity * 100
790
+ sens_import_vol = base_import_volume * (1 + sens_import_change / 100)
791
+ sens_supply_red = (base_import_volume - sens_import_vol) / base_import_volume
792
+ sens_price_change = sens_supply_red * domestic_price_elasticity * 100
793
+
794
+ sensitivity_table.append({
795
+ "tariff_pct": sensitivity_tariff,
796
+ "import_volume_mt": round(sens_import_vol / 1000000, 2),
797
+ "domestic_price_inr": round(base_domestic_price * (1 + sens_price_change / 100), 2),
798
+ "farmer_price_inr": round(base_farmer_price * (1 + sens_price_change * pass_through_farmer / 100), 2)
799
+ })
800
+
801
+ return jsonify({
802
+ "success": True,
803
+ "data": {
804
+ "scenario": {
805
+ "tariff_pct": tariff_pct,
806
+ "period": period,
807
+ "global_price_shock_pct": global_price_shock
808
+ },
809
+ "baseline": {
810
+ "import_volume_mt": round(base_import_volume / 1000000, 2),
811
+ "domestic_price_inr_kg": base_domestic_price,
812
+ "farmer_price_inr_kg": base_farmer_price,
813
+ "consumer_price_inr_kg": base_consumer_price
814
+ },
815
+ "predicted": {
816
+ "import_volume_mt": round(new_import_volume / 1000000, 2),
817
+ "import_change_pct": round(import_volume_change, 2),
818
+ "domestic_price_inr_kg": round(new_domestic_price, 2),
819
+ "domestic_price_change_pct": round(domestic_price_change, 2),
820
+ "farmer_price_inr_kg": round(new_farmer_price, 2),
821
+ "farmer_price_change_pct": round(farmer_price_change, 2),
822
+ "consumer_price_inr_kg": round(new_consumer_price, 2),
823
+ "consumer_price_change_pct": round(consumer_price_change, 2)
824
+ },
825
+ "sensitivity_analysis": sensitivity_table,
826
+ "model_assumptions": {
827
+ "import_elasticity": import_elasticity,
828
+ "domestic_price_elasticity": domestic_price_elasticity,
829
+ "pass_through_farmer": pass_through_farmer,
830
+ "pass_through_consumer": pass_through_consumer
831
+ }
832
+ }
833
+ }), 200
834
+
835
+ except Exception as e:
836
+ return jsonify({
837
+ "success": False,
838
+ "error": f"Simulation error: {str(e)}"
839
+ }), 500
840
+
841
+
842
+ # =============================================================================
843
+ # CROPIC - Crop Image Analysis Endpoint
844
+ # =============================================================================
845
+
846
+ @app.route('/ai/cropic/analyze', methods=['POST'])
847
+ def cropic_analyze():
848
+ """
849
+ Analyze crop damage from image for insurance purposes
850
+
851
+ Request: multipart/form-data with:
852
+ - file: image file
853
+ - metadata: JSON string with {lat, lng, crop_type, stage}
854
+ """
855
+ try:
856
+ if 'file' not in request.files:
857
+ return jsonify({
858
+ "success": False,
859
+ "error": "No image file provided"
860
+ }), 400
861
+
862
+ file = request.files['file']
863
+ if file.filename == '':
864
+ return jsonify({
865
+ "success": False,
866
+ "error": "Empty filename"
867
+ }), 400
868
+
869
+ # Get metadata
870
+ metadata = {}
871
+ if 'metadata' in request.form:
872
+ try:
873
+ metadata = json.loads(request.form['metadata'])
874
+ except:
875
+ pass
876
+
877
+ # Read and validate image
878
+ img_bytes = file.read()
879
+
880
+ # Load disease model if available (lazy)
881
+ model = model_manager.get_model('disease_model', auto_load=False)
882
+ labels = model_manager.get_model('disease_labels', auto_load=False) or []
883
+
884
+ # Check file size (max 10MB)
885
+ if len(img_bytes) > 10 * 1024 * 1024:
886
+ return jsonify({
887
+ "success": False,
888
+ "error": "Image too large. Maximum size is 10MB"
889
+ }), 400
890
+
891
+ try:
892
+ pil_img = Image.open(io.BytesIO(img_bytes))
893
+ pil_img = pil_img.convert('RGB')
894
+
895
+ # Check image dimensions
896
+ width, height = pil_img.size
897
+ if width < 100 or height < 100:
898
+ return jsonify({
899
+ "success": False,
900
+ "error": "Image too small. Minimum dimensions: 100x100"
901
+ }), 400
902
+
903
+ except Exception as e:
904
+ return jsonify({
905
+ "success": False,
906
+ "error": f"Invalid image format: {str(e)}"
907
+ }), 400
908
+
909
+ # Use existing disease model for classification if available
910
+ crop_type = metadata.get('crop_type', 'unknown')
911
+ stage = metadata.get('stage', 'vegetative')
912
+
913
+ # If disease model is loaded, use it for damage classification
914
+ damage_type = "unknown"
915
+ damage_percentage = 0
916
+ confidence = 0.5
917
+
918
+ if model is not None:
919
+ try:
920
+ top_label, conf, topk = predict(model, pil_img, labels, device, topk=5)
921
+
922
+ # Map disease labels to damage types
923
+ damage_mapping = {
924
+ 'healthy': ('none', 0),
925
+ 'bacterial': ('bacterial_infection', 40),
926
+ 'fungal': ('fungal_disease', 35),
927
+ 'viral': ('viral_infection', 45),
928
+ 'pest': ('pest_damage', 30),
929
+ 'nutrient': ('nutrient_deficiency', 25),
930
+ 'drought': ('drought_stress', 50),
931
+ 'flood': ('waterlogging', 60)
932
+ }
933
+
934
+ # Simple matching
935
+ for key, (dtype, dpct) in damage_mapping.items():
936
+ if key in top_label.lower():
937
+ damage_type = dtype
938
+ damage_percentage = dpct
939
+ break
940
+
941
+ if 'healthy' in top_label.lower():
942
+ damage_type = 'none'
943
+ damage_percentage = 0
944
+ else:
945
+ # Estimate damage from confidence
946
+ damage_percentage = int(conf * 50) # Scale to reasonable range
947
+ if damage_type == 'unknown':
948
+ damage_type = 'unclassified_damage'
949
+
950
+ confidence = float(conf)
951
+
952
+ except Exception as e:
953
+ print(f"Classification error: {e}")
954
+ # Fallback to random estimation for demo
955
+ damage_type = "unclassified_damage"
956
+ damage_percentage = np.random.randint(10, 50)
957
+ confidence = 0.6
958
+ else:
959
+ # No model - provide simulated response
960
+ damage_types = ['none', 'pest_damage', 'disease', 'drought_stress', 'flood_damage']
961
+ damage_type = np.random.choice(damage_types, p=[0.3, 0.2, 0.25, 0.15, 0.1])
962
+ damage_percentage = 0 if damage_type == 'none' else np.random.randint(10, 60)
963
+ confidence = np.random.uniform(0.6, 0.9)
964
+
965
+ return jsonify({
966
+ "success": True,
967
+ "data": {
968
+ "crop_type": crop_type,
969
+ "stage": stage,
970
+ "damage_type": damage_type,
971
+ "damage_percentage": damage_percentage,
972
+ "confidence": round(confidence, 2),
973
+ "image_quality": {
974
+ "dimensions": f"{width}x{height}",
975
+ "format": pil_img.format or "JPEG",
976
+ "is_valid": True
977
+ },
978
+ "location": {
979
+ "lat": metadata.get('lat'),
980
+ "lng": metadata.get('lng')
981
+ },
982
+ "recommendations": _get_damage_recommendations(damage_type, damage_percentage)
983
+ }
984
+ }), 200
985
+
986
+ except Exception as e:
987
+ return jsonify({
988
+ "success": False,
989
+ "error": f"Analysis error: {str(e)}"
990
+ }), 500
991
+
992
+
993
+ def _get_damage_recommendations(damage_type: str, damage_pct: int) -> list:
994
+ """Get recommendations based on damage type and severity"""
995
+ recommendations = []
996
+
997
+ if damage_type == 'none':
998
+ recommendations.append({
999
+ "action": "monitoring",
1000
+ "description": "Continue regular monitoring. Crop appears healthy."
1001
+ })
1002
+ elif damage_type == 'pest_damage':
1003
+ recommendations.append({
1004
+ "action": "pesticide_application",
1005
+ "description": "Apply appropriate pesticide. Consult local agriculture office for specific recommendations."
1006
+ })
1007
+ elif damage_type in ['disease', 'bacterial_infection', 'fungal_disease']:
1008
+ recommendations.append({
1009
+ "action": "fungicide_treatment",
1010
+ "description": "Apply fungicide treatment. Remove and destroy affected plant parts."
1011
+ })
1012
+ elif damage_type == 'drought_stress':
1013
+ recommendations.append({
1014
+ "action": "irrigation",
1015
+ "description": "Increase irrigation frequency. Apply mulch to retain soil moisture."
1016
+ })
1017
+ elif damage_type in ['flood_damage', 'waterlogging']:
1018
+ recommendations.append({
1019
+ "action": "drainage",
1020
+ "description": "Improve field drainage. Allow soil to dry before resuming irrigation."
1021
+ })
1022
+
1023
+ if damage_pct >= 50:
1024
+ recommendations.append({
1025
+ "action": "insurance_claim",
1026
+ "description": "Damage exceeds 50%. Consider filing an insurance claim.",
1027
+ "priority": "high"
1028
+ })
1029
+
1030
+ return recommendations
1031
+
1032
+
1033
+ # ── Harvest Readiness Detection ─────────────────────────────────────────────
1034
+ _harvest_model = None
1035
+
1036
+ def _load_harvest_model():
1037
+ """Lazy-load the harvest readiness YOLO classification model from HF Hub."""
1038
+ global _harvest_model
1039
+ if _harvest_model is not None:
1040
+ return _harvest_model
1041
+ try:
1042
+ from ultralytics import YOLO
1043
+ model_path = hf_hub_download(
1044
+ repo_id=HF_REPO_HARVEST, filename="best.pt"
1045
+ )
1046
+ _harvest_model = YOLO(model_path)
1047
+ logger.info("Harvest readiness YOLO model loaded from HF Hub")
1048
+ except Exception as e:
1049
+ log_exception(logger, e, "Failed to load harvest readiness model")
1050
+ _harvest_model = None
1051
+ return _harvest_model
1052
+
1053
+
1054
+ @app.route("/harvest_readiness", methods=["POST"])
1055
+ def harvest_readiness():
1056
+ """Detect harvest readiness from a crop image using YOLO11m-cls."""
1057
+ model = _load_harvest_model()
1058
+ if model is None:
1059
+ return jsonify({"error": "Harvest readiness model unavailable"}), 503
1060
+
1061
+ if "file" not in request.files:
1062
+ return jsonify({"error": "no file part"}), 400
1063
+ file = request.files["file"]
1064
+ if file.filename == "":
1065
+ return jsonify({"error": "empty filename"}), 400
1066
+
1067
+ try:
1068
+ img_bytes = file.read()
1069
+ pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
1070
+
1071
+ results = model.predict(pil_img, imgsz=224, verbose=False)
1072
+ result = results[0]
1073
+
1074
+ # Classification results: result.probs contains probabilities
1075
+ probs = result.probs
1076
+ top_class_idx = int(probs.top1)
1077
+ top_confidence = float(probs.top1conf)
1078
+ class_name = result.names[top_class_idx] if result.names else str(top_class_idx)
1079
+
1080
+ # Determine readiness from class name using keyword matching
1081
+ # The YOLO model classifies into categories like "Ready", "Unripe", "Overripe", etc.
1082
+ ready_keywords = {"ready", "ripe", "mature", "harvest", "overripe"}
1083
+ unready_keywords = {"unready", "unripe", "immature", "green", "growing"}
1084
+ class_lower = class_name.lower()
1085
+ class_tokens = set(class_lower.replace("_", " ").replace("-", " ").split())
1086
+ is_ready = bool(class_tokens & ready_keywords) and not bool(class_tokens & unready_keywords)
1087
+
1088
+ # Estimate maturity percentage from confidence and class
1089
+ if is_ready:
1090
+ maturity = max(80, int(top_confidence * 100))
1091
+ days_left = 0
1092
+ else:
1093
+ maturity = max(10, min(70, int(top_confidence * 60)))
1094
+ days_left = max(1, int((100 - maturity) * 0.5))
1095
+
1096
+ # Build top-k predictions
1097
+ topk_indices = probs.top5 if hasattr(probs, 'top5') else [top_class_idx]
1098
+ topk_confs = probs.top5conf.tolist() if hasattr(probs, 'top5conf') else [top_confidence]
1099
+ topk = [
1100
+ {"label": result.names.get(int(idx), str(idx)), "confidence": round(float(c), 4)}
1101
+ for idx, c in zip(topk_indices, topk_confs)
1102
+ ]
1103
+
1104
+ return jsonify({
1105
+ "ready": "Yes" if is_ready else "No",
1106
+ "maturity": maturity,
1107
+ "days_left": days_left,
1108
+ "note": f"Classified as '{class_name}' with {top_confidence:.1%} confidence.",
1109
+ "class": class_name,
1110
+ "confidence": round(top_confidence, 4),
1111
+ "topk": topk,
1112
+ })
1113
+ except Exception as e:
1114
+ logger.error(f"Harvest readiness prediction error: {e}")
1115
+ return jsonify({"error": str(e)}), 500
1116
+
1117
+
1118
+ # ── Saffron Authenticity Classifier ──────────────────────────────────────────
1119
+ HF_SAFFRON_REPO = os.environ.get("HF_REPO_SAFFRON", "Arko007/saffron-verify-pretrained")
1120
+ HF_SAFFRON_API = f"https://api-inference.huggingface.co/models/{HF_SAFFRON_REPO}"
1121
+ SAFFRON_CLASSES = ["mogra", "lacha", "adulterated"]
1122
+
1123
+
1124
+ @app.route("/saffron_classify", methods=["POST"])
1125
+ def saffron_classify():
1126
+ """Classify saffron purity from an uploaded image."""
1127
+ if "file" not in request.files:
1128
+ return jsonify({"error": "no file part"}), 400
1129
+ file = request.files["file"]
1130
+ if file.filename == "":
1131
+ return jsonify({"error": "empty filename"}), 400
1132
+
1133
+ try:
1134
+ img_bytes = file.read()
1135
+ # Validate it is a real image
1136
+ pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
1137
+
1138
+ # Try HF Inference API first
1139
+ hf_token = os.environ.get("HF_TOKEN", "")
1140
+ headers = {}
1141
+ if hf_token:
1142
+ headers["Authorization"] = f"Bearer {hf_token}"
1143
+
1144
+ try:
1145
+ # Re-encode as JPEG for the API
1146
+ buf = io.BytesIO()
1147
+ pil_img.save(buf, format="JPEG")
1148
+ resp = _call_hf_inference_api(
1149
+ HF_SAFFRON_API,
1150
+ headers=headers,
1151
+ data=buf.getvalue(),
1152
+ )
1153
+ if resp.status_code == 200:
1154
+ results = resp.json()
1155
+ if isinstance(results, list) and len(results) > 0:
1156
+ top = results[0]
1157
+ return jsonify({
1158
+ "model": "saffron-verify-pretrained",
1159
+ "prediction": top.get("label", "unknown"),
1160
+ "confidence": round(top.get("score", 0.0), 4),
1161
+ "all_predictions": [
1162
+ {"label": r.get("label", ""), "confidence": round(r.get("score", 0.0), 4)}
1163
+ for r in results
1164
+ ],
1165
+ "timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
1166
+ })
1167
+ logger.warning(f"Saffron HF API returned status {resp.status_code}: {resp.text[:200]}")
1168
+ except Exception as api_err:
1169
+ logger.warning(f"Saffron HF Inference API failed: {api_err}")
1170
+
1171
+ # Fallback: try loading model locally via timm
1172
+ try:
1173
+ import timm
1174
+ import torch.nn as nn
1175
+ from torchvision import transforms
1176
+
1177
+ class _SaffronModel(nn.Module):
1178
+ def __init__(self):
1179
+ super().__init__()
1180
+ self.backbone = timm.create_model(
1181
+ "convnext_base", pretrained=False,
1182
+ num_classes=0, drop_rate=0.3, drop_path_rate=0.2,
1183
+ )
1184
+ feat_dim = self.backbone.num_features
1185
+ self.head = nn.Sequential(
1186
+ nn.LayerNorm(feat_dim),
1187
+ nn.Dropout(p=0.3),
1188
+ nn.Linear(feat_dim, 512),
1189
+ nn.GELU(),
1190
+ nn.Dropout(p=0.15),
1191
+ nn.Linear(512, 3),
1192
+ )
1193
+
1194
+ def forward(self, x):
1195
+ return self.head(self.backbone(x))
1196
+
1197
+ ckpt_path = hf_hub_download(repo_id=HF_SAFFRON_REPO, filename="best_model.pth")
1198
+ model_s = _SaffronModel()
1199
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
1200
+ model_s.load_state_dict(ckpt.get("model_state", ckpt.get("model_state_dict", ckpt)))
1201
+ model_s.eval()
1202
+
1203
+ transform = transforms.Compose([
1204
+ transforms.Resize(512),
1205
+ transforms.CenterCrop(512),
1206
+ transforms.ToTensor(),
1207
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
1208
+ ])
1209
+ tensor = transform(pil_img).unsqueeze(0)
1210
+ with torch.no_grad():
1211
+ logits = model_s(tensor)
1212
+ probs = torch.softmax(logits, dim=1)[0]
1213
+ pred_idx = probs.argmax().item()
1214
+
1215
+ all_preds = [
1216
+ {"label": SAFFRON_CLASSES[i], "confidence": round(probs[i].item(), 4)}
1217
+ for i in range(len(SAFFRON_CLASSES))
1218
+ ]
1219
+ all_preds.sort(key=lambda x: x["confidence"], reverse=True)
1220
+
1221
+ return jsonify({
1222
+ "model": "saffron-verify-pretrained",
1223
+ "prediction": SAFFRON_CLASSES[pred_idx],
1224
+ "confidence": round(probs[pred_idx].item(), 4),
1225
+ "all_predictions": all_preds,
1226
+ "timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
1227
+ })
1228
+ except Exception as local_err:
1229
+ logger.warning(f"Saffron local model also failed: {local_err}")
1230
+
1231
+ return jsonify({"error": "Saffron model unavailable via API and local fallback"}), 503
1232
+ except Exception as e:
1233
+ logger.error(f"Saffron classification error: {e}")
1234
+ return jsonify({"error": str(e)}), 500
1235
+
1236
+
1237
+ # ── Walnut Defect Classifier ────────────────────────────────────────────────
1238
+ HF_WALNUT_DEFECT_REPO = os.environ.get("HF_REPO_WALNUT_DEFECT", "Arko007/walnut-defect-classifier")
1239
+ HF_WALNUT_DEFECT_API = f"https://api-inference.huggingface.co/models/{HF_WALNUT_DEFECT_REPO}"
1240
+ WALNUT_DEFECT_CLASSES = ["Healthy", "Black Spot", "Shriveled", "Damaged"]
1241
+
1242
+
1243
+ @app.route("/walnut_defect_classify", methods=["POST"])
1244
+ def walnut_defect_classify():
1245
+ """Classify walnut shell defects from an uploaded image."""
1246
+ if "file" not in request.files:
1247
+ return jsonify({"error": "no file part"}), 400
1248
+ file = request.files["file"]
1249
+ if file.filename == "":
1250
+ return jsonify({"error": "empty filename"}), 400
1251
+
1252
+ try:
1253
+ img_bytes = file.read()
1254
+ pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
1255
+
1256
+ hf_token = os.environ.get("HF_TOKEN", "")
1257
+ headers = {}
1258
+ if hf_token:
1259
+ headers["Authorization"] = f"Bearer {hf_token}"
1260
+
1261
+ try:
1262
+ buf = io.BytesIO()
1263
+ pil_img.save(buf, format="JPEG")
1264
+ resp = _call_hf_inference_api(
1265
+ HF_WALNUT_DEFECT_API,
1266
+ headers=headers,
1267
+ data=buf.getvalue(),
1268
+ )
1269
+ if resp.status_code == 200:
1270
+ results = resp.json()
1271
+ if isinstance(results, list) and len(results) > 0:
1272
+ top = results[0]
1273
+ return jsonify({
1274
+ "model": "walnut-defect-classifier",
1275
+ "prediction": top.get("label", "unknown"),
1276
+ "confidence": round(top.get("score", 0.0), 4),
1277
+ "all_predictions": [
1278
+ {"label": r.get("label", ""), "confidence": round(r.get("score", 0.0), 4)}
1279
+ for r in results
1280
+ ],
1281
+ "timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
1282
+ })
1283
+ logger.warning(f"Walnut defect HF API returned status {resp.status_code}: {resp.text[:200]}")
1284
+ except Exception as api_err:
1285
+ logger.warning(f"Walnut defect HF Inference API failed: {api_err}")
1286
+
1287
+ # Fallback: load model locally via timm
1288
+ try:
1289
+ import timm
1290
+ ckpt_path = hf_hub_download(repo_id=HF_WALNUT_DEFECT_REPO, filename="best_model.pth")
1291
+ model_w = timm.create_model("efficientnet_b3", pretrained=False, num_classes=4, drop_rate=0.4)
1292
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
1293
+ state = {k.replace("module.", ""): v for k, v in ckpt.get("model_state_dict", ckpt).items()}
1294
+ model_w.load_state_dict(state)
1295
+ model_w.eval()
1296
+
1297
+ from torchvision import transforms
1298
+ transform = transforms.Compose([
1299
+ transforms.Resize((512, 512)),
1300
+ transforms.ToTensor(),
1301
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
1302
+ ])
1303
+ tensor = transform(pil_img).unsqueeze(0)
1304
+ with torch.no_grad():
1305
+ probs = torch.softmax(model_w(tensor), dim=1)[0]
1306
+ pred_idx = probs.argmax().item()
1307
+
1308
+ all_preds = [
1309
+ {"label": WALNUT_DEFECT_CLASSES[i], "confidence": round(probs[i].item(), 4)}
1310
+ for i in range(len(WALNUT_DEFECT_CLASSES))
1311
+ ]
1312
+ all_preds.sort(key=lambda x: x["confidence"], reverse=True)
1313
+
1314
+ return jsonify({
1315
+ "model": "walnut-defect-classifier",
1316
+ "prediction": WALNUT_DEFECT_CLASSES[pred_idx],
1317
+ "confidence": round(probs[pred_idx].item(), 4),
1318
+ "all_predictions": all_preds,
1319
+ "timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
1320
+ })
1321
+ except Exception as local_err:
1322
+ logger.warning(f"Walnut defect local model also failed: {local_err}")
1323
+
1324
+ return jsonify({"error": "Walnut defect model unavailable via API and local fallback"}), 503
1325
+ except Exception as e:
1326
+ logger.error(f"Walnut defect classification error: {e}")
1327
+ return jsonify({"error": str(e)}), 500
1328
+
1329
+
1330
+ # ── Walnut Rancidity Predictor ──────────────────────────────────────────────
1331
+
1332
+ @app.route("/walnut_rancidity_predict", methods=["POST"])
1333
+ def walnut_rancidity_predict():
1334
+ """Predict walnut rancidity and remaining shelf life from storage conditions.
1335
+
1336
+ Uses Arrhenius-based lipid oxidation kinetics (the same chemistry model
1337
+ behind the Arko007/walnut-rancidity-predictor HF model) so that the
1338
+ endpoint works without downloading a ~85 K-parameter LSTM checkpoint.
1339
+ """
1340
+ is_valid, error_response = validate_content_type(request)
1341
+ if not is_valid:
1342
+ return jsonify(error_response), error_response["status"]
1343
+
1344
+ try:
1345
+ data = request.get_json(force=True)
1346
+ except Exception:
1347
+ return jsonify({"error": "Invalid JSON payload"}), 400
1348
+
1349
+ # Required fields
1350
+ storage_days = data.get("storage_days")
1351
+ temperature = data.get("temperature")
1352
+ humidity = data.get("humidity")
1353
+ moisture = data.get("moisture")
1354
+
1355
+ if storage_days is None or temperature is None or humidity is None or moisture is None:
1356
+ return jsonify({
1357
+ "error": "Missing required fields: storage_days, temperature, humidity, moisture"
1358
+ }), 400
1359
+
1360
+ try:
1361
+ storage_days = float(storage_days)
1362
+ temperature = float(temperature)
1363
+ humidity = float(humidity)
1364
+ moisture = float(moisture)
1365
+ except (ValueError, TypeError):
1366
+ return jsonify({"error": "All inputs must be numeric"}), 400
1367
+
1368
+ # Validation with friendly responses
1369
+ if storage_days < 0 or storage_days > 365:
1370
+ return jsonify({
1371
+ "success": False,
1372
+ "message": "Please choose storage days between 0 and 365 days.",
1373
+ "validation_error": "storage_days_out_of_range"
1374
+ }), 200
1375
+
1376
+ if temperature < -10 or temperature > 50:
1377
+ return jsonify({
1378
+ "success": False,
1379
+ "message": "Please choose temperature between -10 and 50 degrees Celsius.",
1380
+ "validation_error": "temperature_out_of_range"
1381
+ }), 200
1382
+
1383
+ if humidity < 0 or humidity > 100:
1384
+ return jsonify({
1385
+ "success": False,
1386
+ "message": "Please choose humidity between 0 and 100 percent.",
1387
+ "validation_error": "humidity_out_of_range"
1388
+ }), 200
1389
+
1390
+ if moisture < 0 or moisture > 15:
1391
+ return jsonify({
1392
+ "success": False,
1393
+ "message": "Please choose a value between 0 and 15 percent for moisture content.",
1394
+ "validation_error": "moisture_out_of_range"
1395
+ }), 200
1396
+
1397
+ try:
1398
+ import math
1399
+
1400
+ # Arrhenius kinetics: k(T) = A * exp(-Ea / (R * T_kelvin))
1401
+ A = 1.5e12
1402
+ Ea = 80_000 # J/mol
1403
+ R = 8.314 # J/(mol*K)
1404
+ T_kelvin = temperature + 273.15
1405
+ k_base = A * math.exp(-Ea / (R * T_kelvin))
1406
+
1407
+ # Humidity and moisture correction factors
1408
+ humidity_factor = 1.0 + 0.005 * max(0, humidity - 50)
1409
+ moisture_factor = 1.0 + 0.02 * max(0, moisture - 4)
1410
+ k_eff = k_base * humidity_factor * moisture_factor
1411
+
1412
+ # PV(t) = PV_0 * exp(k * t) — initial PV ~ 0.5 meq/kg for fresh walnuts
1413
+ PV_0 = 0.5
1414
+ PV_t = PV_0 * math.exp(k_eff * storage_days)
1415
+
1416
+ # Rancidity threshold: PV > 5 meq/kg (FSSAI / Codex)
1417
+ rancidity_prob = 1.0 / (1.0 + math.exp(-(PV_t - 5)))
1418
+
1419
+ # Shelf life remaining = days until PV reaches 5
1420
+ if PV_t >= 5:
1421
+ shelf_life_remaining = 0.0
1422
+ elif k_eff > 0:
1423
+ shelf_life_remaining = max(0.0, (math.log(5 / PV_0) / k_eff) - storage_days)
1424
+ else:
1425
+ shelf_life_remaining = 365.0
1426
+
1427
+ # Decay curve (normalised PV, capped at 1)
1428
+ decay_curve = min(1.0, PV_t / 10.0)
1429
+
1430
+ # Risk level
1431
+ if rancidity_prob < 0.30:
1432
+ risk_level = "LOW"
1433
+ elif rancidity_prob < 0.70:
1434
+ risk_level = "MEDIUM"
1435
+ else:
1436
+ risk_level = "HIGH"
1437
+
1438
+ return jsonify({
1439
+ "success": True,
1440
+ "model": "walnut-rancidity-predictor",
1441
+ "prediction": {
1442
+ "rancidity_probability": round(rancidity_prob, 4),
1443
+ "shelf_life_remaining_days": round(shelf_life_remaining, 1),
1444
+ "decay_curve_value": round(decay_curve, 4),
1445
+ },
1446
+ "risk_level": risk_level,
1447
+ "advisory": (
1448
+ "Walnuts are safe for storage."
1449
+ if risk_level == "LOW"
1450
+ else "Monitor quality closely — consider selling soon."
1451
+ if risk_level == "MEDIUM"
1452
+ else "High rancidity risk — sell or consume immediately."
1453
+ ),
1454
+ "timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
1455
+ }), 200
1456
+ except Exception as e:
1457
+ logger.error(f"Walnut rancidity prediction error: {e}")
1458
+ return jsonify({"error": str(e)}), 500
1459
+
1460
+
1461
+ # ── Apple Price Predictor ────────────────────────────────────────────────────
1462
+ APPLE_VARIETY_BASE_PRICES = {
1463
+ "Shimla": 97, "Kinnauri": 125, "Royal Delicious": 82,
1464
+ "Golden Delicious": 87, "Maharaji": 60,
1465
+ }
1466
+ APPLE_REGIONS = ["Himachal Pradesh", "Jammu & Kashmir", "Uttarakhand",
1467
+ "Arunachal Pradesh", "Nagaland"]
1468
+ APPLE_STORAGE_COST_PER_DAY = 0.75 # ₹/kg/day
1469
+
1470
+
1471
+ @app.route("/apple_price_predict", methods=["POST"])
1472
+ def apple_price_predict():
1473
+ """Predict apple wholesale price 7 days ahead and recommend SELL or STORE."""
1474
+ is_valid, error_response = validate_content_type(request)
1475
+ if not is_valid:
1476
+ return jsonify(error_response), error_response["status"]
1477
+
1478
+ try:
1479
+ data = request.get_json(force=True)
1480
+ except Exception:
1481
+ return jsonify({"error": "Invalid JSON payload"}), 400
1482
+
1483
+ current_price = data.get("current_price")
1484
+ apple_variety = data.get("apple_variety", "Shimla")
1485
+ region = data.get("region", "Himachal Pradesh")
1486
+ storage_time_days = data.get("storage_time_days", 0)
1487
+ date_str = data.get("date", _time.strftime("%Y-%m-%d"))
1488
+
1489
+ if current_price is None:
1490
+ return jsonify({"error": "current_price is required"}), 400
1491
+
1492
+ try:
1493
+ current_price = float(current_price)
1494
+ storage_time_days = int(storage_time_days)
1495
+ except (ValueError, TypeError):
1496
+ return jsonify({"error": "current_price must be numeric, storage_time_days must be integer"}), 400
1497
+
1498
+ if current_price <= 0:
1499
+ return jsonify({"error": "current_price must be positive"}), 400
1500
+
1501
+ try:
1502
+ import math
1503
+ from datetime import datetime
1504
+
1505
+ # Parse date for seasonal adjustment
1506
+ try:
1507
+ dt = datetime.strptime(date_str, "%Y-%m-%d")
1508
+ except ValueError:
1509
+ dt = datetime.utcnow()
1510
+
1511
+ month = dt.month
1512
+
1513
+ # Seasonal price adjustments (Indian apple market dynamics)
1514
+ seasonal_adj = 0.0
1515
+ if 7 <= month <= 9: # Harvest season — supply glut
1516
+ seasonal_adj = -12.0
1517
+ elif 4 <= month <= 6: # Summer scarcity
1518
+ seasonal_adj = 15.0
1519
+ elif month in (10, 11): # Diwali festival demand
1520
+ seasonal_adj = 8.0
1521
+
1522
+ # Variety premium
1523
+ base_price = APPLE_VARIETY_BASE_PRICES.get(apple_variety, 90)
1524
+ variety_factor = base_price / 90.0
1525
+
1526
+ # Storage quality decay
1527
+ storage_decay = -0.08 * storage_time_days
1528
+
1529
+ # Simple trend: mild inflation
1530
+ annual_inflation = 5.0
1531
+ days_in_year = 365.0
1532
+ trend_adj = (7.0 / days_in_year) * annual_inflation
1533
+
1534
+ # Predicted 7-day price (deterministic)
1535
+ predicted_price_7d = round(
1536
+ current_price * variety_factor
1537
+ + seasonal_adj
1538
+ + storage_decay
1539
+ + trend_adj,
1540
+ 2,
1541
+ )
1542
+ # Clamp to realistic range
1543
+ predicted_price_7d = max(30.0, min(200.0, predicted_price_7d))
1544
+
1545
+ storage_cost_7d = round(APPLE_STORAGE_COST_PER_DAY * 7, 2)
1546
+ breakeven_price = round(current_price + storage_cost_7d, 2)
1547
+ recommendation = "STORE" if predicted_price_7d > breakeven_price else "SELL"
1548
+
1549
+ return jsonify({
1550
+ "model": "apple-price-predictor",
1551
+ "predicted_price_7d": predicted_price_7d,
1552
+ "recommendation": recommendation,
1553
+ "current_price": current_price,
1554
+ "storage_cost_7d": storage_cost_7d,
1555
+ "breakeven_price": breakeven_price,
1556
+ "currency": "INR",
1557
+ "confidence": "hybrid seasonal+trend model",
1558
+ "advisory": (
1559
+ f"Predicted price in 7 days: ₹{predicted_price_7d}/kg. "
1560
+ f"{'Store for better returns.' if recommendation == 'STORE' else 'Sell now — prices may not cover storage costs.'}"
1561
+ ),
1562
+ "timestamp": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
1563
+ }), 200
1564
+ except Exception as e:
1565
+ logger.error(f"Apple price prediction error: {e}")
1566
+ return jsonify({"error": str(e)}), 500
1567
+
1568
+
1569
+ if __name__ == "__main__":
1570
+ 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,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/agromind-plant-disease-nfnet"
23
+ )
24
+ HF_REPO_MOBILENET = os.environ.get(
25
+ "HF_REPO_MOBILENET", "Arko007/agromind-plant-disease-mobilenet"
26
+ )
27
+
28
+ # Default assumptions (can be overridden by model config.json)
29
+ IMAGENET_MEAN = [0.485, 0.456, 0.406]
30
+ IMAGENET_STD = [0.229, 0.224, 0.225]
31
+ INPUT_SIZE = 224
32
+
33
+ # Preprocessing transform (resize -> center crop -> to tensor -> normalize)
34
+ transform = transforms.Compose([
35
+ transforms.Resize(256),
36
+ transforms.CenterCrop(INPUT_SIZE),
37
+ transforms.ToTensor(),
38
+ transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD)
39
+ ])
40
+
41
+
42
+ def _download(repo_id, filename):
43
+ """Download a file from HF Hub with caching."""
44
+ logger.info("Downloading %s from %s ...", filename, repo_id)
45
+ return hf_hub_download(repo_id=repo_id, filename=filename)
46
+
47
+
48
+ def load_labels(path):
49
+ """Load labels from a text file (one label per line) or return empty list."""
50
+ if path is None:
51
+ return []
52
+ with open(path, "r", encoding="utf-8") as f:
53
+ return [line.strip() for line in f if line.strip()]
54
+
55
+
56
+ def load_remedies(path):
57
+ """Load remedies JSON or return empty dict."""
58
+ if path is None:
59
+ return {}
60
+ with open(path, "r", encoding="utf-8") as f:
61
+ return json.load(f)
62
+
63
+
64
+ def build_mobilenet_model(num_classes, device, checkpoint_path):
65
+ model = models.mobilenet_v2(pretrained=False)
66
+ num_ftrs = model.classifier[1].in_features
67
+ model.classifier[1] = torch.nn.Linear(num_ftrs, num_classes)
68
+
69
+ state = torch.load(checkpoint_path, map_location=device)
70
+ if isinstance(state, dict) and ("state_dict" in state) and not any(
71
+ k.startswith("module.") for k in state
72
+ ):
73
+ model.load_state_dict(state["state_dict"])
74
+ else:
75
+ try:
76
+ model.load_state_dict(state)
77
+ except Exception:
78
+ new_state = {}
79
+ for k, v in state.items():
80
+ name = k.replace("module.", "") if k.startswith("module.") else k
81
+ new_state[name] = v
82
+ model.load_state_dict(new_state)
83
+
84
+ model.to(device)
85
+ model.eval()
86
+ return model
87
+
88
+
89
+ def build_timm_model_from_config(config, checkpoint_path, device):
90
+ try:
91
+ import timm
92
+ except Exception as e:
93
+ raise ImportError(
94
+ "timm is required to load timm models: pip install timm"
95
+ ) from e
96
+
97
+ try:
98
+ from safetensors.torch import load_file as load_safetensors
99
+ except Exception as e:
100
+ raise ImportError(
101
+ "safetensors is required: pip install safetensors"
102
+ ) from e
103
+
104
+ class_names = config.get("class_names") or config.get("labels")
105
+ if class_names is None:
106
+ raise ValueError("config.json must contain 'class_names' list")
107
+
108
+ model = timm.create_model(
109
+ config["architecture"], pretrained=False, num_classes=len(class_names)
110
+ )
111
+ state_dict = load_safetensors(checkpoint_path)
112
+ model.load_state_dict(state_dict)
113
+ model.to(device)
114
+ model.eval()
115
+ return model
116
+
117
+
118
+ def load_model_from_hf(device, prefer_nfnet=True):
119
+ """
120
+ Download and load the plant-disease model from Hugging Face Hub.
121
+
122
+ Tries NFNet-F1 first (if prefer_nfnet is True), then falls back to
123
+ MobileNetV2.
124
+
125
+ Returns: model, labels, remedies
126
+ """
127
+ global transform
128
+
129
+ # --- Try NFNet-F1 (safetensors) ---
130
+ if prefer_nfnet:
131
+ try:
132
+ st_path = _download(HF_REPO_NFNET, "model.safetensors")
133
+ cfg_path = _download(HF_REPO_NFNET, "config.json")
134
+ labels_path = _download(HF_REPO_NFNET, "labels.txt")
135
+ remedies_path = _download(HF_REPO_NFNET, "remedies.json")
136
+
137
+ with open(cfg_path, "r", encoding="utf-8") as f:
138
+ config = json.load(f)
139
+
140
+ labels = config.get("class_names", [])
141
+ if os.path.exists(labels_path):
142
+ file_labels = load_labels(labels_path)
143
+ if len(file_labels) == len(labels):
144
+ labels = file_labels
145
+
146
+ remedies = load_remedies(remedies_path)
147
+
148
+ img_size = config.get("input_size", INPUT_SIZE)
149
+ mean = config.get("normalization", {}).get("mean", IMAGENET_MEAN)
150
+ std = config.get("normalization", {}).get("std", IMAGENET_STD)
151
+ transform = transforms.Compose([
152
+ transforms.Resize((img_size, img_size)),
153
+ transforms.ToTensor(),
154
+ transforms.Normalize(mean=mean, std=std),
155
+ ])
156
+
157
+ model = build_timm_model_from_config(config, st_path, device)
158
+ logger.info("Loaded NFNet-F1 model from HF Hub (%s)", HF_REPO_NFNET)
159
+ return model, labels, remedies
160
+ except Exception as e:
161
+ logger.warning(
162
+ "Could not load NFNet-F1 from HF Hub: %s — falling back to MobileNetV2", e
163
+ )
164
+
165
+ # --- Fallback: MobileNetV2 (.pth) ---
166
+ try:
167
+ pth_path = _download(HF_REPO_MOBILENET, "newplant_model_final.pth")
168
+ labels_path = _download(HF_REPO_MOBILENET, "labels.txt")
169
+ remedies_path = _download(HF_REPO_MOBILENET, "remedies.json")
170
+ except Exception as e:
171
+ raise RuntimeError(
172
+ f"Failed to download MobileNetV2 model from HF Hub "
173
+ f"({HF_REPO_MOBILENET}): {e}"
174
+ ) from e
175
+
176
+ labels = load_labels(labels_path)
177
+ remedies = load_remedies(remedies_path)
178
+
179
+ transform = transforms.Compose([
180
+ transforms.Resize(256),
181
+ transforms.CenterCrop(INPUT_SIZE),
182
+ transforms.ToTensor(),
183
+ transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
184
+ ])
185
+
186
+ model = build_mobilenet_model(len(labels), device, pth_path)
187
+ logger.info("Loaded MobileNetV2 model from HF Hub (%s)", HF_REPO_MOBILENET)
188
+ return model, labels, remedies
189
+
190
+
191
+ def predict(model, pil_image, labels, device, topk=3):
192
+ """Return top-1 label, confidence, and topk list of (label, prob)."""
193
+ img_t = transform(pil_image).unsqueeze(0).to(device)
194
+ with torch.no_grad():
195
+ outputs = model(img_t)
196
+ probs = F.softmax(outputs, dim=1)
197
+ top_probs, top_idxs = probs.topk(topk, dim=1)
198
+ top_probs = top_probs.cpu().numpy()[0]
199
+ top_idxs = top_idxs.cpu().numpy()[0]
200
+ top_labels = [labels[i] for i in top_idxs]
201
+ return top_labels[0], float(top_probs[0]), list(zip(top_labels, top_probs.tolist()))
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
2
+ pillow
3
+ numpy<2
4
+
5
+ --extra-index-url https://download.pytorch.org/whl/cpu
6
+ torch
7
+ torchvision
8
+ torchaudio
9
+
10
+ huggingface_hub
11
+
12
+ # optional for NFNet-F1 safetensors model
13
+ timm
14
+ safetensors
15
+
16
+ flask-cors
17
+ pandas
18
+ scikit-learn==1.5.1
19
+ joblib
20
+ lightgbm
21
+ gunicorn
22
+
23
+ # Retry and testing utilities
24
+ tenacity
25
+ pytest
26
+ pytest-cov
27
+ pytest-mock
28
+ httpx
29
+
30
+ # YOLO harvest readiness model
31
+ ultralytics
32
+
33
+ # HTTP client for HF Inference API fallback
34
+ requests
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,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(prefer_nfnet: bool = True) -> Tuple[Any, list, dict]:
68
+ """Load the disease detection model from HF Hub.
69
+
70
+ Args:
71
+ prefer_nfnet: Try NFNet-F1 first, fallback to MobileNetV2
72
+
73
+ Returns:
74
+ Tuple of (model, labels, remedies)
75
+ """
76
+ # Import model_utils which has the loading logic
77
+ from model_utils import load_model_from_hf
78
+
79
+ device = get_device()
80
+ model, labels, remedies = load_model_from_hf(device, prefer_nfnet=prefer_nfnet)
81
+ logger.info(f"Disease model loaded — {len(labels)} labels")
82
+ return model, labels, remedies
83
+
84
+
85
+ def load_crop_recommendation_models() -> Dict[str, Any]:
86
+ """Load crop recommendation models from HF Hub.
87
+
88
+ Returns:
89
+ Dictionary containing model, StandardScaler, and MinMaxScaler
90
+ """
91
+ repo_id = os.environ.get("HF_REPO_CROP", "Arko007/agromind-crop-recommendation")
92
+
93
+ try:
94
+ model = joblib.load(_download_from_hf(repo_id, "crop_predict_model.pkl"))
95
+ standard_scaler = joblib.load(_download_from_hf(repo_id, "crop_predict_standscaler.pkl"))
96
+ minmax_scaler = joblib.load(_download_from_hf(repo_id, "crop_predict_minmaxscaler.pkl"))
97
+ logger.info("Crop recommendation models loaded from HF Hub")
98
+ except Exception as exc:
99
+ if _is_numpy_binary_compat_error(exc):
100
+ raise RuntimeError(
101
+ "Crop recommendation models failed to load due to NumPy/SciPy binary compatibility. "
102
+ "Ensure scikit-learn==1.5.1 and numpy<2 are installed (matching training environment)."
103
+ ) from exc
104
+ raise
105
+
106
+ return {
107
+ "model": model,
108
+ "standard_scaler": standard_scaler,
109
+ "minmax_scaler": minmax_scaler
110
+ }
111
+
112
+
113
+ def load_fertilizer_models() -> Dict[str, Any]:
114
+ """Load fertilizer prediction models from HF Hub.
115
+
116
+ Returns:
117
+ Dictionary containing classifier and label_encoder
118
+ """
119
+ repo_id = os.environ.get("HF_REPO_FERTILIZER", "Arko007/agromind-fertilizer-prediction")
120
+
121
+ try:
122
+ with open(_download_from_hf(repo_id, "classifier.pkl"), "rb") as f:
123
+ classifier = pickle.load(f)
124
+ with open(_download_from_hf(repo_id, "fertilizer.pkl"), "rb") as f:
125
+ label_encoder = pickle.load(f)
126
+ logger.info("Fertilizer prediction models loaded from HF Hub")
127
+ except Exception as exc:
128
+ if _is_numpy_binary_compat_error(exc):
129
+ raise RuntimeError(
130
+ "Fertilizer models failed to load due to NumPy/SciPy binary compatibility. "
131
+ "Ensure scikit-learn==1.5.1 and numpy<2 are installed (matching training environment)."
132
+ ) from exc
133
+ raise
134
+
135
+ return {
136
+ "classifier": classifier,
137
+ "label_encoder": label_encoder
138
+ }
139
+
140
+
141
+ def load_loan_models() -> Dict[str, Any]:
142
+ """Load loan prediction models from HF Hub.
143
+
144
+ Returns:
145
+ Dictionary containing price_model and approval_model
146
+ """
147
+ repo_id = os.environ.get("HF_REPO_LOAN", "Arko007/agromind-loan-prediction")
148
+
149
+ try:
150
+ price_model = joblib.load(_download_from_hf(repo_id, "price_model.pkl"))
151
+ approval_model = joblib.load(_download_from_hf(repo_id, "approval_model.pkl"))
152
+ logger.info("Loan prediction models loaded from HF Hub")
153
+ except Exception as exc:
154
+ if _is_numpy_binary_compat_error(exc):
155
+ raise RuntimeError(
156
+ "Loan models failed to load due to NumPy/SciPy binary compatibility. "
157
+ "Ensure scikit-learn==1.5.1 and numpy<2 are installed (matching training environment)."
158
+ ) from exc
159
+ raise
160
+
161
+ return {
162
+ "price_model": price_model,
163
+ "approval_model": approval_model
164
+ }
165
+
166
+
167
+ def initialize_models(load_all: bool = True) -> None:
168
+ """Initialize all models at startup.
169
+
170
+ This function should be called during application startup to load
171
+ all models into the cache. Models that fail to load will be logged
172
+ but won't crash the application.
173
+
174
+ Args:
175
+ load_all: If True, attempts to load all models. If False, only loads on-demand.
176
+ """
177
+ if not load_all:
178
+ logger.info("Model lazy-loading enabled. Models will load on first use.")
179
+ return
180
+
181
+ logger.info("Initializing all models...")
182
+
183
+ # Load disease model
184
+ try:
185
+ model, labels, remedies = load_disease_model(prefer_nfnet=True)
186
+ _model_cache['disease_model'] = model
187
+ _model_cache['disease_labels'] = labels
188
+ _model_cache['disease_remedies'] = remedies
189
+ except Exception as e:
190
+ log_exception(logger, e, "Failed to load disease model")
191
+ _model_cache['disease_model'] = None
192
+ _model_cache['disease_labels'] = []
193
+ _model_cache['disease_remedies'] = {}
194
+
195
+ # Load crop recommendation models
196
+ try:
197
+ crop_models = load_crop_recommendation_models()
198
+ _model_cache['crop_model'] = crop_models['model']
199
+ _model_cache['crop_standard_scaler'] = crop_models['standard_scaler']
200
+ _model_cache['crop_minmax_scaler'] = crop_models['minmax_scaler']
201
+ except Exception as e:
202
+ log_exception(logger, e, "Failed to load crop recommendation models")
203
+ _model_cache['crop_model'] = None
204
+ _model_cache['crop_standard_scaler'] = None
205
+ _model_cache['crop_minmax_scaler'] = None
206
+
207
+ # Load fertilizer models
208
+ try:
209
+ fertilizer_models = load_fertilizer_models()
210
+ _model_cache['fertilizer_classifier'] = fertilizer_models['classifier']
211
+ _model_cache['fertilizer_label_encoder'] = fertilizer_models['label_encoder']
212
+ except Exception as e:
213
+ log_exception(logger, e, "Failed to load fertilizer models")
214
+ _model_cache['fertilizer_classifier'] = None
215
+ _model_cache['fertilizer_label_encoder'] = None
216
+
217
+ # Load loan models
218
+ try:
219
+ loan_models = load_loan_models()
220
+ _model_cache['loan_price_model'] = loan_models['price_model']
221
+ _model_cache['loan_approval_model'] = loan_models['approval_model']
222
+ except Exception as e:
223
+ log_exception(logger, e, "Failed to load loan models")
224
+ _model_cache['loan_price_model'] = None
225
+ _model_cache['loan_approval_model'] = None
226
+
227
+ logger.info("Model initialization complete")
228
+
229
+
230
+ def get_model(model_name: str, auto_load: bool = True) -> Optional[Any]:
231
+ """Get a model from the cache.
232
+
233
+ Args:
234
+ model_name: Name of the model to retrieve
235
+ auto_load: If True and model not in cache, attempt to load it
236
+
237
+ Returns:
238
+ Model instance or None if not available
239
+ """
240
+ if model_name in _model_cache:
241
+ return _model_cache[model_name]
242
+
243
+ if not auto_load:
244
+ return None
245
+
246
+ # Attempt to load on-demand
247
+ logger.info(f"Model '{model_name}' not in cache, loading on-demand...")
248
+
249
+ try:
250
+ if model_name == 'disease_model':
251
+ model, labels, remedies = load_disease_model()
252
+ _model_cache['disease_model'] = model
253
+ _model_cache['disease_labels'] = labels
254
+ _model_cache['disease_remedies'] = remedies
255
+ return model
256
+ elif 'crop' in model_name:
257
+ crop_models = load_crop_recommendation_models()
258
+ _model_cache['crop_model'] = crop_models['model']
259
+ _model_cache['crop_standard_scaler'] = crop_models['standard_scaler']
260
+ _model_cache['crop_minmax_scaler'] = crop_models['minmax_scaler']
261
+ return _model_cache.get(model_name)
262
+ elif 'fertilizer' in model_name:
263
+ fertilizer_models = load_fertilizer_models()
264
+ _model_cache['fertilizer_classifier'] = fertilizer_models['classifier']
265
+ _model_cache['fertilizer_label_encoder'] = fertilizer_models['label_encoder']
266
+ return _model_cache.get(model_name)
267
+ elif 'loan' in model_name:
268
+ loan_models = load_loan_models()
269
+ _model_cache['loan_price_model'] = loan_models['price_model']
270
+ _model_cache['loan_approval_model'] = loan_models['approval_model']
271
+ return _model_cache.get(model_name)
272
+ except Exception as e:
273
+ log_exception(logger, e, f"Failed to load model '{model_name}'")
274
+ return None
275
+
276
+ return None
277
+
278
+
279
+ def predict_crop(features: np.ndarray) -> int:
280
+ """Predict crop recommendation from features.
281
+
282
+ Args:
283
+ features: NumPy array of shape (1, 7) with [N, P, K, temp, humidity, ph, rainfall]
284
+
285
+ Returns:
286
+ Predicted crop ID (integer)
287
+
288
+ Raises:
289
+ RuntimeError: If models are not loaded
290
+ """
291
+ model = get_model('crop_model')
292
+ minmax_scaler = get_model('crop_minmax_scaler')
293
+ standard_scaler = get_model('crop_standard_scaler')
294
+
295
+ if model is None or minmax_scaler is None or standard_scaler is None:
296
+ raise RuntimeError("Crop recommendation models not loaded")
297
+
298
+ # Scale features
299
+ scaled_features = minmax_scaler.transform(features)
300
+ final_features = standard_scaler.transform(scaled_features)
301
+
302
+ # Make prediction
303
+ prediction = model.predict(final_features)
304
+ return int(prediction[0])
305
+
306
+
307
+ def predict_fertilizer(features: np.ndarray) -> str:
308
+ """Predict fertilizer recommendation from features.
309
+
310
+ Args:
311
+ features: NumPy array with soil and crop features
312
+
313
+ Returns:
314
+ Predicted fertilizer name (string)
315
+
316
+ Raises:
317
+ RuntimeError: If models are not loaded
318
+ """
319
+ classifier = get_model('fertilizer_classifier')
320
+ label_encoder = get_model('fertilizer_label_encoder')
321
+
322
+ if classifier is None or label_encoder is None:
323
+ raise RuntimeError("Fertilizer prediction models not loaded")
324
+
325
+ # Make prediction
326
+ prediction = classifier.predict(features)
327
+ fertilizer = label_encoder.inverse_transform(prediction)
328
+ return str(fertilizer[0])
329
+
330
+
331
+ def predict_disease(pil_image, topk: int = 3) -> Tuple[str, float, list]:
332
+ """Predict disease from plant image.
333
+
334
+ Args:
335
+ pil_image: PIL Image object
336
+ topk: Number of top predictions to return
337
+
338
+ Returns:
339
+ Tuple of (top_label, confidence, top_k_predictions)
340
+
341
+ Raises:
342
+ RuntimeError: If model is not loaded
343
+ """
344
+ from model_utils import predict
345
+
346
+ model = get_model('disease_model')
347
+ labels = get_model('disease_labels')
348
+
349
+ if model is None or not labels:
350
+ raise RuntimeError("Disease model not loaded")
351
+
352
+ device = get_device()
353
+ return predict(model, pil_image, labels, device, topk=topk)
354
+
355
+
356
+ def get_disease_remedy(label: str) -> Optional[str]:
357
+ """Get remedy for a disease label.
358
+
359
+ Args:
360
+ label: Disease label
361
+
362
+ Returns:
363
+ Remedy string or None if not found
364
+ """
365
+ remedies = get_model('disease_remedies')
366
+ if remedies is None:
367
+ return None
368
+ return remedies.get(label)
369
+
370
+
371
+ def is_model_loaded(model_name: str) -> bool:
372
+ """Check if a model is loaded in the cache.
373
+
374
+ Args:
375
+ model_name: Name of the model to check
376
+
377
+ Returns:
378
+ True if model is loaded and not None, False otherwise
379
+ """
380
+ return model_name in _model_cache and _model_cache[model_name] is not None
381
+
382
+
383
+ def get_model_status() -> Dict[str, bool]:
384
+ """Get the status of all models.
385
+
386
+ Returns:
387
+ Dictionary mapping model names to their loaded status
388
+ """
389
+ return {
390
+ 'disease_model': is_model_loaded('disease_model'),
391
+ 'crop_model': is_model_loaded('crop_model'),
392
+ 'fertilizer_classifier': is_model_loaded('fertilizer_classifier'),
393
+ 'loan_price_model': is_model_loaded('loan_price_model'),
394
+ 'loan_approval_model': is_model_loaded('loan_approval_model'),
395
+ 'harvest_readiness_model': is_model_loaded('harvest_readiness_model'),
396
+ }
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/.eslintrc.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "env": {
3
+ "node": true,
4
+ "es2022": true,
5
+ "jest": true
6
+ },
7
+ "extends": ["eslint:recommended"],
8
+ "parserOptions": {
9
+ "ecmaVersion": "latest",
10
+ "sourceType": "module"
11
+ },
12
+ "rules": {
13
+ "no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }],
14
+ "no-console": "off"
15
+ }
16
+ }
backend/Dockerfile ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend Dockerfile (HF Spaces compatible)
2
+
3
+ FROM node:20-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/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.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,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import User from '../models/auth.model.js';
2
+ import bcrypt from 'bcrypt';
3
+ import jwt from 'jsonwebtoken';
4
+ import mongoose from 'mongoose';
5
+ import { validateRole } from '../utils/roleValidator.js';
6
+
7
+ /**
8
+ * POST /api/auth/sync-user
9
+ * Called by the frontend after a successful Firebase/Google sign-in to ensure
10
+ * the user exists in MongoDB. Creates a new record for first-time Google users
11
+ * or links an existing email-password account to the Firebase UID.
12
+ * Requires a valid Firebase ID token (verified by verifyToken middleware).
13
+ */
14
+ export const syncGoogleUser = async (req, res) => {
15
+ try {
16
+ if (!req.firebaseUser) {
17
+ return res.status(401).json({ message: 'Unauthorized' });
18
+ }
19
+ const { uid, email, name, picture } = req.firebaseUser;
20
+ const requestedRole = req.body?.role || 'farmer';
21
+ const userRole = validateRole(requestedRole);
22
+
23
+ // Find existing record by Firebase UID first, then fall back to email
24
+ let user = await User.findOne({ firebaseUid: uid });
25
+ if (!user && email) {
26
+ user = await User.findOne({ email: email.trim().toLowerCase() });
27
+ }
28
+
29
+ if (!user) {
30
+ // First-time Google/Firebase user — create their MongoDB record
31
+ user = await User.create({
32
+ name: name || email?.split('@')[0] || 'User',
33
+ email: email?.trim().toLowerCase(),
34
+ firebaseUid: uid,
35
+ role: userRole,
36
+ // password is intentionally omitted for OAuth users
37
+ });
38
+ } else {
39
+ // Existing user — backfill firebaseUid if missing
40
+ if (!user.firebaseUid) {
41
+ user.firebaseUid = uid;
42
+ await user.save();
43
+ }
44
+ }
45
+
46
+ return res.status(200).json({
47
+ message: 'User synced',
48
+ role: user.role,
49
+ userId: user._id,
50
+ });
51
+ } catch (err) {
52
+ console.error('syncGoogleUser error:', err);
53
+ return res.status(500).json({ message: 'Something went wrong' });
54
+ }
55
+ };
56
+
57
+ export const signup = async (req, res) => {
58
+ const { name, email, password, role } = req.body;
59
+ try {
60
+ const jwtSecret = process.env.JWT_KEY || process.env.JWT_SECRET;
61
+ const normalizedEmail = email?.trim().toLowerCase();
62
+
63
+ // Ensure all fields are provided
64
+ if (!name || !normalizedEmail || !password) {
65
+ return res.status(400).json({ message: "All fields are required" });
66
+ }
67
+
68
+ if (!jwtSecret) {
69
+ return res.status(500).json({ message: "Server configuration error" });
70
+ }
71
+
72
+ // Validate role - default to 'farmer' if not provided or invalid
73
+ const userRole = validateRole(role);
74
+
75
+ // Check if user already exists
76
+ const existingUser = await User.findOne({ email: normalizedEmail });
77
+ if (existingUser) return res.status(400).json({ message: "User already exists" });
78
+
79
+ // Hash password
80
+ const hashedPassword = await bcrypt.hash(password, 12);
81
+
82
+ // Create new user
83
+ const newUser = await User.create({ name, email: normalizedEmail, password: hashedPassword, role: userRole });
84
+
85
+ // Generate JWT token
86
+ const token = jwt.sign(
87
+ { id: newUser._id, role: newUser.role, email: newUser.email, name: newUser.name },
88
+ jwtSecret,
89
+ { expiresIn: "10h" }
90
+ );
91
+
92
+ // Set token in cookie. Use secure cookies only in production (HTTPS).
93
+ const cookieOptions = process.env.NODE_ENV === 'production'
94
+ ? { secure: true, sameSite: 'None', path: '/', maxAge: 86400000 }
95
+ : { secure: false, sameSite: 'Lax', path: '/' };
96
+
97
+ return res
98
+ .cookie('token', token, cookieOptions)
99
+ .status(201)
100
+ .json({ message: "User created successfully", token, role: newUser.role, name: newUser.name, email: newUser.email });
101
+ } catch (err) {
102
+ console.error(err);
103
+ return res.status(500).json({ message: "Something went wrong" });
104
+ }
105
+ };
106
+
107
+ export const signin = async (req, res) => {
108
+ const { email, password, role } = req.body;
109
+ try {
110
+ const jwtSecret = process.env.JWT_KEY || process.env.JWT_SECRET;
111
+ const normalizedEmail = email?.trim().toLowerCase();
112
+
113
+ if (!normalizedEmail || !password) {
114
+ return res.status(400).json({ message: "All fields are required" });
115
+ }
116
+
117
+ if (!jwtSecret) {
118
+ return res.status(500).json({ message: "Server configuration error" });
119
+ }
120
+
121
+ // Validate role only when explicitly provided
122
+ const userRole = role ? validateRole(role) : null;
123
+
124
+ const user = await User.findOne({ email: normalizedEmail });
125
+ if (!user) return res.status(401).json({ message: "Invalid credentials" });
126
+
127
+ // Google/OAuth users have no password — they must use Google sign-in
128
+ if (!user.password) return res.status(401).json({ message: "This account uses Google sign-in. Please sign in with Google." });
129
+
130
+ // Check if the user's role matches the requested role
131
+ if (userRole && userRole !== user.role) return res.status(401).json({ message: "Invalid credentials" });
132
+
133
+ // Validate password
134
+ const isPasswordCorrect = await bcrypt.compare(password, user.password);
135
+ if (!isPasswordCorrect) return res.status(401).json({ message: "Invalid credentials" });
136
+
137
+ // Generate JWT token
138
+ const token = jwt.sign(
139
+ { id: user._id, role: user.role, email: user.email, name: user.name },
140
+ jwtSecret,
141
+ { expiresIn: "1d" }
142
+ );
143
+
144
+ // Set token in cookie. Use secure cookies only in production (HTTPS).
145
+ if (token.length > 0) {
146
+ const cookieOptions = process.env.NODE_ENV === 'production'
147
+ ? { secure: true, sameSite: 'None', path: '/', maxAge: 86400000 }
148
+ : { secure: false, sameSite: 'Lax', path: '/' };
149
+
150
+ return res
151
+ .cookie('token', token, cookieOptions)
152
+ .status(200)
153
+ .json({ message: "Logged in successfully", token, role: user.role, name: user.name, email: user.email });
154
+ }
155
+ } catch (err) {
156
+ console.error(err);
157
+ return res.status(500).json({ message: "Something went wrong" });
158
+ }
159
+ };
160
+
161
+ export const signout = async (req, res) => {
162
+ // Clear the cookie on logout
163
+ const cookieOptions = process.env.NODE_ENV === 'production'
164
+ ? { secure: true, sameSite: 'None', path: '/' }
165
+ : { secure: false, sameSite: 'Lax', path: '/' };
166
+ res.clearCookie('token', cookieOptions);
167
+ return res.status(200).json({ message: 'Logged out successfully' });
168
+ };
169
+
170
+ export const getUserProfile = async (req, res) => {
171
+ try {
172
+ let user = null;
173
+
174
+ if (req.userId && mongoose.isValidObjectId(req.userId)) {
175
+ // Legacy JWT path: req.userId is a Mongo ObjectId string
176
+ user = await User.findById(req.userId).select('-password');
177
+ } else if (req.firebaseUser?.uid) {
178
+ // Firebase path: look up by firebaseUid field, fallback to email
179
+ user = await User.findOne({ firebaseUid: req.firebaseUser.uid }).select('-password');
180
+ if (!user && req.userEmail) {
181
+ user = await User.findOne({ email: req.userEmail }).select('-password');
182
+ }
183
+ } else if (req.userEmail) {
184
+ // Final fallback: lookup by email
185
+ user = await User.findOne({ email: req.userEmail }).select('-password');
186
+ }
187
+
188
+ if (!user) return res.status(404).json({ message: "User not found" });
189
+ res.json(user);
190
+ } catch (err) {
191
+ console.error(err);
192
+ res.status(500).json({ message: "Internal server error" });
193
+ }
194
+ };
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,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { generateAIContent } from '../utils/aiHelper.js';
2
+ import { extractLanguage, getLanguageName } from '../utils/aiOrchestrator.js';
3
+ import dotenv from 'dotenv';
4
+ import mongoose from 'mongoose';
5
+
6
+ const notificationSchema = new mongoose.Schema({
7
+ userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
8
+ title: { type: String, required: true },
9
+ message: { type: String, required: true },
10
+ type: { type: String, default: 'general' },
11
+ read: { type: Boolean, default: false },
12
+ link: String,
13
+ }, { timestamps: true });
14
+
15
+ const Notification = mongoose.models.Notification || mongoose.model('Notification', notificationSchema);
16
+
17
+ export const getFarmingAlerts = async (req, res) => {
18
+ dotenv.config();
19
+
20
+ const { region } = req.query;
21
+ const lang = extractLanguage(req);
22
+ const langName = getLanguageName(lang);
23
+ const langInstruction = lang !== 'en' ? `\nRespond STRICTLY in ${langName} language.` : '';
24
+
25
+ try {
26
+ const promptText = `
27
+ 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}.
28
+
29
+ Focus on:
30
+ - Important weather-related alerts relevant to farming today.
31
+ - Any immediate farming precautions or actions farmers should take.
32
+
33
+ Keep each alert clear, brief, and farmer-friendly. Thank you!${langInstruction}
34
+ `;
35
+
36
+ const alerts = await generateAIContent(promptText.trim());
37
+ res.status(200).json({ alerts });
38
+ } catch (err) {
39
+ console.error("Error fetching farming alerts: ", err);
40
+ res.status(500).json({ error: "Failed to fetch alerts" });
41
+ }
42
+ };
43
+
44
+ export const listNotifications = async (req, res) => {
45
+ try {
46
+ const userId = req.user?._id;
47
+
48
+ if (!userId) {
49
+ return res.status(401).json({ success: false, error: 'Unauthorized' });
50
+ }
51
+
52
+ const notifications = await Notification.find({ userId }).sort('-createdAt').limit(20).lean();
53
+ const unread = notifications.filter((n) => !n.read).length;
54
+
55
+ return res.status(200).json({
56
+ success: true,
57
+ data: {
58
+ notifications,
59
+ unread,
60
+ },
61
+ });
62
+ } catch (err) {
63
+ return res.status(500).json({ success: false, error: err.message });
64
+ }
65
+ };
66
+
67
+ export const seedNotification = async (req, res) => {
68
+ try {
69
+ const userId = req.user?._id;
70
+ if (!userId) {
71
+ return res.status(401).json({ success: false, error: 'Unauthorized' });
72
+ }
73
+
74
+ const { title, message, type = 'general', link } = req.body || {};
75
+ if (!title || !message) {
76
+ return res.status(400).json({ success: false, error: 'title and message are required' });
77
+ }
78
+
79
+ const created = await Notification.create({ userId, title, message, type, link });
80
+ return res.status(201).json({ success: true, data: created });
81
+ } catch (err) {
82
+ return res.status(500).json({ success: false, error: err.message });
83
+ }
84
+ };
85
+
86
+ export const markNotificationRead = async (req, res) => {
87
+ try {
88
+ const userId = req.user?._id;
89
+ const { id } = req.params;
90
+ if (!userId) {
91
+ return res.status(401).json({ success: false, error: 'Unauthorized' });
92
+ }
93
+
94
+ await Notification.updateOne({ _id: id, userId }, { $set: { read: true } });
95
+ return res.status(200).json({ success: true });
96
+ } catch (err) {
97
+ return res.status(500).json({ success: false, error: err.message });
98
+ }
99
+ };
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
+ };
backend/controllers/taskController.js ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // import axios from 'axios';
2
+ import Task from '../models/task.model.js';
3
+ // import dotenv from 'dotenv';
4
+
5
+ export const createTask = async(req, res)=>{
6
+ try{
7
+ const {title, description, date, isCompleted} = req.body;
8
+ const userId = req.userId;
9
+ console.log("Received Date: ", date);
10
+
11
+ const task = new Task({title,description,date, isCompleted, user: userId})
12
+ await task.save();
13
+ res.status(201).json(task);
14
+ }catch(err){
15
+ res.status(500).json({message: "Failed to create Task",err});
16
+ }
17
+ }
18
+
19
+ export const getTasks = async(req, res)=>{
20
+ try{
21
+ const userId = req.userId;
22
+ const {date} = req.query;
23
+ console.log(date);
24
+
25
+ const query = date? {date, user: userId} : {user: userId};// if date is provided filter tasks by date
26
+ const tasks = await Task.find(query);// fetch task with the query
27
+ res.status(200).json(tasks);
28
+ }catch(err){
29
+ res.status(500).json({message : "Failed to get tasks"});
30
+ }
31
+ }
32
+
33
+ export const getTask = async(req, res)=>{
34
+ try{
35
+ const {id} = req.params;
36
+ const userId = req.userId;
37
+ const task = await Task.findOne({_id: id, user: userId});
38
+ if(task){
39
+ res.status(201).json(task);
40
+ }else{
41
+ res.status(404).json({message: 'Task not found'});
42
+ }
43
+ }catch(err){
44
+ res.status(500).json({message: "Failed to fetch the task"});
45
+ }
46
+ }
47
+
48
+ export const getTaskByDate = async(req, res)=>{
49
+ try{
50
+ const userId = req.userId;
51
+ const {date} = req.params;
52
+ const tasks = await Task.find({date : new Date(date), user: userId});
53
+ res.status(200).json(tasks);
54
+ }catch(err){
55
+ res.status(500).json({message: "Failed to get task by date"}, err);
56
+ }
57
+ }
58
+
59
+
60
+ export const getMonthlyTaskStats = async (req, res) => {
61
+ try {
62
+ const { year, month } = req.query;
63
+ const userId = req.userId;
64
+ const startDate = new Date(year, month - 1, 1);
65
+ const endDate = new Date(year, month, 0);
66
+
67
+ const tasks = await Task.find({ date: { $gte: startDate, $lte: endDate }, user: userId });
68
+ const totalTasks = tasks.length;
69
+ const completedTasks = tasks.filter(task => task.isCompleted).length;
70
+ const remainingTasks = totalTasks - completedTasks;
71
+
72
+ res.status(200).json({ totalTasks, completedTasks, remainingTasks });
73
+ } catch (error) {
74
+ res.status(500).json({ message: 'Failed to get task statistics', error });
75
+ }
76
+ };
77
+
78
+ export const updateTask = async(req, res)=>{
79
+ try{
80
+ const {id} = req.params;
81
+ const userId = req.userId;
82
+ const {title, description, date, isCompleted} = req.body;
83
+ const updatedTask = await Task.findOneAndUpdate(
84
+ { _id: id, user: userId }, // Ensure the task belongs to the user
85
+ { title, description, date, isCompleted },
86
+ { new: true }
87
+ );
88
+ if (updatedTask) {
89
+ res.status(200).json(updatedTask);
90
+ } else {
91
+ res.status(404).json({ message: 'Task not found' });
92
+ }
93
+ }catch(err){
94
+ res.status(500).json({message: "Failed to update the task", err});
95
+ }
96
+ }
97
+
98
+ export const deleteTask = async(req, res)=>{
99
+ try{
100
+ const userId = req.userId;
101
+ const {id} = req.params;
102
+ await Task.findOneAndDelete({_id: id, user: userId});
103
+ res.status(200).json({message : "Deleted successfully"});
104
+ }catch(err){
105
+ res.status(500).json({message: "Failed to delete the task"});
106
+ }
107
+ }