Your Name commited on
Commit
b60f30d
Β·
1 Parent(s): 8becebc

v3.1: Stability and diagnostic update

Browse files
Files changed (2) hide show
  1. Dockerfile +15 -3
  2. main.py +141 -248
Dockerfile CHANGED
@@ -1,11 +1,23 @@
1
  FROM python:3.11-slim
2
  WORKDIR /app
3
- RUN apt-get update && apt-get install -y libhdf5-dev && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
 
 
4
  COPY requirements.txt .
5
  RUN pip install --no-cache-dir -r requirements.txt
 
 
6
  COPY best_v6.keras .
7
  COPY class_names.json .
8
- COPY main.py .
9
  COPY portal.html .
 
 
10
  EXPOSE 7860
11
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
1
  FROM python:3.11-slim
2
  WORKDIR /app
3
+
4
+ # Standard CV and HDF5 libraries for TF/PIL
5
+ RUN apt-get update && apt-get install -y \
6
+ libhdf5-dev \
7
+ libgl1-mesa-glx \
8
+ libglib2.0-0 \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
  COPY requirements.txt .
12
  RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy model first (larger, cacheable layer)
15
  COPY best_v6.keras .
16
  COPY class_names.json .
 
17
  COPY portal.html .
18
+ COPY main.py .
19
+
20
  EXPOSE 7860
21
+
22
+ # Force 1 worker to save RAM on free tier
23
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
main.py CHANGED
@@ -1,14 +1,13 @@
1
  """
2
- Crop Classifier REST API v3.0
3
  ================================
4
- Auto-generated API keys via Supabase.
5
- Each user registers once β†’ gets a unique key β†’ uses it forever.
6
  """
7
 
8
  from fastapi import FastAPI, File, UploadFile, HTTPException, Header, Depends, BackgroundTasks
9
  from fastapi.middleware.cors import CORSMiddleware
10
- from fastapi.responses import HTMLResponse
11
- from fastapi.staticfiles import StaticFiles
12
  from pydantic import BaseModel, EmailStr
13
  import numpy as np
14
  import json, os, io, time, logging, base64, uuid, secrets
@@ -24,13 +23,13 @@ logger = logging.getLogger(__name__)
24
 
25
  # ── Supabase config ─────────────────────────────────────────────────────────
26
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://ykvatttsnpjrwqfhhysu.supabase.co")
27
- SUPABASE_KEY = os.environ.get("SUPABASE_KEY",
28
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlrdmF0dHRzbnBqcndxZmhoeXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA0OTk5NjQsImV4cCI6MjA4NjA3NTk2NH0.5Njnh8NBEcPDddHjwv3CoUpCcAHu-ALNUQHQVdAdq-Y"
29
  )
30
  SB_HEADERS = {"apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}", "Content-Type": "application/json"}
31
  SB_TABLE = f"{SUPABASE_URL}/rest/v1/crop_api_keys"
32
 
33
- # ── In-memory key cache (refreshed every 60 seconds) ──────────────────────
34
  _key_cache: dict = {} # {api_key: {name, email, id}}
35
  _cache_ts: float = 0.0
36
 
@@ -38,65 +37,63 @@ async def refresh_key_cache():
38
  global _key_cache, _cache_ts
39
  try:
40
  async with httpx.AsyncClient() as client:
41
- r = await client.get(SB_TABLE + "?is_active=eq.true&select=api_key,name,email,id",
42
  headers=SB_HEADERS, timeout=10)
43
  if r.status_code == 200:
44
  _key_cache = {row["api_key"]: row for row in r.json()}
45
  _cache_ts = time.time()
46
- logger.info(f"Key cache refreshed: {len(_key_cache)} active keys")
 
 
47
  except Exception as e:
48
- logger.warning(f"Key cache refresh failed: {e}")
49
-
50
- def get_key_info(api_key: str) -> dict | None:
51
- return _key_cache.get(api_key)
52
 
53
  # ── App ──────────────────────────────────────────────────────────────────────
54
  app = FastAPI(
55
  title="🌾 Crop Classifier API",
56
- description=(
57
- "AI-powered crop image classification API.\n\n"
58
- "**Get your free API key** β†’ visit `/portal` \n\n"
59
- "**Docs** β†’ `/docs`"
60
- ),
61
- version="3.0.0",
62
  )
63
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
64
  allow_methods=["*"], allow_headers=["*"])
65
 
66
- # ── Startup: load model + warm cache ─────────────────────────────────────────
67
  MODEL_PATH = os.environ.get("MODEL_PATH", "best_v6.keras")
68
  JSON_PATH = os.environ.get("JSON_PATH", "class_names.json")
69
 
70
- logger.info(f"Loading model: {MODEL_PATH}")
71
- model = tf.keras.models.load_model(MODEL_PATH)
72
- with open(JSON_PATH) as f:
73
- class_names: list = json.load(f)["class_names"]
74
- logger.info(f"Model loaded. {len(class_names)} classes.")
75
 
76
- import asyncio
77
  @app.on_event("startup")
78
  async def startup():
79
- await refresh_key_cache()
 
 
 
 
 
 
 
 
 
 
80
 
81
- # ── NVIDIA / LLaMA ───────────────────────────────────────────────────────────
82
- NVIDIA_API_KEY = os.environ.get(
83
- "NVIDIA_API_KEY",
84
- "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"
85
- )
86
  LLAMA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
87
 
88
- # ── Helpers ───────────────────────────────────────────────────────────────────
89
  def confidence_label(pct: float) -> str:
90
  return "High" if pct >= 70 else "Medium" if pct >= 40 else "Low"
91
 
92
  def preprocess_image(image_bytes: bytes) -> np.ndarray:
93
  try:
94
  image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
95
- except Exception:
96
- raise HTTPException(status_code=422, detail="Cannot decode image.")
97
- image = image.resize((224, 224))
98
- arr = np.expand_dims(np.array(image, dtype=np.float32), axis=0)
99
- return preprocess_input(arr)
 
100
 
101
  def compress_image(image_bytes: bytes) -> bytes:
102
  try:
@@ -113,18 +110,17 @@ def call_llama_vision(image_bytes: bytes, top3_preds: list) -> dict:
113
  img_b64 = base64.b64encode(compress_image(image_bytes)).decode("utf-8")
114
  predictions_str = ", ".join(f"{p['crop']} ({p['confidence_percent']}%)" for p in top3_preds)
115
  prompt = (
116
- "You are an expert agricultural scientist and crop quality inspector with 20 years of experience.\n"
117
- "Carefully analyze the crop or agricultural product shown in this image.\n\n"
118
- f"An automated vision model suggests it might be: {predictions_str}\n\n"
119
- "Respond ONLY in this exact format β€” no extra text, no preamble:\n\n"
120
  "**Crop Name:** [Correct common name]\n"
121
- "**Scientific Name:** [Latin name, or 'N/A']\n"
122
- "**Characteristics:** [Visual features: color, shape, texture, size]\n"
123
- "**Quality:** [Choose ONE: Premium, Excellent, Very Good, Good, Fair, or Bad]\n"
124
- "**Market Grade:** [Choose ONE: Grade A, Grade B, Grade C, or Ungraded]\n"
125
- "**Prediction Accuracy:** [Choose ONE: Correct, Partially Correct, or Incorrect]\n"
126
- "**Storage Tip:** [One practical storage recommendation]\n"
127
- "**Explanation:** [2-3 sentences on identification and quality]"
128
  )
129
  payload = {
130
  "model": "meta/llama-3.2-90b-vision-instruct",
@@ -132,7 +128,7 @@ def call_llama_vision(image_bytes: bytes, top3_preds: list) -> dict:
132
  {"type": "text", "text": prompt},
133
  {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
134
  ]}],
135
- "max_tokens": 600, "temperature": 0.3, "top_p": 0.9, "stream": False
136
  }
137
  headers = {"Authorization": f"Bearer {NVIDIA_API_KEY}", "Accept": "application/json"}
138
  resp = req_lib.post(LLAMA_URL, headers=headers, json=payload, timeout=60)
@@ -143,22 +139,21 @@ def call_llama_vision(image_bytes: bytes, top3_preds: list) -> dict:
143
  "quality": None, "market_grade": None, "prediction_accuracy": None,
144
  "storage_tip": None, "explanation": None}
145
  for line in raw_text.splitlines():
146
- line = line.strip()
147
- if line.startswith("- ") or line.startswith("* "): line = line[2:]
148
- clean = line.replace("**", "").strip()
149
- cl = clean.lower()
150
- if cl.startswith("crop name:"): fields["crop_name"] = clean.split(":",1)[1].strip()
151
- elif cl.startswith("scientific name:"): fields["scientific_name"] = clean.split(":",1)[1].strip()
152
- elif cl.startswith("characteristics:"): fields["characteristics"] = clean.split(":",1)[1].strip()
153
- elif cl.startswith("quality:"): fields["quality"] = clean.split(":",1)[1].strip()
154
- elif cl.startswith("market grade:"): fields["market_grade"] = clean.split(":",1)[1].strip()
155
- elif cl.startswith("prediction accuracy:"):fields["prediction_accuracy"]= clean.split(":",1)[1].strip()
156
- elif cl.startswith("storage tip:"): fields["storage_tip"] = clean.split(":",1)[1].strip()
157
- elif cl.startswith("explanation:"): fields["explanation"] = clean.split(":",1)[1].strip()
158
  fields["raw"] = raw_text
159
  return fields
160
  except Exception as e:
161
- logger.warning(f"LLaMA failed: {e}")
162
  return {"error": str(e), "raw": None}
163
 
164
  def build_final_answer(top3: list, ai: dict | None) -> dict:
@@ -174,221 +169,119 @@ def build_final_answer(top3: list, ai: dict | None) -> dict:
174
  }
175
 
176
  async def increment_usage(key_id: str):
177
- """Background task: increment request counter + update last_used_at."""
178
  try:
179
  async with httpx.AsyncClient() as client:
180
- await client.patch(
181
- f"{SB_TABLE}?id=eq.{key_id}",
182
- headers=SB_HEADERS,
183
- json={"last_used_at": datetime.now(timezone.utc).isoformat(),
184
- "requests_count": None}, # use DB increment below
185
- timeout=5
186
- )
187
- # Use raw SQL increment
188
  await client.post(f"{SUPABASE_URL}/rest/v1/rpc/increment_usage",
189
- headers=SB_HEADERS, json={"row_id": key_id}, timeout=5)
190
- except Exception:
191
- pass # non-critical
192
 
193
- # ── API Key Validation ────────────────────────────────────────────────────────
194
- async def validate_api_key(x_api_key: str = Header(..., description="Your API key")):
195
  global _cache_ts
196
- # Refresh cache if older than 60 seconds
197
- if time.time() - _cache_ts > 60:
198
- await refresh_key_cache()
199
- info = get_key_info(x_api_key)
200
  if not info:
201
- raise HTTPException(status_code=401, detail={
202
- "error": "Unauthorized",
203
- "message": "Invalid API key. Get your free key at /portal"
204
- })
205
  return info
206
 
207
- # ── Registration Model ────────────────────────────────────────────────────────
208
  class RegisterRequest(BaseModel):
209
- name: str
210
  email: str
211
 
212
- # ══════════════════════════════════════════════════════════════════════════════
213
- # ROUTES
214
- # ══════════════════════════════════════════════════════════════════════════════
215
-
216
- @app.get("/", tags=["Info"])
217
- def root():
218
- return {
219
- "api": "Crop Classifier API", "version": "3.0.0",
220
- "model": "EfficientNetB3 v6", "accuracy": "93.48%",
221
- "supported_crops": len(class_names), "status": "online",
222
- "get_api_key": "/portal",
223
- "docs": "/docs",
224
- }
225
-
226
- @app.get("/health", tags=["Info"])
227
- def health():
228
- return {"status": "ok"}
229
-
230
- @app.get("/crops", tags=["Info"])
231
- def list_crops():
232
- return {"total": len(class_names),
233
- "crops": [n.replace("_"," ").title() for n in class_names]}
234
-
235
-
236
- # ── REGISTER: auto-generate API key ──────────────────────────────────────────
237
- @app.post("/register", tags=["API Key"])
238
  async def register(body: RegisterRequest):
239
- """
240
- ## Get your free API key
241
-
242
- Submit your name and email to receive a unique API key instantly.
243
- No manual approval needed.
244
- """
245
- # Check if email already has a key
246
- try:
247
- async with httpx.AsyncClient() as client:
248
- check = await client.get(
249
- f"{SB_TABLE}?email=eq.{body.email}&select=api_key,name",
250
- headers=SB_HEADERS, timeout=10
251
- )
252
- if check.status_code == 200 and check.json():
253
- existing = check.json()[0]
254
- return {
255
- "success": True,
256
- "message": f"You already have an API key, {existing['name']}!",
257
- "api_key": existing["api_key"],
258
- "is_new": False,
259
- }
260
- except Exception:
261
- pass
262
-
263
- # Generate new unique key
264
- new_key = "crop-" + secrets.token_urlsafe(20)
265
-
266
  try:
 
267
  async with httpx.AsyncClient() as client:
268
- r = await client.post(SB_TABLE, headers={**SB_HEADERS, "Prefer": "return=representation"},
269
- json={"api_key": new_key, "name": body.name, "email": body.email}, timeout=10)
 
 
 
 
 
 
270
  if r.status_code not in (200, 201):
271
- raise HTTPException(status_code=500, detail="Failed to save API key. Try again.")
272
- except HTTPException:
273
- raise
 
274
  except Exception as e:
275
- raise HTTPException(status_code=500, detail=f"Database error: {e}")
 
276
 
277
- # Refresh cache immediately
278
- await refresh_key_cache()
279
-
280
- logger.info(f"New API key issued to {body.email} ({body.name}): {new_key[:12]}...")
281
- return {
282
- "success": True,
283
- "message": f"Welcome, {body.name}! Your API key is ready.",
284
- "api_key": new_key,
285
- "is_new": True,
286
- "usage": {
287
- "endpoint": "https://vdx-0-crop-classifier-api.hf.space/predict",
288
- "header": f"x-api-key: {new_key}",
289
- "docs": "https://vdx-0-crop-classifier-api.hf.space/docs",
290
- }
291
- }
292
 
 
 
 
293
 
294
- # ── Developer Portal ──────────────────────────────────────────────────────────
295
- @app.get("/portal", response_class=HTMLResponse, tags=["API Key"], include_in_schema=False)
296
- def portal():
297
- """Beautiful developer portal to get an API key."""
298
- with open("portal.html", "r") as f:
299
- return f.read()
300
 
301
-
302
- # ── PREDICT (full) ────────────────────────────────────────────────────────────
303
- @app.post("/predict", tags=["Prediction"])
304
  async def predict(
305
  background_tasks: BackgroundTasks,
306
  file: UploadFile = File(...),
307
  key_info: dict = Depends(validate_api_key),
308
  ):
309
- """Full crop analysis: EfficientNetB3 model + LLaMA Vision expert."""
310
- request_id = str(uuid.uuid4())
311
- ts = datetime.now(timezone.utc).isoformat()
312
-
313
- image_bytes = await file.read()
314
- if not image_bytes: raise HTTPException(422, "File is empty.")
315
- if len(image_bytes) > 10*1024*1024: raise HTTPException(413, "Max 10MB.")
316
-
317
- t0 = time.time()
318
- preds = model.predict(preprocess_image(image_bytes), verbose=0)[0]
319
- inference_ms = round((time.time()-t0)*1000, 1)
320
-
321
- top3_idx = np.argsort(preds)[-3:][::-1]
322
- top3 = [{"rank": i+1, "crop": class_names[idx].replace("_"," ").title(),
323
- "confidence_percent": round(float(preds[idx])*100, 2),
324
- "confidence_label": confidence_label(round(float(preds[idx])*100, 2))}
325
- for i, idx in enumerate(top3_idx)]
326
-
327
- t1 = time.time()
328
- ai = call_llama_vision(image_bytes, top3)
329
- llama_ms = round((time.time()-t1)*1000, 1)
330
-
331
- # Increment usage counter in background (non-blocking)
332
- background_tasks.add_task(increment_usage, key_info["id"])
333
-
334
- return {
335
- "success": True,
336
- "request_id": request_id,
337
- "timestamp": ts,
338
- "final_answer": build_final_answer(top3, ai),
339
- "model_prediction": {
340
- "top_prediction": top3[0]["crop"],
341
- "confidence_percent": top3[0]["confidence_percent"],
342
- "confidence_label": top3[0]["confidence_label"],
343
- "top3": top3,
344
- "inference_time_ms": inference_ms,
345
- },
346
- "ai_expert_verification": {
347
- "crop_name": ai.get("crop_name"),
348
- "scientific_name": ai.get("scientific_name"),
349
- "characteristics": ai.get("characteristics"),
350
- "quality": ai.get("quality"),
351
- "market_grade": ai.get("market_grade"),
352
- "prediction_accuracy": ai.get("prediction_accuracy"),
353
- "storage_tip": ai.get("storage_tip"),
354
- "explanation": ai.get("explanation"),
355
- "llama_time_ms": llama_ms,
356
- },
357
- "model_version": "v6",
358
- "request_by": key_info["name"],
359
- }
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
- # ── PREDICT FAST (model only) ─────────────────────────────────────────────────
363
- @app.post("/predict/fast", tags=["Prediction"])
364
  async def predict_fast(
365
  background_tasks: BackgroundTasks,
366
  file: UploadFile = File(...),
367
  key_info: dict = Depends(validate_api_key),
368
  ):
369
- """Fast prediction β€” model only, no LLaMA. Response in <500ms."""
370
- request_id = str(uuid.uuid4())
371
- image_bytes = await file.read()
372
- if not image_bytes: raise HTTPException(422, "File is empty.")
373
-
374
- t0 = time.time()
375
- preds = model.predict(preprocess_image(image_bytes), verbose=0)[0]
376
- inference_ms = round((time.time()-t0)*1000, 1)
377
-
378
- top3_idx = np.argsort(preds)[-3:][::-1]
379
  top3 = [{"rank": i+1, "crop": class_names[idx].replace("_"," ").title(),
380
- "confidence_percent": round(float(preds[idx])*100, 2),
381
- "confidence_label": confidence_label(round(float(preds[idx])*100, 2))}
382
- for i, idx in enumerate(top3_idx)]
383
-
384
  background_tasks.add_task(increment_usage, key_info["id"])
385
-
386
- return {
387
- "success": True, "request_id": request_id,
388
- "mode": "fast (model only)",
389
- "top_prediction": top3[0]["crop"],
390
- "confidence_percent": top3[0]["confidence_percent"],
391
- "confidence_label": top3[0]["confidence_label"],
392
- "top3": top3, "inference_time_ms": inference_ms,
393
- "model_version": "v6", "request_by": key_info["name"],
394
- }
 
1
  """
2
+ Crop Classifier REST API v3.1
3
  ================================
4
+ Status: Stability & Debug Update
5
+ Changes: Supabase resilience, FileResponse, detailed JSON errors.
6
  """
7
 
8
  from fastapi import FastAPI, File, UploadFile, HTTPException, Header, Depends, BackgroundTasks
9
  from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
 
11
  from pydantic import BaseModel, EmailStr
12
  import numpy as np
13
  import json, os, io, time, logging, base64, uuid, secrets
 
23
 
24
  # ── Supabase config ─────────────────────────────────────────────────────────
25
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://ykvatttsnpjrwqfhhysu.supabase.co")
26
+ SUPABASE_KEY = os.environ.get("SUPABASE_KEY",
27
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlrdmF0dHRzbnBqcndxZmhoeXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA0OTk5NjQsImV4cCI6MjA4NjA3NTk2NH0.5Njnh8NBEcPDddHjwv3CoUpCcAHu-ALNUQHQVdAdq-Y"
28
  )
29
  SB_HEADERS = {"apikey": SUPABASE_KEY, "Authorization": f"Bearer {SUPABASE_KEY}", "Content-Type": "application/json"}
30
  SB_TABLE = f"{SUPABASE_URL}/rest/v1/crop_api_keys"
31
 
32
+ # ── In-memory key cache ──────────────────────────────────────────────────────
33
  _key_cache: dict = {} # {api_key: {name, email, id}}
34
  _cache_ts: float = 0.0
35
 
 
37
  global _key_cache, _cache_ts
38
  try:
39
  async with httpx.AsyncClient() as client:
40
+ r = await client.get(f"{SB_TABLE}?is_active=eq.true&select=api_key,name,email,id",
41
  headers=SB_HEADERS, timeout=10)
42
  if r.status_code == 200:
43
  _key_cache = {row["api_key"]: row for row in r.json()}
44
  _cache_ts = time.time()
45
+ logger.info(f"Key cache refreshed: {len(_key_cache)} keys")
46
+ else:
47
+ logger.warning(f"Key cache failed: Supabase returned {r.status_code}")
48
  except Exception as e:
49
+ logger.error(f"Critical error refreshing key cache: {e}")
 
 
 
50
 
51
  # ── App ──────────────────────────────────────────────────────────────────────
52
  app = FastAPI(
53
  title="🌾 Crop Classifier API",
54
+ version="3.1.0",
 
 
 
 
 
55
  )
56
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
57
  allow_methods=["*"], allow_headers=["*"])
58
 
59
+ # ── Startup ──────────────────────────────────────────────────────────────────
60
  MODEL_PATH = os.environ.get("MODEL_PATH", "best_v6.keras")
61
  JSON_PATH = os.environ.get("JSON_PATH", "class_names.json")
62
 
63
+ model = None
64
+ class_names = []
 
 
 
65
 
 
66
  @app.on_event("startup")
67
  async def startup():
68
+ global model, class_names
69
+ try:
70
+ logger.info(f"Loading model: {MODEL_PATH}")
71
+ model = tf.keras.models.load_model(MODEL_PATH)
72
+ with open(JSON_PATH) as f:
73
+ class_names = json.load(f)["class_names"]
74
+ logger.info(f"Model loaded successfully. {len(class_names)} classes.")
75
+ await refresh_key_cache()
76
+ except Exception as e:
77
+ logger.error(f"STARTUP FAILED: {e}")
78
+ # Dont raise here - allow server to start so /health works for debugging
79
 
80
+ # ── LLaMA ────────────────────────────────────────────────────────────────────
81
+ NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "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")
 
 
 
82
  LLAMA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
83
 
84
+ # ── Helpers ──────────────────────────────────────────────────────────────────
85
  def confidence_label(pct: float) -> str:
86
  return "High" if pct >= 70 else "Medium" if pct >= 40 else "Low"
87
 
88
  def preprocess_image(image_bytes: bytes) -> np.ndarray:
89
  try:
90
  image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
91
+ image = image.resize((224, 224))
92
+ arr = np.expand_dims(np.array(image, dtype=np.float32), axis=0)
93
+ return preprocess_input(arr)
94
+ except Exception as e:
95
+ logger.warning(f"Image preprocessing failed: {e}")
96
+ return None
97
 
98
  def compress_image(image_bytes: bytes) -> bytes:
99
  try:
 
110
  img_b64 = base64.b64encode(compress_image(image_bytes)).decode("utf-8")
111
  predictions_str = ", ".join(f"{p['crop']} ({p['confidence_percent']}%)" for p in top3_preds)
112
  prompt = (
113
+ "You are an expert agricultural scientist. Analyze the crop in this image.\n"
114
+ f"Possible crops: {predictions_str}\n\n"
115
+ "Format your response as EXACTLY these fields, one per line:\n"
 
116
  "**Crop Name:** [Correct common name]\n"
117
+ "**Scientific Name:** [Latin name]\n"
118
+ "**Characteristics:** [Visual features]\n"
119
+ "**Quality:** [Premium, Excellent, Very Good, Good, Fair, or Bad]\n"
120
+ "**Market Grade:** [Grade A, Grade B, Grade C, or Ungraded]\n"
121
+ "**Prediction Accuracy:** [Correct, Partially Correct, or Incorrect]\n"
122
+ "**Storage Tip:** [1 sentence]\n"
123
+ "**Explanation:** [2 sentences]"
124
  )
125
  payload = {
126
  "model": "meta/llama-3.2-90b-vision-instruct",
 
128
  {"type": "text", "text": prompt},
129
  {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
130
  ]}],
131
+ "max_tokens": 600, "temperature": 0.2, "stream": False
132
  }
133
  headers = {"Authorization": f"Bearer {NVIDIA_API_KEY}", "Accept": "application/json"}
134
  resp = req_lib.post(LLAMA_URL, headers=headers, json=payload, timeout=60)
 
139
  "quality": None, "market_grade": None, "prediction_accuracy": None,
140
  "storage_tip": None, "explanation": None}
141
  for line in raw_text.splitlines():
142
+ clean = line.replace("**", "").replace("- ","").replace("* ","").strip()
143
+ if ":" in clean:
144
+ k, v = clean.split(":", 1)
145
+ k = k.lower().strip()
146
+ if "crop name" in k: fields["crop_name"] = v.strip()
147
+ elif "scientific name" in k: fields["scientific_name"] = v.strip()
148
+ elif "characteristics" in k: fields["characteristics"] = v.strip()
149
+ elif "quality" in k: fields["quality"] = v.strip()
150
+ elif "market grade" in k: fields["market_grade"] = v.strip()
151
+ elif "prediction accuracy" in k: fields["prediction_accuracy"] = v.strip()
152
+ elif "storage tip" in k: fields["storage_tip"] = v.strip()
153
+ elif "explanation" in k: fields["explanation"] = v.strip()
154
  fields["raw"] = raw_text
155
  return fields
156
  except Exception as e:
 
157
  return {"error": str(e), "raw": None}
158
 
159
  def build_final_answer(top3: list, ai: dict | None) -> dict:
 
169
  }
170
 
171
  async def increment_usage(key_id: str):
 
172
  try:
173
  async with httpx.AsyncClient() as client:
 
 
 
 
 
 
 
 
174
  await client.post(f"{SUPABASE_URL}/rest/v1/rpc/increment_usage",
175
+ headers=SB_HEADERS, json={"row_id": key_id}, timeout=3)
176
+ except: pass
 
177
 
178
+ async def validate_api_key(x_api_key: str = Header(..., alias="x-api-key")):
 
179
  global _cache_ts
180
+ if time.time() - _cache_ts > 60: await refresh_key_cache()
181
+ info = _key_cache.get(x_api_key)
 
 
182
  if not info:
183
+ raise HTTPException(status_code=401, detail={"error": "Invalid API key", "portal": "/portal"})
 
 
 
184
  return info
185
 
186
+ # ── Registration ─────────────────────────────────────────────────────────────
187
  class RegisterRequest(BaseModel):
188
+ name: str
189
  email: str
190
 
191
+ @app.post("/register", tags=["Admin"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  async def register(body: RegisterRequest):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  try:
194
+ new_key = f"crop-{secrets.token_urlsafe(20)}"
195
  async with httpx.AsyncClient() as client:
196
+ # Check existing
197
+ r = await client.get(f"{SB_TABLE}?email=eq.{body.email}&select=api_key,name", headers=SB_HEADERS)
198
+ if r.status_code == 200 and r.json():
199
+ return {"success": True, "api_key": r.json()[0]["api_key"], "is_new": False}
200
+
201
+ # Create new
202
+ r = await client.post(SB_TABLE, headers=SB_HEADERS,
203
+ json={"api_key": new_key, "name": body.name, "email": body.email})
204
  if r.status_code not in (200, 201):
205
+ raise Exception(f"Supabase error: {r.text}")
206
+
207
+ await refresh_key_cache()
208
+ return {"success": True, "api_key": new_key, "is_new": True}
209
  except Exception as e:
210
+ logger.error(f"Registration failed: {e}")
211
+ raise HTTPException(500, detail={"error": "Failed to create key", "message": str(e)})
212
 
213
+ # ── Routes ───────────────────────────────────────────────────────────────────
214
+ @app.get("/portal", response_class=HTMLResponse)
215
+ async def portal_page():
216
+ if not os.path.exists("portal.html"):
217
+ return HTMLResponse("<h1>Portal page missing</h1>", status_code=404)
218
+ return FileResponse("portal.html")
 
 
 
 
 
 
 
 
 
219
 
220
+ @app.get("/health")
221
+ async def health():
222
+ return {"status": "ok", "model_ready": model is not None, "classes": len(class_names)}
223
 
224
+ @app.get("/crops")
225
+ async def list_crops():
226
+ return {"crops": [n.replace("_"," ").title() for n in class_names]}
 
 
 
227
 
228
+ @app.post("/predict")
 
 
229
  async def predict(
230
  background_tasks: BackgroundTasks,
231
  file: UploadFile = File(...),
232
  key_info: dict = Depends(validate_api_key),
233
  ):
234
+ if model is None:
235
+ raise HTTPException(503, detail="Model still loading or failed to load. Check /health.")
236
+
237
+ img_bytes = await file.read()
238
+ if not img_bytes: raise HTTPException(422, detail="Empty image file.")
239
+
240
+ processed = preprocess_image(img_bytes)
241
+ if processed is None:
242
+ raise HTTPException(422, detail="Invalid image file. Could not decode.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
+ try:
245
+ # Inference
246
+ res = model.predict(processed, verbose=0)[0]
247
+ top3_idx = np.argsort(res)[-3:][::-1]
248
+ top3 = [{"rank": i+1, "crop": class_names[idx].replace("_"," ").title(),
249
+ "confidence_percent": round(float(res[idx])*100, 2),
250
+ "confidence_label": confidence_label(round(float(res[idx])*100, 2))}
251
+ for i, idx in enumerate(top3_idx)]
252
+
253
+ # LLaMA
254
+ ai = call_llama_vision(img_bytes, top3)
255
+ background_tasks.add_task(increment_usage, key_info["id"])
256
+
257
+ return {
258
+ "success": True,
259
+ "final_answer": build_final_answer(top3, ai),
260
+ "model_prediction": {"top3": top3},
261
+ "ai_expert": ai,
262
+ "request_id": str(uuid.uuid4())
263
+ }
264
+ except Exception as e:
265
+ logger.error(f"Prediction failed: {e}")
266
+ raise HTTPException(500, detail={"error": "Prediction error", "message": str(e)})
267
 
268
+ @app.post("/predict/fast")
 
269
  async def predict_fast(
270
  background_tasks: BackgroundTasks,
271
  file: UploadFile = File(...),
272
  key_info: dict = Depends(validate_api_key),
273
  ):
274
+ if model is None: raise HTTPException(503, detail="Model loading.")
275
+ img_bytes = await file.read()
276
+ processed = preprocess_image(img_bytes)
277
+ if processed is None: raise HTTPException(422, detail="Bad image.")
278
+
279
+ res = model.predict(processed, verbose=0)[0]
280
+ top3_idx = np.argsort(res)[-3:][::-1]
 
 
 
281
  top3 = [{"rank": i+1, "crop": class_names[idx].replace("_"," ").title(),
282
+ "confidence_percent": round(float(res[idx])*100, 2),
283
+ "confidence_label": confidence_label(round(float(res[idx])*100, 2))}
284
+ for i, idx in enumerate(top3_idx)]
285
+
286
  background_tasks.add_task(increment_usage, key_info["id"])
287
+ return {"success": True, "top_prediction": top3[0]["crop"], "top3": top3}