dpv007 commited on
Commit
a83b25f
·
verified ·
1 Parent(s): 879187e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +145 -226
app.py CHANGED
@@ -3,17 +3,18 @@
3
  Elderly HealthWatch AI Backend (FastAPI)
4
  Pipeline:
5
  - receive images
6
- - run VLM (remote HF Space via gradio_api/call/chat style) -> raw text + meta
7
  - run LLM (remote gradio /chat) -> structured risk JSON (per requested schema)
8
  - continue rest of processing and store results
9
 
10
  Notes:
11
- - Add gradio_client==1.13.2 (or another compatible 1.x) to requirements.txt for LLM calls
12
- - Add httpx to requirements.txt for VLM POST/GET flow
13
  - If VLM/LLM Spaces are private, set HF_TOKEN in the environment for authentication.
14
  - This variant:
15
- * sends the face image to the HF Space using the POST/GET event flow (tries JSON data-uri first, then multipart fallback)
16
- * returns raw VLM output and meta (no VLM-side JSON extraction)
 
 
17
  """
18
 
19
  import io
@@ -25,7 +26,6 @@ import logging
25
  import traceback
26
  import re
27
  import time
28
- import base64
29
  from typing import Dict, Any, Optional, Tuple
30
  from datetime import datetime
31
 
@@ -36,10 +36,7 @@ from PIL import Image
36
  import numpy as np
37
  import cv2 # opencv-python-headless expected installed
38
 
39
- # HTTP client for the POST/GET event style VLM calls
40
- import httpx
41
-
42
- # Optional gradio client (for LLM calls)
43
  try:
44
  from gradio_client import Client, handle_file # type: ignore
45
  GRADIO_AVAILABLE = True
@@ -249,243 +246,156 @@ def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
249
  return out
250
 
251
  # -----------------------
252
- # VLM helper using HF Spaces POST/GET event flow (gradio_api/call/chat)
253
- # Robust: try JSON (data-uri) POST first; if 5xx, fall back to multipart/form-data file upload.
254
  # -----------------------
 
 
 
 
 
 
 
255
  def run_vlm_and_get_features(face_path: str, eye_path: Optional[str] = None, prompt: Optional[str] = None,
256
  raise_on_file_delivery_failure: bool = False
257
  ) -> Tuple[Optional[Dict[str, Any]], str, Dict[str, Any]]:
258
  """
259
- VLM caller using the HF Spaces 'gradio_api/call/chat' style:
260
- 1) POST -> returns an EVENT_ID
261
- 2) GET /gradio_api/call/chat/{EVENT_ID} -> fetch result
262
-
263
- Behavior:
264
- - Try JSON payload with data URI (fast path)
265
- - If JSON POST yields server error (5xx), retry with multipart/form-data attaching the face image
266
- - Poll GET endpoint a few times for result
267
- - Return (parsed_features_or_None, raw_text, meta)
268
- - parsed_features is None (we avoid parsing JSON here)
269
  """
270
  prompt = prompt or DEFAULT_VLM_PROMPT
271
 
 
272
  if not os.path.exists(face_path):
273
  raise FileNotFoundError(f"Face image not found at: {face_path}")
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
- with open(face_path, "rb") as f:
276
- face_bytes = f.read()
277
- if not face_bytes:
278
- raise ValueError("Face image is empty (0 bytes)")
 
 
 
 
 
 
 
 
 
 
 
279
 
280
- face_b64 = base64.b64encode(face_bytes).decode("ascii")
281
- face_data_uri = f"data:image/jpeg;base64,{face_b64}"
282
 
283
- payload_json = {
284
- "data": [
285
- {
286
- "text": prompt,
287
- "files": [face_data_uri]
288
- }
289
- ]
290
- }
291
 
292
- # Prepare endpoint(s)
293
- SPACE_HOST = os.getenv("VLM_SPACE_HOST") # optional full host override
294
- if SPACE_HOST:
295
- base_url = SPACE_HOST.rstrip("/")
 
 
 
 
 
 
 
 
 
296
  else:
297
- # Many public HF spaces map to {owner}-{space}.hf.space when used in hostnames.
298
- if "/" in GRADIO_VLM_SPACE:
299
- base_url = f"https://{GRADIO_VLM_SPACE.replace('/', '-')}.hf.space"
 
300
  else:
301
- base_url = f"https://{GRADIO_VLM_SPACE}.hf.space"
302
 
303
- post_url = f"{base_url}/gradio_api/call/chat"
304
- get_url_template = f"{base_url}/gradio_api/call/chat/{{event_id}}"
305
-
306
- headers_json = {"Content-Type": "application/json"}
307
- if HF_TOKEN:
308
- headers_json["Authorization"] = f"Bearer {HF_TOKEN}"
309
-
310
- meta: Dict[str, Any] = {
311
- "vlm_file_delivery_ok": False,
312
- "vlm_files_seen": None,
313
- "vlm_raw_len": 0,
314
- "vlm_out_object": None,
315
- "post_url": post_url,
316
- "attempts": []
317
- }
318
-
319
- def _extract_event_id(resp_text: str, resp_json: Optional[Dict[str, Any]]) -> Optional[str]:
320
- if isinstance(resp_json, dict):
321
- for k in ("event_id", "id", "job"):
322
- if k in resp_json and resp_json[k]:
323
- return resp_json[k]
324
- # try a quoted token heuristic (like the awk approach)
325
- m = re.search(r'"([^"]{8,})"', resp_text or "")
326
- if m:
327
- return m.group(1)
328
- parts = re.split(r'"', resp_text or "")
329
- if len(parts) >= 5:
330
- candidate = parts[3].strip()
331
- if candidate:
332
- return candidate
333
- return None
334
-
335
- with httpx.Client(timeout=30.0) as client:
336
- # Attempt 1: JSON data-uri POST
337
  try:
338
- logger.info("VLM POST (JSON data-uri) -> %s (prompt len=%d)", post_url, len(prompt))
339
- resp = client.post(post_url, headers=headers_json, json=payload_json)
340
- resp.raise_for_status()
341
- meta["attempts"].append({"mode": "json", "status_code": resp.status_code})
342
- try:
343
- resp_json = resp.json()
344
- except Exception:
345
- resp_json = None
346
-
347
- event_id = _extract_event_id(resp.text, resp_json)
348
- if not event_id:
349
- raise RuntimeError(f"Failed to obtain EVENT_ID from VLM POST (json) response: {resp.text[:1000]}")
350
- meta["event_id"] = event_id
351
-
352
- except httpx.HTTPStatusError as he:
353
- # Log attempt and fallback to multipart if server-side error
354
- status = he.response.status_code if he.response is not None else None
355
- body_excerpt = (he.response.text[:1000] if he.response is not None else str(he))
356
- logger.warning("VLM JSON POST failed (status=%s). Response excerpt: %s", status, body_excerpt[:400])
357
- meta["attempts"].append({"mode": "json", "status_code": status, "error": body_excerpt})
358
- if status is None or 500 <= status < 600:
359
- # Try multipart fallback
360
- try:
361
- logger.info("Attempting multipart/form-data fallback to %s", post_url)
362
- # Some Spaces expect 'data' field to be JSON array describing inputs and files to be referenced.
363
- # We'll send 'data' as JSON string with a placeholder for file indices, and attach the file in 'file' part.
364
- data_field = json.dumps([{"text": prompt, "files": [None]}])
365
- files = {
366
- "data": (None, data_field, "application/json"),
367
- "file": (os.path.basename(face_path), face_bytes, "image/jpeg")
368
- }
369
- # Authorization header only; content-type will be set by httpx for multipart
370
- headers_mp = {}
371
- if HF_TOKEN:
372
- headers_mp["Authorization"] = f"Bearer {HF_TOKEN}"
373
-
374
- resp2 = client.post(post_url, headers=headers_mp, files=files)
375
- resp2.raise_for_status()
376
- meta["attempts"].append({"mode": "multipart", "status_code": resp2.status_code})
377
- try:
378
- resp2_json = resp2.json()
379
- except Exception:
380
- resp2_json = None
381
- event_id = _extract_event_id(resp2.text, resp2_json)
382
- if not event_id:
383
- raise RuntimeError(f"Failed to obtain EVENT_ID from VLM POST (multipart) response: {resp2.text[:1000]}")
384
- meta["event_id"] = event_id
385
- except Exception as e_mp:
386
- logger.exception("Multipart fallback failed")
387
- meta["attempts"].append({"mode": "multipart", "error": str(e_mp)})
388
- raise RuntimeError(f"VLM POST failed (json then multipart): {body_excerpt[:1000]} | multipart error: {str(e_mp)}")
389
- else:
390
- # Non-5xx error — surface it
391
- raise RuntimeError(f"VLM POST failed with status {status}: {body_excerpt[:1000]}")
392
- except Exception as e:
393
- logger.exception("VLM POST unexpected failure")
394
- meta["attempts"].append({"mode": "json", "error": str(e)})
395
- raise RuntimeError(f"VLM POST failed: {e}")
396
-
397
- # If we have event_id, poll GET endpoint for result
398
- event_id = meta.get("event_id")
399
- if not event_id:
400
- raise RuntimeError("No event_id obtained from VLM POST (unexpected)")
401
-
402
- get_url = get_url_template.format(event_id=event_id)
403
- logger.info("Polling VLM event result at %s", get_url)
404
-
405
- max_polls = 8
406
- poll_delay = 0.5
407
- final_text = ""
408
- last_response_json = None
409
- for attempt in range(max_polls):
410
- try:
411
- r2 = client.get(get_url, timeout=30.0)
412
- except Exception as e_get:
413
- logger.warning("GET attempt %d failed: %s", attempt + 1, str(e_get))
414
- time.sleep(poll_delay)
415
- continue
416
 
417
- if r2.status_code == 204 or not (r2.text and r2.text.strip()):
418
- time.sleep(poll_delay)
419
- continue
420
 
421
- try:
422
- r2j = r2.json()
423
- last_response_json = r2j
424
- except Exception:
425
- r2j = None
426
-
427
- text_out = ""
428
- if isinstance(r2j, dict):
429
- if "data" in r2j and isinstance(r2j["data"], list) and len(r2j["data"]) > 0:
430
- first = r2j["data"][0]
431
- if isinstance(first, dict):
432
- text_out = first.get("text") or first.get("output") or json.dumps(first)
433
- elif isinstance(first, str):
434
- text_out = first
435
- text_out = text_out or r2j.get("text") or r2j.get("msg") or r2j.get("output", "") or ""
436
  else:
437
- text_out = r2.text or ""
 
 
438
 
439
- if text_out and text_out.strip():
440
- final_text = text_out
441
- meta["attempts"].append({"mode": "get", "status_code": r2.status_code})
442
- break
443
- else:
444
- time.sleep(poll_delay)
445
- continue
446
 
447
- if not final_text:
448
- final_text = (r2.text or "").strip()
449
- meta["attempts"].append({"mode": "get_last", "status_code": r2.status_code if 'r2' in locals() and r2 is not None else None, "raw": final_text[:500]})
450
 
451
- meta["vlm_raw_len"] = len(final_text)
452
- meta["vlm_out_object"] = (final_text[:2000] + "...") if len(final_text) > 2000 else final_text
453
 
454
- # Best-effort: detect whether server mentions receiving a file
455
- files_seen = None
 
 
 
 
 
 
 
 
 
456
  try:
457
- if isinstance(last_response_json, dict):
458
- for key in ("files", "output_files", "files_sent", "uploaded_files", "received_files"):
459
- if key in last_response_json and isinstance(last_response_json[key], (list, tuple)):
460
- files_seen = len(last_response_json[key])
461
- break
462
- if files_seen is None and final_text:
463
- ext_matches = re.findall(r"\.(?:jpg|jpeg|png|bmp|gif)\b", final_text, flags=re.IGNORECASE)
464
- if ext_matches:
465
- files_seen = len(ext_matches)
466
- else:
467
- matches = re.findall(r"\b(?:uploaded|received|file)\b", final_text, flags=re.IGNORECASE)
468
- if matches:
469
- files_seen = max(1, len(matches))
470
- except Exception:
471
- files_seen = None
472
 
473
- meta["vlm_files_seen"] = files_seen
474
- meta["vlm_file_delivery_ok"] = (files_seen is not None and files_seen >= 1)
 
 
475
 
476
- parsed_features = None
477
- return parsed_features, (final_text or ""), meta
478
 
479
  # -----------------------
480
  # Gradio / LLM helper (defensive, with retry + clamps)
481
  # -----------------------
482
- def get_gradio_client_for_space(space: str) -> Client:
483
- if not GRADIO_AVAILABLE:
484
- raise RuntimeError("gradio_client not installed in this environment. Add gradio_client to requirements.txt.")
485
- if HF_TOKEN:
486
- return Client(space, hf_token=HF_TOKEN)
487
- return Client(space)
488
-
489
  def run_llm_on_vlm(vlm_features_or_raw: Any,
490
  max_new_tokens: int = 1024,
491
  temperature: float = 0.0,
@@ -640,6 +550,16 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
640
 
641
  return parsed
642
 
 
 
 
 
 
 
 
 
 
 
643
  except Exception as e:
644
  logger.exception("LLM call failed on attempt %d: %s", attempt, str(e))
645
  last_exc = e
@@ -670,10 +590,9 @@ async def health_check():
670
  return {
671
  "status": "healthy",
672
  "detector": impl,
673
- "vlm_available": True, # we use HTTP POST/GET for VLM
674
  "vlm_space": GRADIO_VLM_SPACE,
675
- "llm_space": LLM_GRADIO_SPACE,
676
- "gradio_client_for_llm": GRADIO_AVAILABLE
677
  }
678
 
679
  @app.post("/api/v1/validate-eye-photo")
@@ -705,7 +624,7 @@ async def validate_eye_photo(image: UploadFile = File(...)):
705
  is_valid = eye_openness_score >= 0.3
706
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
707
  "message_english": "Photo looks good! Eyes are properly open." if is_valid else "Eyes appear to be closed or partially closed. Please open your eyes wide and try again.",
708
- "message_hindi": "फोटो अच्छी ���ै! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
709
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
710
  except Exception:
711
  traceback.print_exc()
@@ -729,7 +648,7 @@ async def validate_eye_photo(image: UploadFile = File(...)):
729
  is_valid = eye_openness_score >= 0.3
730
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
731
  "message_english": "Photo looks good! Eyes are properly open." if is_valid else "Eyes appear to be closed or partially closed. Please open your eyes wide and try again.",
732
- "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
733
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
734
 
735
  if isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
@@ -756,7 +675,7 @@ async def validate_eye_photo(image: UploadFile = File(...)):
756
  left_eye = {"x": cx, "y": cy}
757
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
758
  "message_english": "Photo looks good! Eyes are detected." if is_valid else "Eyes not detected. Please open your eyes wide and try again.",
759
- "message_hindi": "फोटो अच्छी है! आंखें मिलीं।" if is_valid else "आंखें नहीं मिलीं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
760
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
761
  except Exception:
762
  traceback.print_exc()
@@ -858,8 +777,7 @@ async def get_vitals_from_upload(
858
  Note: VLM will receive only the face image (not the eye image).
859
  """
860
  if not GRADIO_AVAILABLE:
861
- # LLM call requires gradio_client to be installed. If not present, user must install it.
862
- raise HTTPException(status_code=500, detail="LLM client (gradio_client) not available in this deployment.")
863
 
864
  # save files to a temp directory
865
  try:
@@ -970,6 +888,8 @@ class ImageUrls(BaseModel):
970
  face_image_url: HttpUrl
971
  eye_image_url: HttpUrl
972
 
 
 
973
  # helper: download URL to file with safety checks
974
  async def download_image_to_path(url: str, dest_path: str, max_bytes: int = 5_000_000, timeout_seconds: int = 10) -> None:
975
  """
@@ -1011,8 +931,7 @@ async def get_vitals_from_urls(payload: ImageUrls = Body(...)):
1011
  Body: { "face_image_url": "...", "eye_image_url": "..." }
1012
  """
1013
  if not GRADIO_AVAILABLE:
1014
- # LLM call requires gradio_client to be installed
1015
- raise HTTPException(status_code=500, detail="LLM client (gradio_client) not available in this deployment.")
1016
 
1017
  # prepare tmp paths
1018
  try:
@@ -1256,4 +1175,4 @@ async def process_screening(screening_id: str):
1256
  # -----------------------
1257
  if __name__ == "__main__":
1258
  import uvicorn
1259
- uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
 
3
  Elderly HealthWatch AI Backend (FastAPI)
4
  Pipeline:
5
  - receive images
6
+ - run VLM (remote gradio / chat_fn) -> JSON feature vector + raw text + meta
7
  - run LLM (remote gradio /chat) -> structured risk JSON (per requested schema)
8
  - continue rest of processing and store results
9
 
10
  Notes:
11
+ - Add gradio_client==1.13.2 (or another compatible 1.x) to requirements.txt
 
12
  - If VLM/LLM Spaces are private, set HF_TOKEN in the environment for authentication.
13
  - This variant:
14
+ * logs raw VLM responses,
15
+ * always returns raw VLM output in API responses,
16
+ * extracts JSON from VLM via regex when possible, and
17
+ * sends only the face image to the VLM (not the eye image).
18
  """
19
 
20
  import io
 
26
  import traceback
27
  import re
28
  import time
 
29
  from typing import Dict, Any, Optional, Tuple
30
  from datetime import datetime
31
 
 
36
  import numpy as np
37
  import cv2 # opencv-python-headless expected installed
38
 
39
+ # Optional gradio client (for VLM + LLM calls)
 
 
 
40
  try:
41
  from gradio_client import Client, handle_file # type: ignore
42
  GRADIO_AVAILABLE = True
 
246
  return out
247
 
248
  # -----------------------
249
+ # Gradio / VLM helper (sends only face image, returns meta)
 
250
  # -----------------------
251
+ def get_gradio_client_for_space(space: str) -> Client:
252
+ if not GRADIO_AVAILABLE:
253
+ raise RuntimeError("gradio_client not installed in this environment. Add gradio_client to requirements.txt.")
254
+ if HF_TOKEN:
255
+ return Client(space, hf_token=HF_TOKEN)
256
+ return Client(space)
257
+
258
  def run_vlm_and_get_features(face_path: str, eye_path: Optional[str] = None, prompt: Optional[str] = None,
259
  raise_on_file_delivery_failure: bool = False
260
  ) -> Tuple[Optional[Dict[str, Any]], str, Dict[str, Any]]:
261
  """
262
+ Synchronous call to remote VLM (gradio /chat_fn). Sends ONLY the face image file.
263
+ Returns tuple: (parsed_features_dict_or_None, raw_text_response_str, meta)
264
+ meta includes:
265
+ - vlm_file_delivery_ok (bool) # expects ≥1 file acknowledged (face)
266
+ - vlm_files_seen (int or None)
267
+ - vlm_raw_len (int)
268
+ - vlm_out_object (short repr)
 
 
 
269
  """
270
  prompt = prompt or DEFAULT_VLM_PROMPT
271
 
272
+
273
  if not os.path.exists(face_path):
274
  raise FileNotFoundError(f"Face image not found at: {face_path}")
275
+ if not os.path.exists(eye_path):
276
+ raise FileNotFoundError(f"Eye image not found at: {eye_path}")
277
+
278
+ face_size = os.path.getsize(face_path)
279
+ eye_size = os.path.getsize(eye_path)
280
+ logger.info(f"VLM input files - Face: {face_size} bytes, Eye: {eye_size} bytes")
281
+
282
+ if face_size == 0 or eye_size == 0:
283
+ raise ValueError("One or both images are empty (0 bytes)")
284
+
285
+ if not GRADIO_AVAILABLE:
286
+ raise RuntimeError("gradio_client not available in this environment.")
287
 
288
+ client = get_gradio_client_for_space(GRADIO_VLM_SPACE)
289
+
290
+ # Verify files can be opened as images
291
+ try:
292
+ Image.open(face_path).verify()
293
+ Image.open(eye_path).verify()
294
+ logger.info("Both images verified as valid")
295
+ except Exception as e:
296
+ raise ValueError(f"Invalid image file(s): {e}")
297
+
298
+ message = {"text": prompt, "files": [handle_file(face_path), handle_file(eye_path)]}
299
+
300
+ logger.info(f"Calling VLM with message structure: text={len(prompt)} chars, files=2")
301
+ client = get_gradio_client_for_space(GRADIO_VLM_SPACE)
302
+ # NOTE: only send face image to the Space
303
 
304
+ message = {"text": prompt, "files": [handle_file(face_path)]}
 
305
 
306
+ meta: Dict[str, Any] = {"vlm_file_delivery_ok": False, "vlm_files_seen": None, "vlm_raw_len": 0, "vlm_out_object": None}
 
 
 
 
 
 
 
307
 
308
+ # SINGLE CALL (no retries)
309
+ try:
310
+ logger.info("Calling VLM Space %s with 1 file (face only)", GRADIO_VLM_SPACE)
311
+ result = client.predict(message=message, history=[], api_name="/chat_fn")
312
+ except Exception as e:
313
+ logger.exception("VLM call failed (no retries)")
314
+ raise RuntimeError(f"VLM call failed: {e}")
315
+
316
+ # Normalize result
317
+ raw_text = ""
318
+ out = None
319
+ if not result:
320
+ logger.warning("VLM returned empty result object")
321
  else:
322
+ if isinstance(result, (list, tuple)):
323
+ out = result[0]
324
+ elif isinstance(result, dict):
325
+ out = result
326
  else:
327
+ out = {"text": str(result)}
328
 
329
+ text_out = out.get("text") or out.get("output") or ""
330
+ raw_text = text_out or ""
331
+ meta["vlm_raw_len"] = len(raw_text or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  try:
333
+ meta["vlm_out_object"] = str(out)[:2000]
334
+ except Exception:
335
+ meta["vlm_out_object"] = "<unreprable>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
 
337
+ logger.info("VLM response object (debug snippet): %s", meta["vlm_out_object"])
 
 
338
 
339
+ # --- Check whether the remote acknowledged receiving files (expect 1) ---
340
+ files_seen = None
341
+ try:
342
+ if isinstance(out, dict):
343
+ for key in ("files", "output_files", "files_sent", "uploaded_files", "received_files"):
344
+ if key in out and isinstance(out[key], (list, tuple)):
345
+ files_seen = len(out[key])
346
+ break
347
+
348
+ if files_seen is None and raw_text:
349
+ ext_matches = re.findall(r"\.(?:jpg|jpeg|png|bmp|gif)\b", raw_text, flags=re.IGNORECASE)
350
+ if ext_matches:
351
+ files_seen = len(ext_matches)
 
 
352
  else:
353
+ matches = re.findall(r"\b(?:uploaded|received|file)\b", raw_text, flags=re.IGNORECASE)
354
+ if matches:
355
+ files_seen = max(1, len(matches))
356
 
357
+ meta["vlm_files_seen"] = files_seen
358
+ meta["vlm_file_delivery_ok"] = (files_seen is not None and files_seen >= 1)
359
+ except Exception:
360
+ meta["vlm_files_seen"] = None
361
+ meta["vlm_file_delivery_ok"] = False
 
 
362
 
363
+ if raise_on_file_delivery_failure and not meta["vlm_file_delivery_ok"]:
364
+ logger.error("VLM did not acknowledge receiving the face file. meta=%s", meta)
365
+ raise RuntimeError("VLM Space did not acknowledge receiving the face image")
366
 
367
+ # Log raw VLM output for debugging/auditing
368
+ logger.info("VLM raw output (length=%d):\n%s", len(raw_text or ""), (raw_text[:1000] + "...") if raw_text and len(raw_text) > 1000 else (raw_text or "<EMPTY>"))
369
 
370
+ # Try to parse JSON first (fast path)
371
+ parsed_features = None
372
+ try:
373
+ parsed_features = json.loads(raw_text) if raw_text and raw_text.strip() else None
374
+ if parsed_features is not None and not isinstance(parsed_features, dict):
375
+ parsed_features = None
376
+ except Exception:
377
+ parsed_features = None
378
+
379
+ # If json.loads failed or returned None, try regex-based extraction
380
+ if parsed_features is None and raw_text and raw_text.strip():
381
  try:
382
+ parsed_features = extract_json_via_regex(raw_text)
383
+ logger.info("VLM regex-extracted features:\n%s", json.dumps(parsed_features, indent=2, ensure_ascii=False))
384
+ except Exception as e:
385
+ logger.info("VLM regex extraction failed or found nothing: %s", str(e))
386
+ parsed_features = None
 
 
 
 
 
 
 
 
 
 
387
 
388
+ if parsed_features is None:
389
+ logger.info("VLM parsed features: None (will fallback to sending '{}' or raw string to LLM).")
390
+ else:
391
+ logger.info("VLM parsed features (final): %s", json.dumps(parsed_features, ensure_ascii=False))
392
 
393
+ # Always return parsed_features (or None), raw_text (string), and meta dict
394
+ return parsed_features, (raw_text or ""), meta
395
 
396
  # -----------------------
397
  # Gradio / LLM helper (defensive, with retry + clamps)
398
  # -----------------------
 
 
 
 
 
 
 
399
  def run_llm_on_vlm(vlm_features_or_raw: Any,
400
  max_new_tokens: int = 1024,
401
  temperature: float = 0.0,
 
550
 
551
  return parsed
552
 
553
+ except AppError as app_e:
554
+ logger.exception("LLM AppError (remote validation failed) on attempt %d: %s", attempt, str(app_e))
555
+ last_exc = app_e
556
+ if attempt == 1:
557
+ predict_kwargs["temperature"] = 0.2
558
+ predict_kwargs["max_new_tokens"] = float(512)
559
+ logger.info("Retrying LLM call with temperature=0.2 and max_new_tokens=512")
560
+ continue
561
+ else:
562
+ raise RuntimeError(f"LLM call failed (AppError): {app_e}")
563
  except Exception as e:
564
  logger.exception("LLM call failed on attempt %d: %s", attempt, str(e))
565
  last_exc = e
 
590
  return {
591
  "status": "healthy",
592
  "detector": impl,
593
+ "vlm_available": GRADIO_AVAILABLE,
594
  "vlm_space": GRADIO_VLM_SPACE,
595
+ "llm_space": LLM_GRADIO_SPACE
 
596
  }
597
 
598
  @app.post("/api/v1/validate-eye-photo")
 
624
  is_valid = eye_openness_score >= 0.3
625
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
626
  "message_english": "Photo looks good! Eyes are properly open." if is_valid else "Eyes appear to be closed or partially closed. Please open your eyes wide and try again.",
627
+ "message_hindi": "फोटो अच्छी ै! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
628
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
629
  except Exception:
630
  traceback.print_exc()
 
648
  is_valid = eye_openness_score >= 0.3
649
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
650
  "message_english": "Photo looks good! Eyes are properly open." if is_valid else "Eyes appear to be closed or partially closed. Please open your eyes wide and try again.",
651
+ "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
652
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
653
 
654
  if isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
 
675
  left_eye = {"x": cx, "y": cy}
676
  return {"valid": bool(is_valid), "face_detected": True, "eye_openness_score": round(eye_openness_score, 2),
677
  "message_english": "Photo looks good! Eyes are detected." if is_valid else "Eyes not detected. Please open your eyes wide and try again.",
678
+ "message_hindi": "फोटो अच्छी है! आंखें मिलीं।" if is_valid else "आंखें नहीं मिलीं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें",
679
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
680
  except Exception:
681
  traceback.print_exc()
 
777
  Note: VLM will receive only the face image (not the eye image).
778
  """
779
  if not GRADIO_AVAILABLE:
780
+ raise HTTPException(status_code=500, detail="VLM/LLM client not available in this deployment.")
 
781
 
782
  # save files to a temp directory
783
  try:
 
888
  face_image_url: HttpUrl
889
  eye_image_url: HttpUrl
890
 
891
+ import httpx # make sure to add httpx to requirements
892
+
893
  # helper: download URL to file with safety checks
894
  async def download_image_to_path(url: str, dest_path: str, max_bytes: int = 5_000_000, timeout_seconds: int = 10) -> None:
895
  """
 
931
  Body: { "face_image_url": "...", "eye_image_url": "..." }
932
  """
933
  if not GRADIO_AVAILABLE:
934
+ raise HTTPException(status_code=500, detail="VLM/LLM client not available in this deployment.")
 
935
 
936
  # prepare tmp paths
937
  try:
 
1175
  # -----------------------
1176
  if __name__ == "__main__":
1177
  import uvicorn
1178
+ uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)