dpv007 commited on
Commit
f37add2
·
verified ·
1 Parent(s): d1faea8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +168 -161
app.py CHANGED
@@ -10,10 +10,11 @@ Pipeline:
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 version includes a robust regex-based extractor that finds the outermost {...} block
14
- in the LLM output, extracts numeric values for the required keys, and always returns
15
- numeric defaults (no NaN) so frontends will not receive null/None for numeric fields.
16
- - This variant logs raw VLM & LLM outputs and the parsed JSON using Python logging.
 
17
  """
18
 
19
  import io
@@ -24,6 +25,7 @@ import asyncio
24
  import logging
25
  import traceback
26
  import re
 
27
  from typing import Dict, Any, Optional, Tuple
28
  from datetime import datetime
29
 
@@ -49,6 +51,10 @@ GRADIO_VLM_SPACE = os.getenv("GRADIO_SPACE", "developer0hye/Qwen3-VL-8B-Instruct
49
  LLM_GRADIO_SPACE = os.getenv("LLM_GRADIO_SPACE", "Tonic/med-gpt-oss-20b-demo")
50
  HF_TOKEN = os.getenv("HF_TOKEN", None)
51
 
 
 
 
 
52
  # Default VLM prompt
53
  DEFAULT_VLM_PROMPT = (
54
  "From the provided face/eye images, compute the required screening features "
@@ -140,34 +146,32 @@ def estimate_eye_openness_from_detection(confidence: float) -> float:
140
  return 0.0
141
 
142
  # -----------------------
143
- # Regex-based robust extractor
144
  # -----------------------
145
  def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
146
  """
147
- 1) Finds the outermost { ... } block in raw_text.
148
- 2) Extracts numeric values after the listed keys using regex, tolerating:
149
- - quotes, spaces, percent signs, percent numbers like "55%", strings like "0.12", integers, or numbers in quotes.
150
- 3) Returns a dict with numeric fields GUARANTEED to be floats (no None/NaN), and string fields for summary/recommendation.
 
 
 
 
 
 
151
  """
152
- # Find the first {...} block (outermost approximation)
153
  match = re.search(r"\{[\s\S]*\}", raw_text)
154
  if not match:
155
- raise ValueError("No JSON-like block found in LLM output")
156
-
157
  block = match.group(0)
158
 
159
  def find_number_for_key(key: str) -> Optional[float]:
160
- """
161
- Returns a float in range 0..1 for probabilities, and raw numeric for other keys depending on usage.
162
- This helper returns None if not found; caller will replace with defaults (0.0).
163
- """
164
- # Try multiple patterns to be robust
165
- # Pattern captures numbers possibly with % and optional quotes, e.g. "45%", '0.12', 0.5, " 87 "
166
  patterns = [
167
- rf'"{key}"\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?', # "key": "45%" or "key": 0.45
168
  rf"'{key}'\s*:\s*['\"]?\s*([-+]?\d+(\.\d+)?)\s*%?\s*['\"]?",
169
- rf'\b{key}\b\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?', # key: 45%
170
- rf'"{key}"\s*:\s*["\']([^"\']+)["\']', # capture quoted text (for non-numeric attempts)
171
  rf"'{key}'\s*:\s*['\"]([^'\"]+)['\"]"
172
  ]
173
  for pat in patterns:
@@ -177,33 +181,25 @@ def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
177
  g = m.group(1)
178
  if g is None:
179
  continue
180
- s = str(g).strip()
181
- # Remove percent sign if present
182
- s = s.replace("%", "").strip()
183
- # Try to coerce to float
184
  try:
185
- val = float(s)
186
- return val
187
  except Exception:
188
- # not numeric
189
  return None
190
  return None
191
 
192
  def find_text_for_key(key: str) -> str:
193
- # capture "key": "some text" allowing single/double quotes and also unquoted until comma/}
194
  m = re.search(rf'"{key}"\s*:\s*"([^"]*)"', block, flags=re.IGNORECASE)
195
  if m:
196
  return m.group(1).strip()
197
  m = re.search(rf"'{key}'\s*:\s*'([^']*)'", block, flags=re.IGNORECASE)
198
  if m:
199
  return m.group(1).strip()
200
- # fallback: key: some text (unquoted) up to comma or }
201
  m = re.search(rf'\b{key}\b\s*:\s*([^\n,}}]+)', block, flags=re.IGNORECASE)
202
  if m:
203
  return m.group(1).strip().strip('",')
204
  return ""
205
 
206
- # Extract raw numeric candidates
207
  raw_risk = find_number_for_key("risk_score")
208
  raw_jaundice = find_number_for_key("jaundice_probability")
209
  raw_anemia = find_number_for_key("anemia_probability")
@@ -211,17 +207,13 @@ def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
211
  raw_neuro = find_number_for_key("neurological_issue_probability")
212
  raw_conf = find_number_for_key("confidence")
213
 
214
- # Normalize:
215
- # - For probabilities: if value > 1 and <=100 => treat as percent -> divide by 100. If <=1 treat as fraction.
216
  def normalize_prob(v: Optional[float]) -> float:
217
  if v is None:
218
  return 0.0
219
  if v > 1.0 and v <= 100.0:
220
  return max(0.0, min(1.0, v / 100.0))
221
- # if v is large >100, clamp to 1.0
222
  if v > 100.0:
223
  return 1.0
224
- # otherwise assume already 0..1
225
  return max(0.0, min(1.0, v))
226
 
227
  jaundice_probability = normalize_prob(raw_jaundice)
@@ -230,17 +222,13 @@ def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
230
  neurological_issue_probability = normalize_prob(raw_neuro)
231
  confidence = normalize_prob(raw_conf)
232
 
233
- # risk_score: return in 0..100
234
  def normalize_risk(v: Optional[float]) -> float:
235
  if v is None:
236
  return 0.0
237
  if v <= 1.0:
238
- # fraction given -> scale to 0..100
239
  return round(max(0.0, min(100.0, v * 100.0)), 2)
240
- # if between 1 and 100, assume it's already 0..100
241
  if v > 1.0 and v <= 100.0:
242
  return round(max(0.0, min(100.0, v)), 2)
243
- # clamp anything insane
244
  return round(max(0.0, min(100.0, v if v < float('inf') else 100.0)), 2)
245
 
246
  risk_score = normalize_risk(raw_risk)
@@ -275,10 +263,10 @@ def run_vlm_and_get_features(face_path: str, eye_path: str, prompt: Optional[str
275
  Synchronous call to remote VLM (gradio /chat_fn). Returns tuple:
276
  (parsed_features_dict_or_None, raw_text_response_str)
277
 
278
- We attempt to parse JSON as before, but always return the original raw text so it can be
279
- forwarded verbatim to the LLM if desired. This function now also tries the robust
280
- regex extractor (extract_json_via_regex) on the raw text if json.loads fails, and logs
281
- the extracted values.
282
  """
283
  prompt = prompt or DEFAULT_VLM_PROMPT
284
  if not os.path.exists(face_path) or not os.path.exists(eye_path):
@@ -289,66 +277,82 @@ def run_vlm_and_get_features(face_path: str, eye_path: str, prompt: Optional[str
289
  client = get_gradio_client_for_space(GRADIO_VLM_SPACE)
290
  message = {"text": prompt, "files": [handle_file(face_path), handle_file(eye_path)]}
291
 
292
- try:
293
- logger.info("Calling VLM Space %s", GRADIO_VLM_SPACE)
294
- result = client.predict(message=message, history=[], api_name="/chat_fn")
295
- except Exception as e:
296
- logger.exception("VLM call failed")
297
- raise RuntimeError(f"VLM call failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
- if not result:
300
- raise RuntimeError("Empty response from VLM")
 
 
 
301
 
302
- # Normalize result
303
- if isinstance(result, (list, tuple)):
304
- out = result[0]
305
- elif isinstance(result, dict):
306
- out = result
307
- else:
308
- out = {"text": str(result)}
 
 
 
309
 
310
- if not isinstance(out, dict):
311
- raise RuntimeError("Unexpected VLM output format (expected dict with 'text' key)")
312
 
313
- text_out = out.get("text") or out.get("output") or None
314
- if not text_out:
315
- text_out = json.dumps(out)
316
 
317
  # Log raw VLM output for debugging/auditing
318
- try:
319
- logger.info("VLM raw output:\n%s", text_out)
320
- except Exception:
321
- logger.info("VLM raw output (could not pretty print)")
322
 
323
- # Try to parse JSON first (as before)
324
  parsed_features = None
325
  try:
326
- parsed_features = json.loads(text_out)
327
- if not isinstance(parsed_features, dict):
328
  parsed_features = None
329
  except Exception:
330
  parsed_features = None
331
 
332
- # If json.loads failed, try regex-based extraction (robust)
333
- if parsed_features is None:
334
  try:
335
  parsed_features = extract_json_via_regex(text_out)
336
  logger.info("VLM regex-extracted features:\n%s", json.dumps(parsed_features, indent=2, ensure_ascii=False))
337
  except Exception as e:
338
- # No JSON-like block or extraction failed: keep parsed_features as None and log
339
- logger.info("VLM regex extraction did not find structured JSON (this may be fine): %s", str(e))
340
  parsed_features = None
341
 
342
- # Log parsed features if available
343
- if parsed_features is not None:
344
- try:
345
- logger.info("VLM parsed features (final):\n%s", json.dumps(parsed_features, indent=2, ensure_ascii=False))
346
- except Exception:
347
- logger.info("VLM parsed features (raw): %s", str(parsed_features))
348
  else:
349
- logger.info("VLM parsed features: None (raw output kept)")
350
 
351
- return parsed_features, text_out
 
352
 
353
  # -----------------------
354
  # Gradio / LLM helper (defensive, with retry + clamps)
@@ -362,9 +366,9 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
362
  developer_prompt: Optional[str] = None) -> Dict[str, Any]:
363
  """
364
  Call the remote LLM Space's /chat endpoint with defensive input handling and a single retry.
365
- - Coerces types (int for tokens), clamps ranges where remote spaces often expect them.
366
- - Retries once with safe defaults if the Space rejects the inputs (e.g. temperature too low).
367
- - Logs and returns regex-extracted JSON as before.
368
  """
369
  if not GRADIO_AVAILABLE:
370
  raise RuntimeError("gradio_client not installed. Add gradio_client to requirements.txt")
@@ -380,13 +384,17 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
380
  system_prompt = system_prompt or LLM_SYSTEM_PROMPT
381
  developer_prompt = developer_prompt or LLM_DEVELOPER_PROMPT
382
 
383
- # Prepare the combined prompt: use raw string as-is, otherwise json.dumps the dict
384
  if isinstance(vlm_features_or_raw, str):
385
- vlm_json_str = vlm_features_or_raw
 
 
386
  else:
387
- vlm_json_str = json.dumps(vlm_features_or_raw, default=str)
 
 
388
 
389
- # Strong, explicit instruction to output only JSON
390
  instruction = (
391
  "\n\nSTRICT INSTRUCTIONS (READ CAREFULLY):\n"
392
  "1) OUTPUT ONLY a single valid JSON object and nothing else — no prose, no explanation, no code fences.\n"
@@ -397,10 +405,9 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
397
  "If you cannot estimate a value, set it to null.\n\n"
398
  "Now, based on the VLM output below, produce ONLY the JSON object described above.\n\n"
399
  "===BEGIN VLM OUTPUT===\n"
400
- f"{vlm_json_str}\n"
401
  "===END VLM OUTPUT===\n\n"
402
  )
403
- input_payload_str = instruction
404
 
405
  # Defensive coercion / clamps
406
  try_max_new_tokens = int(max_new_tokens) if max_new_tokens is not None else 1024
@@ -408,13 +415,12 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
408
  try_max_new_tokens = 1024
409
 
410
  try_temperature = float(temperature) if temperature is not None else 0.0
411
- # Many demos require temperature >= 0.1; clamp to 0.1 minimum to avoid validation failures
412
  if try_temperature < 0.1:
413
  try_temperature = 0.1
414
 
415
- # prepare kwargs for predict
416
  predict_kwargs = dict(
417
- input_data=input_payload_str,
418
  max_new_tokens=float(try_max_new_tokens),
419
  model_identity=model_identity,
420
  system_prompt=system_prompt,
@@ -427,26 +433,39 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
427
  api_name="/chat"
428
  )
429
 
430
- # attempt + one retry with safer defaults if AppError occurs
431
  last_exc = None
432
  for attempt in (1, 2):
433
  try:
434
  logger.info("Calling LLM Space %s (attempt %d) with temperature=%s, max_new_tokens=%s",
435
  LLM_GRADIO_SPACE, attempt, predict_kwargs.get("temperature"), predict_kwargs.get("max_new_tokens"))
436
  result = client.predict(**predict_kwargs)
 
437
  # normalize to string
438
  if isinstance(result, (dict, list)):
439
  text_out = json.dumps(result)
440
  else:
441
  text_out = str(result)
 
442
  if not text_out or len(text_out.strip()) == 0:
443
  raise RuntimeError("LLM returned empty response")
444
- logger.info("LLM raw output:\n%s", text_out)
 
445
 
446
  # parse with regex extractor (may raise)
447
- parsed = extract_json_via_regex(text_out)
448
- if not isinstance(parsed, dict):
449
- raise ValueError("Parsed LLM output is not a JSON object/dict")
 
 
 
 
 
 
 
 
 
 
 
450
 
451
  # pretty log parsed JSON
452
  try:
@@ -454,7 +473,7 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
454
  except Exception:
455
  logger.info("LLM parsed JSON (raw dict): %s", str(parsed))
456
 
457
- # defensive clamps (same as before)
458
  def safe_prob(val):
459
  try:
460
  v = float(val)
@@ -493,29 +512,24 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
493
  return parsed
494
 
495
  except AppError as app_e:
496
- # Specific remote validation error: log and attempt a single retry with ultra-safe defaults
497
  logger.exception("LLM AppError (remote validation failed) on attempt %d: %s", attempt, str(app_e))
498
  last_exc = app_e
499
  if attempt == 1:
500
- # tighten inputs and retry: force temperature=0.2, max_new_tokens=512
501
  predict_kwargs["temperature"] = 0.2
502
  predict_kwargs["max_new_tokens"] = float(512)
503
  logger.info("Retrying LLM call with temperature=0.2 and max_new_tokens=512")
504
  continue
505
  else:
506
- # no more retries
507
  raise RuntimeError(f"LLM call failed (AppError): {app_e}")
508
  except Exception as e:
509
  logger.exception("LLM call failed on attempt %d: %s", attempt, str(e))
510
  last_exc = e
511
- # try one retry only for non-AppError exceptions
512
  if attempt == 1:
513
  predict_kwargs["temperature"] = 0.2
514
  predict_kwargs["max_new_tokens"] = float(512)
515
  continue
516
  raise RuntimeError(f"LLM call failed: {e}")
517
 
518
- # if we reach here, raise last caught exception
519
  raise RuntimeError(f"LLM call ultimately failed: {last_exc}")
520
 
521
  # -----------------------
@@ -544,13 +558,8 @@ async def health_check():
544
 
545
  @app.post("/api/v1/validate-eye-photo")
546
  async def validate_eye_photo(image: UploadFile = File(...)):
547
- """
548
- Lightweight validation endpoint. Uses available detector (facenet/mtcnn/opencv) to check face/eye detection.
549
- For full pipeline, use /api/v1/upload which invokes VLM+LLM in background.
550
- """
551
  if mtcnn is None:
552
  raise HTTPException(status_code=500, detail="No face detector available in this deployment.")
553
-
554
  try:
555
  content = await image.read()
556
  if not content:
@@ -558,7 +567,6 @@ async def validate_eye_photo(image: UploadFile = File(...)):
558
  pil_img = load_image_from_bytes(content)
559
  img_arr = np.asarray(pil_img) # RGB
560
 
561
- # facenet-pytorch branch
562
  if not isinstance(mtcnn, dict) and _MTCNN_IMPL == "facenet_pytorch":
563
  try:
564
  boxes, probs, landmarks = mtcnn.detect(pil_img, landmarks=True)
@@ -583,7 +591,6 @@ async def validate_eye_photo(image: UploadFile = File(...)):
583
  traceback.print_exc()
584
  raise HTTPException(status_code=500, detail="Face detector failed during inference.")
585
 
586
- # classic mtcnn branch
587
  if not isinstance(mtcnn, dict) and _MTCNN_IMPL == "mtcnn":
588
  try:
589
  detections = mtcnn.detect_faces(img_arr)
@@ -605,7 +612,6 @@ async def validate_eye_photo(image: UploadFile = File(...)):
605
  "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें।",
606
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
607
 
608
- # OpenCV Haar cascade fallback
609
  if isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
610
  try:
611
  gray = cv2.cvtColor(img_arr, cv2.COLOR_RGB2GRAY)
@@ -707,7 +713,11 @@ async def get_status(screening_id: str):
707
  async def get_results(screening_id: str):
708
  if screening_id not in screenings_db:
709
  raise HTTPException(status_code=404, detail="Screening not found")
710
- return screenings_db[screening_id]
 
 
 
 
711
 
712
  @app.get("/api/v1/history/{user_id}")
713
  async def get_history(user_id: str):
@@ -724,7 +734,7 @@ async def get_vitals_from_upload(
724
  ):
725
  """
726
  Run VLM -> LLM pipeline synchronously (but off the event loop) and return:
727
- { vlm_features, vlm_raw, structured_risk }
728
  """
729
  if not GRADIO_AVAILABLE:
730
  raise HTTPException(status_code=500, detail="VLM/LLM client not available in this deployment.")
@@ -750,21 +760,26 @@ async def get_vitals_from_upload(
750
  # Run VLM (off the event loop)
751
  vlm_features, vlm_raw = await asyncio.to_thread(run_vlm_and_get_features, face_path, eye_path)
752
 
753
- # Log VLM outputs (already logged inside run_vlm..., but log again with context)
754
- logger.info("get_vitals_from_upload - VLM raw (snippet): %s", (vlm_raw[:100] + "...") if vlm_raw else "None")
755
- logger.info("get_vitals_from_upload - VLM parsed features: %s", vlm_features if vlm_features is not None else "None")
756
 
757
- # Prefer sending raw vlm text to LLM (same behavior as process_screening)
758
- llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}")
 
 
 
 
 
759
 
760
  # Run LLM (off the event loop)
761
  structured_risk = await asyncio.to_thread(run_llm_on_vlm, llm_input)
762
 
763
- # Return merged result
764
  return {
765
- "vlm_features": vlm_features,
766
- "vlm_raw": vlm_raw,
767
- "structured_risk": structured_risk
768
  }
769
  except Exception as e:
770
  logger.exception("get_vitals_from_upload pipeline failed")
@@ -789,17 +804,22 @@ async def get_vitals_for_screening(screening_id: str):
789
  # Run VLM off the event loop
790
  vlm_features, vlm_raw = await asyncio.to_thread(run_vlm_and_get_features, face_path, eye_path)
791
 
792
- # Log VLM outputs
793
- logger.info("get_vitals_for_screening(%s) - VLM raw (snippet): %s", screening_id, (vlm_raw[:100] + "...") if vlm_raw else "None")
794
- logger.info("get_vitals_for_screening(%s) - VLM parsed features: %s", screening_id, vlm_features if vlm_features is not None else "None")
 
 
 
 
 
 
795
 
796
- llm_input = vlm_raw if vlm_raw else (vlm_features if vlm_features else "{}")
797
  structured_risk = await asyncio.to_thread(run_llm_on_vlm, llm_input)
798
 
799
  # Optionally store this run's outputs back into the DB for inspection
800
  entry.setdefault("ai_results", {})
801
  entry["ai_results"].update({
802
- "vlm_features": vlm_features,
803
  "vlm_raw": vlm_raw,
804
  "structured_risk": structured_risk,
805
  "last_vitals_run": datetime.utcnow().isoformat() + "Z"
@@ -807,16 +827,16 @@ async def get_vitals_for_screening(screening_id: str):
807
 
808
  return {
809
  "screening_id": screening_id,
810
- "vlm_features": vlm_features,
811
- "vlm_raw": vlm_raw,
812
- "structured_risk": structured_risk
813
  }
814
  except Exception as e:
815
  logger.exception("get_vitals_for_screening pipeline failed")
816
  raise HTTPException(status_code=500, detail=f"Pipeline failed: {e}")
817
 
818
  # -----------------------
819
- # Main processing pipeline
820
  # -----------------------
821
  async def process_screening(screening_id: str):
822
  """
@@ -824,7 +844,7 @@ async def process_screening(screening_id: str):
824
  - load images
825
  - quick detector-based quality metrics
826
  - run VLM -> vlm_features (dict or None) + vlm_raw (string)
827
- - run LLM on vlm_raw (preferred) or vlm_features -> structured risk JSON
828
  - merge results into ai_results and finish
829
  """
830
  try:
@@ -914,7 +934,7 @@ async def process_screening(screening_id: str):
914
  vlm_features, vlm_raw = run_vlm_and_get_features(face_path, eye_path)
915
  screenings_db[screening_id].setdefault("ai_results", {})
916
  screenings_db[screening_id]["ai_results"].update({
917
- "vlm_features": vlm_features,
918
  "vlm_raw": vlm_raw
919
  })
920
  except Exception as e:
@@ -922,33 +942,25 @@ async def process_screening(screening_id: str):
922
  screenings_db[screening_id].setdefault("ai_results", {})
923
  screenings_db[screening_id]["ai_results"].update({"vlm_error": str(e)})
924
  vlm_features = None
925
- vlm_raw = None
926
 
927
  # Log VLM outputs in pipeline context
928
- logger.info("process_screening(%s) - VLM raw (snippet): %s", screening_id, (vlm_raw[:100] + "...") if vlm_raw else "None")
929
- logger.info("process_screening(%s) - VLM parsed features: %s", screening_id, vlm_features if vlm_features is not None else "None")
930
 
931
  # --------------------------
932
- # RUN LLM on vlm_raw (preferred) or vlm_features -> structured risk JSON
933
  # --------------------------
934
  structured_risk = None
935
  try:
936
- if vlm_raw:
937
- structured_risk = run_llm_on_vlm(vlm_raw)
938
- elif vlm_features:
939
- structured_risk = run_llm_on_vlm(vlm_features)
940
  else:
941
- # Fallback if VLM failed: produce conservative defaults
942
- structured_risk = {
943
- "risk_score": 0.0,
944
- "jaundice_probability": 0.0,
945
- "anemia_probability": 0.0,
946
- "hydration_issue_probability": 0.0,
947
- "neurological_issue_probability": 0.0,
948
- "summary": "",
949
- "recommendation": "",
950
- "confidence": 0.0
951
- }
952
  screenings_db[screening_id].setdefault("ai_results", {})
953
  screenings_db[screening_id]["ai_results"].update({"structured_risk": structured_risk})
954
  except Exception as e:
@@ -967,19 +979,14 @@ async def process_screening(screening_id: str):
967
  }
968
 
969
  # Use structured_risk for summary recommendations & simple disease inference placeholders
970
- hem = screenings_db[screening_id]["ai_results"].get("medical_insights", {}).get("hemoglobin_estimate", None)
971
- bil = screenings_db[screening_id]["ai_results"].get("medical_insights", {}).get("bilirubin_estimate", None)
972
-
973
- # Keep older ai_results shape for backward compatibility (if you want)
974
  screenings_db[screening_id].setdefault("ai_results", {})
975
  screenings_db[screening_id]["ai_results"].update({
976
  "processing_time_ms": 1200
977
  })
978
 
979
- # disease_predictions & recommendations can be built from structured_risk if needed
980
  disease_predictions = [
981
  {
982
- "condition": "Anemia-like-signs", # internal tag (not surfaced in LLM summary)
983
  "risk_level": "Medium" if structured_risk.get("anemia_probability", 0.0) > 0.5 else "Low",
984
  "probability": structured_risk.get("anemia_probability", 0.0),
985
  "confidence": structured_risk.get("confidence", 0.0)
@@ -995,7 +1002,7 @@ async def process_screening(screening_id: str):
995
  recommendations = {
996
  "action_needed": "consult" if structured_risk.get("risk_score", 0.0) > 30.0 else "monitor",
997
  "message_english": structured_risk.get("recommendation", "") or f"Please follow up with a health professional if concerns persist.",
998
- "message_hindi": "" # could be auto-translated if desired
999
  }
1000
 
1001
  screenings_db[screening_id].update({
 
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 final 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 either cleaned JSON or raw VLM string into LLM (and logs which was used).
18
  """
19
 
20
  import io
 
25
  import logging
26
  import traceback
27
  import re
28
+ import time
29
  from typing import Dict, Any, Optional, Tuple
30
  from datetime import datetime
31
 
 
51
  LLM_GRADIO_SPACE = os.getenv("LLM_GRADIO_SPACE", "Tonic/med-gpt-oss-20b-demo")
52
  HF_TOKEN = os.getenv("HF_TOKEN", None)
53
 
54
+ # VLM retry config (if VLM returns empty text)
55
+ VLM_EMPTY_RETRIES = int(os.getenv("VLM_EMPTY_RETRIES", "2"))
56
+ VLM_EMPTY_RETRY_SLEEP_S = float(os.getenv("VLM_EMPTY_RETRY_SLEEP_S", "0.5"))
57
+
58
  # Default VLM prompt
59
  DEFAULT_VLM_PROMPT = (
60
  "From the provided face/eye images, compute the required screening features "
 
146
  return 0.0
147
 
148
  # -----------------------
149
+ # Regex-based robust extractor (used for both VLM raw parsing & LLM raw parsing)
150
  # -----------------------
151
  def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
152
  """
153
+ Extract numeric fields and text fields from the first {...} block found in raw_text.
154
+ Returns a dict with:
155
+ - risk_score (0..100)
156
+ - jaundice_probability (0..1)
157
+ - anemia_probability (0..1)
158
+ - hydration_issue_probability (0..1)
159
+ - neurological_issue_probability (0..1)
160
+ - confidence (0..1)
161
+ - summary (string)
162
+ - recommendation (string)
163
  """
 
164
  match = re.search(r"\{[\s\S]*\}", raw_text)
165
  if not match:
166
+ raise ValueError("No JSON-like block found in text")
 
167
  block = match.group(0)
168
 
169
  def find_number_for_key(key: str) -> Optional[float]:
 
 
 
 
 
 
170
  patterns = [
171
+ rf'"{key}"\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?',
172
  rf"'{key}'\s*:\s*['\"]?\s*([-+]?\d+(\.\d+)?)\s*%?\s*['\"]?",
173
+ rf'\b{key}\b\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?',
174
+ rf'"{key}"\s*:\s*["\']([^"\']+)["\']',
175
  rf"'{key}'\s*:\s*['\"]([^'\"]+)['\"]"
176
  ]
177
  for pat in patterns:
 
181
  g = m.group(1)
182
  if g is None:
183
  continue
184
+ s = str(g).strip().replace("%", "").strip()
 
 
 
185
  try:
186
+ return float(s)
 
187
  except Exception:
 
188
  return None
189
  return None
190
 
191
  def find_text_for_key(key: str) -> str:
 
192
  m = re.search(rf'"{key}"\s*:\s*"([^"]*)"', block, flags=re.IGNORECASE)
193
  if m:
194
  return m.group(1).strip()
195
  m = re.search(rf"'{key}'\s*:\s*'([^']*)'", block, flags=re.IGNORECASE)
196
  if m:
197
  return m.group(1).strip()
 
198
  m = re.search(rf'\b{key}\b\s*:\s*([^\n,}}]+)', block, flags=re.IGNORECASE)
199
  if m:
200
  return m.group(1).strip().strip('",')
201
  return ""
202
 
 
203
  raw_risk = find_number_for_key("risk_score")
204
  raw_jaundice = find_number_for_key("jaundice_probability")
205
  raw_anemia = find_number_for_key("anemia_probability")
 
207
  raw_neuro = find_number_for_key("neurological_issue_probability")
208
  raw_conf = find_number_for_key("confidence")
209
 
 
 
210
  def normalize_prob(v: Optional[float]) -> float:
211
  if v is None:
212
  return 0.0
213
  if v > 1.0 and v <= 100.0:
214
  return max(0.0, min(1.0, v / 100.0))
 
215
  if v > 100.0:
216
  return 1.0
 
217
  return max(0.0, min(1.0, v))
218
 
219
  jaundice_probability = normalize_prob(raw_jaundice)
 
222
  neurological_issue_probability = normalize_prob(raw_neuro)
223
  confidence = normalize_prob(raw_conf)
224
 
 
225
  def normalize_risk(v: Optional[float]) -> float:
226
  if v is None:
227
  return 0.0
228
  if v <= 1.0:
 
229
  return round(max(0.0, min(100.0, v * 100.0)), 2)
 
230
  if v > 1.0 and v <= 100.0:
231
  return round(max(0.0, min(100.0, v)), 2)
 
232
  return round(max(0.0, min(100.0, v if v < float('inf') else 100.0)), 2)
233
 
234
  risk_score = normalize_risk(raw_risk)
 
263
  Synchronous call to remote VLM (gradio /chat_fn). Returns tuple:
264
  (parsed_features_dict_or_None, raw_text_response_str)
265
 
266
+ Robustness improvements:
267
+ - Retries a few times if raw text is empty.
268
+ - Attempts json.loads first, then extract_json_via_regex.
269
+ - Logs raw output and parsed features for debugging.
270
  """
271
  prompt = prompt or DEFAULT_VLM_PROMPT
272
  if not os.path.exists(face_path) or not os.path.exists(eye_path):
 
277
  client = get_gradio_client_for_space(GRADIO_VLM_SPACE)
278
  message = {"text": prompt, "files": [handle_file(face_path), handle_file(eye_path)]}
279
 
280
+ last_exc = None
281
+ raw_text = None
282
+ for attempt in range(1, VLM_EMPTY_RETRIES + 2): # attempts = retries+1
283
+ try:
284
+ logger.info("Calling VLM Space %s (attempt %d)", GRADIO_VLM_SPACE, attempt)
285
+ result = client.predict(message=message, history=[], api_name="/chat_fn")
286
+ except Exception as e:
287
+ logger.exception("VLM call failed on attempt %d", attempt)
288
+ last_exc = e
289
+ if attempt <= VLM_EMPTY_RETRIES:
290
+ time.sleep(VLM_EMPTY_RETRY_SLEEP_S)
291
+ continue
292
+ raise RuntimeError(f"VLM call ultimately failed: {e}")
293
+
294
+ if not result:
295
+ logger.warning("VLM returned empty result object on attempt %d", attempt)
296
+ raw_text = ""
297
+ else:
298
+ # normalize result object
299
+ if isinstance(result, (list, tuple)):
300
+ out = result[0]
301
+ elif isinstance(result, dict):
302
+ out = result
303
+ else:
304
+ out = {"text": str(result)}
305
 
306
+ text_out = out.get("text") or out.get("output") or ""
307
+ # if files key exists but text is empty, log it
308
+ if isinstance(out, dict) and (out.get("files") == [] or not out.get("files")) and (not text_out.strip()):
309
+ logger.warning("VLM returned no text AND no files in response on attempt %d: %s", attempt, str(out))
310
+ raw_text = text_out
311
 
312
+ # if raw_text is non-empty, break; otherwise retry up to retries
313
+ if raw_text and raw_text.strip():
314
+ break
315
+ else:
316
+ logger.warning("VLM returned empty text on attempt %d. Retrying (%d remaining)...", attempt, max(0, VLM_EMPTY_RETRIES - (attempt - 1)))
317
+ if attempt <= VLM_EMPTY_RETRIES:
318
+ time.sleep(VLM_EMPTY_RETRY_SLEEP_S)
319
+ continue
320
+ # no more retries
321
+ break
322
 
323
+ if raw_text is None:
324
+ raise RuntimeError(f"VLM returned no response (last error: {last_exc})")
325
 
326
+ text_out = raw_text
 
 
327
 
328
  # Log raw VLM output for debugging/auditing
329
+ logger.info("VLM raw output (length=%d):\n%s", len(text_out or ""), (text_out[:1000] + "...") if text_out and len(text_out) > 1000 else (text_out or "<EMPTY>"))
 
 
 
330
 
331
+ # Try to parse JSON first (fast path)
332
  parsed_features = None
333
  try:
334
+ parsed_features = json.loads(text_out) if text_out and text_out.strip() else None
335
+ if parsed_features is not None and not isinstance(parsed_features, dict):
336
  parsed_features = None
337
  except Exception:
338
  parsed_features = None
339
 
340
+ # If json.loads failed or returned None, try regex-based extraction
341
+ if parsed_features is None and text_out and text_out.strip():
342
  try:
343
  parsed_features = extract_json_via_regex(text_out)
344
  logger.info("VLM regex-extracted features:\n%s", json.dumps(parsed_features, indent=2, ensure_ascii=False))
345
  except Exception as e:
346
+ logger.info("VLM regex extraction failed or found nothing: %s", str(e))
 
347
  parsed_features = None
348
 
349
+ if parsed_features is None:
350
+ logger.info("VLM parsed features: None (will fallback to sending '{}' or raw string to LLM).")
 
 
 
 
351
  else:
352
+ logger.info("VLM parsed features (final): %s", json.dumps(parsed_features, ensure_ascii=False))
353
 
354
+ # Always return raw_text (may be empty string) and parsed_features (or None)
355
+ return parsed_features, (text_out or "")
356
 
357
  # -----------------------
358
  # Gradio / LLM helper (defensive, with retry + clamps)
 
366
  developer_prompt: Optional[str] = None) -> Dict[str, Any]:
367
  """
368
  Call the remote LLM Space's /chat endpoint with defensive input handling and a single retry.
369
+ - Logs the VLM raw string and the chosen payload.
370
+ - Sends cleaned JSON (json.dumps(vlm_features)) if vlm_features_or_raw is dict, else sends raw string.
371
+ - Uses regex to extract the final JSON from LLM raw output.
372
  """
373
  if not GRADIO_AVAILABLE:
374
  raise RuntimeError("gradio_client not installed. Add gradio_client to requirements.txt")
 
384
  system_prompt = system_prompt or LLM_SYSTEM_PROMPT
385
  developer_prompt = developer_prompt or LLM_DEVELOPER_PROMPT
386
 
387
+ # Decide what to send to LLM and log the raw input
388
  if isinstance(vlm_features_or_raw, str):
389
+ vlm_raw_str = vlm_features_or_raw
390
+ logger.info("LLM input will be RAW VLM STRING (len=%d)", len(vlm_raw_str or ""))
391
+ vlm_json_str_to_send = vlm_raw_str if vlm_raw_str and vlm_raw_str.strip() else "{}"
392
  else:
393
+ vlm_raw_str = json.dumps(vlm_features_or_raw, ensure_ascii=False) if vlm_features_or_raw else "{}"
394
+ logger.info("LLM input will be CLEANED VLM JSON (len=%d)", len(vlm_raw_str))
395
+ vlm_json_str_to_send = vlm_raw_str
396
 
397
+ # Build instruction payload
398
  instruction = (
399
  "\n\nSTRICT INSTRUCTIONS (READ CAREFULLY):\n"
400
  "1) OUTPUT ONLY a single valid JSON object and nothing else — no prose, no explanation, no code fences.\n"
 
405
  "If you cannot estimate a value, set it to null.\n\n"
406
  "Now, based on the VLM output below, produce ONLY the JSON object described above.\n\n"
407
  "===BEGIN VLM OUTPUT===\n"
408
+ f"{vlm_json_str_to_send}\n"
409
  "===END VLM OUTPUT===\n\n"
410
  )
 
411
 
412
  # Defensive coercion / clamps
413
  try_max_new_tokens = int(max_new_tokens) if max_new_tokens is not None else 1024
 
415
  try_max_new_tokens = 1024
416
 
417
  try_temperature = float(temperature) if temperature is not None else 0.0
418
+ # Some Spaces validate temperature >= 0.1
419
  if try_temperature < 0.1:
420
  try_temperature = 0.1
421
 
 
422
  predict_kwargs = dict(
423
+ input_data=instruction,
424
  max_new_tokens=float(try_max_new_tokens),
425
  model_identity=model_identity,
426
  system_prompt=system_prompt,
 
433
  api_name="/chat"
434
  )
435
 
 
436
  last_exc = None
437
  for attempt in (1, 2):
438
  try:
439
  logger.info("Calling LLM Space %s (attempt %d) with temperature=%s, max_new_tokens=%s",
440
  LLM_GRADIO_SPACE, attempt, predict_kwargs.get("temperature"), predict_kwargs.get("max_new_tokens"))
441
  result = client.predict(**predict_kwargs)
442
+
443
  # normalize to string
444
  if isinstance(result, (dict, list)):
445
  text_out = json.dumps(result)
446
  else:
447
  text_out = str(result)
448
+
449
  if not text_out or len(text_out.strip()) == 0:
450
  raise RuntimeError("LLM returned empty response")
451
+
452
+ logger.info("LLM raw output (len=%d):\n%s", len(text_out or ""), (text_out[:2000] + "...") if len(text_out) > 2000 else text_out)
453
 
454
  # parse with regex extractor (may raise)
455
+ parsed = None
456
+ try:
457
+ parsed = extract_json_via_regex(text_out)
458
+ except Exception:
459
+ # fallback: attempt json.loads naive
460
+ try:
461
+ parsed = json.loads(text_out)
462
+ if not isinstance(parsed, dict):
463
+ parsed = None
464
+ except Exception:
465
+ parsed = None
466
+
467
+ if parsed is None:
468
+ raise ValueError("Failed to extract JSON from LLM output")
469
 
470
  # pretty log parsed JSON
471
  try:
 
473
  except Exception:
474
  logger.info("LLM parsed JSON (raw dict): %s", str(parsed))
475
 
476
+ # defensive clamps (same as extractor expectations)
477
  def safe_prob(val):
478
  try:
479
  v = float(val)
 
512
  return parsed
513
 
514
  except AppError as app_e:
 
515
  logger.exception("LLM AppError (remote validation failed) on attempt %d: %s", attempt, str(app_e))
516
  last_exc = app_e
517
  if attempt == 1:
 
518
  predict_kwargs["temperature"] = 0.2
519
  predict_kwargs["max_new_tokens"] = float(512)
520
  logger.info("Retrying LLM call with temperature=0.2 and max_new_tokens=512")
521
  continue
522
  else:
 
523
  raise RuntimeError(f"LLM call failed (AppError): {app_e}")
524
  except Exception as e:
525
  logger.exception("LLM call failed on attempt %d: %s", attempt, str(e))
526
  last_exc = e
 
527
  if attempt == 1:
528
  predict_kwargs["temperature"] = 0.2
529
  predict_kwargs["max_new_tokens"] = float(512)
530
  continue
531
  raise RuntimeError(f"LLM call failed: {e}")
532
 
 
533
  raise RuntimeError(f"LLM call ultimately failed: {last_exc}")
534
 
535
  # -----------------------
 
558
 
559
  @app.post("/api/v1/validate-eye-photo")
560
  async def validate_eye_photo(image: UploadFile = File(...)):
 
 
 
 
561
  if mtcnn is None:
562
  raise HTTPException(status_code=500, detail="No face detector available in this deployment.")
 
563
  try:
564
  content = await image.read()
565
  if not content:
 
567
  pil_img = load_image_from_bytes(content)
568
  img_arr = np.asarray(pil_img) # RGB
569
 
 
570
  if not isinstance(mtcnn, dict) and _MTCNN_IMPL == "facenet_pytorch":
571
  try:
572
  boxes, probs, landmarks = mtcnn.detect(pil_img, landmarks=True)
 
591
  traceback.print_exc()
592
  raise HTTPException(status_code=500, detail="Face detector failed during inference.")
593
 
 
594
  if not isinstance(mtcnn, dict) and _MTCNN_IMPL == "mtcnn":
595
  try:
596
  detections = mtcnn.detect_faces(img_arr)
 
612
  "message_hindi": "फोटो अच्छी है! आंखें ठीक से खुली हैं।" if is_valid else "आंखें बंद या आंशिक रूप से बंद दिखाई दे रही हैं। कृपया अपनी आंखें चौड़ी खोलें और पुनः प्रयास करें।",
613
  "eye_landmarks": {"left_eye": left_eye, "right_eye": right_eye}}
614
 
 
615
  if isinstance(mtcnn, dict) and mtcnn.get("impl") == "opencv":
616
  try:
617
  gray = cv2.cvtColor(img_arr, cv2.COLOR_RGB2GRAY)
 
713
  async def get_results(screening_id: str):
714
  if screening_id not in screenings_db:
715
  raise HTTPException(status_code=404, detail="Screening not found")
716
+ # Ensure vlm_raw is always present in ai_results for debugging
717
+ entry = screenings_db[screening_id]
718
+ entry.setdefault("ai_results", {})
719
+ entry["ai_results"].setdefault("vlm_raw", entry.get("ai_results", {}).get("vlm_raw", ""))
720
+ return entry
721
 
722
  @app.get("/api/v1/history/{user_id}")
723
  async def get_history(user_id: str):
 
734
  ):
735
  """
736
  Run VLM -> LLM pipeline synchronously (but off the event loop) and return:
737
+ { vlm_parsed_features, vlm_raw_output, llm_structured_risk }
738
  """
739
  if not GRADIO_AVAILABLE:
740
  raise HTTPException(status_code=500, detail="VLM/LLM client not available in this deployment.")
 
760
  # Run VLM (off the event loop)
761
  vlm_features, vlm_raw = await asyncio.to_thread(run_vlm_and_get_features, face_path, eye_path)
762
 
763
+ # Log VLM outputs (already logged inside run_vlm..., but additional context)
764
+ logger.info("get_vitals_from_upload - VLM raw (snippet): %s", (vlm_raw[:500] + "...") if vlm_raw else "<EMPTY>")
765
+ logger.info("get_vitals_from_upload - VLM parsed features: %s", json.dumps(vlm_features, indent=2, ensure_ascii=False) if vlm_features else "None")
766
 
767
+ # Decide what to feed to LLM: prefer cleaned JSON if available, else raw VLM string
768
+ if vlm_features:
769
+ llm_input = json.dumps(vlm_features, ensure_ascii=False)
770
+ logger.info("Feeding CLEANED VLM JSON to LLM (len=%d).", len(llm_input))
771
+ else:
772
+ llm_input = vlm_raw if vlm_raw and vlm_raw.strip() else "{}"
773
+ logger.info("Feeding RAW VLM STRING to LLM (len=%d).", len(llm_input))
774
 
775
  # Run LLM (off the event loop)
776
  structured_risk = await asyncio.to_thread(run_llm_on_vlm, llm_input)
777
 
778
+ # Return merged result (includes raw VLM output for debugging)
779
  return {
780
+ "vlm_raw_output": vlm_raw,
781
+ "vlm_parsed_features": vlm_features,
782
+ "llm_structured_risk": structured_risk
783
  }
784
  except Exception as e:
785
  logger.exception("get_vitals_from_upload pipeline failed")
 
804
  # Run VLM off the event loop
805
  vlm_features, vlm_raw = await asyncio.to_thread(run_vlm_and_get_features, face_path, eye_path)
806
 
807
+ logger.info("get_vitals_for_screening(%s) - VLM raw (snippet): %s", screening_id, (vlm_raw[:500] + "...") if vlm_raw else "<EMPTY>")
808
+ logger.info("get_vitals_for_screening(%s) - VLM parsed features: %s", screening_id, json.dumps(vlm_features, indent=2, ensure_ascii=False) if vlm_features else "None")
809
+
810
+ if vlm_features:
811
+ llm_input = json.dumps(vlm_features, ensure_ascii=False)
812
+ logger.info("Feeding CLEANED VLM JSON to LLM (len=%d).", len(llm_input))
813
+ else:
814
+ llm_input = vlm_raw if vlm_raw and vlm_raw.strip() else "{}"
815
+ logger.info("Feeding RAW VLM STRING to LLM (len=%d).", len(llm_input))
816
 
 
817
  structured_risk = await asyncio.to_thread(run_llm_on_vlm, llm_input)
818
 
819
  # Optionally store this run's outputs back into the DB for inspection
820
  entry.setdefault("ai_results", {})
821
  entry["ai_results"].update({
822
+ "vlm_parsed_features": vlm_features,
823
  "vlm_raw": vlm_raw,
824
  "structured_risk": structured_risk,
825
  "last_vitals_run": datetime.utcnow().isoformat() + "Z"
 
827
 
828
  return {
829
  "screening_id": screening_id,
830
+ "vlm_raw_output": vlm_raw,
831
+ "vlm_parsed_features": vlm_features,
832
+ "llm_structured_risk": structured_risk
833
  }
834
  except Exception as e:
835
  logger.exception("get_vitals_for_screening pipeline failed")
836
  raise HTTPException(status_code=500, detail=f"Pipeline failed: {e}")
837
 
838
  # -----------------------
839
+ # Main background pipeline (upload -> process_screening)
840
  # -----------------------
841
  async def process_screening(screening_id: str):
842
  """
 
844
  - load images
845
  - quick detector-based quality metrics
846
  - run VLM -> vlm_features (dict or None) + vlm_raw (string)
847
+ - run LLM on vlm_features (preferred) or vlm_raw -> structured risk JSON
848
  - merge results into ai_results and finish
849
  """
850
  try:
 
934
  vlm_features, vlm_raw = run_vlm_and_get_features(face_path, eye_path)
935
  screenings_db[screening_id].setdefault("ai_results", {})
936
  screenings_db[screening_id]["ai_results"].update({
937
+ "vlm_parsed_features": vlm_features,
938
  "vlm_raw": vlm_raw
939
  })
940
  except Exception as e:
 
942
  screenings_db[screening_id].setdefault("ai_results", {})
943
  screenings_db[screening_id]["ai_results"].update({"vlm_error": str(e)})
944
  vlm_features = None
945
+ vlm_raw = ""
946
 
947
  # Log VLM outputs in pipeline context
948
+ logger.info("process_screening(%s) - VLM raw (snippet): %s", screening_id, (vlm_raw[:500] + "...") if vlm_raw else "<EMPTY>")
949
+ logger.info("process_screening(%s) - VLM parsed features: %s", screening_id, json.dumps(vlm_features, indent=2, ensure_ascii=False) if vlm_features else "None")
950
 
951
  # --------------------------
952
+ # RUN LLM on vlm_parsed (preferred) or vlm_raw -> structured risk JSON
953
  # --------------------------
954
  structured_risk = None
955
  try:
956
+ if vlm_features:
957
+ # prefer cleaned JSON
958
+ llm_input = json.dumps(vlm_features, ensure_ascii=False)
 
959
  else:
960
+ # fallback to raw string (may be empty)
961
+ llm_input = vlm_raw if vlm_raw and vlm_raw.strip() else "{}"
962
+
963
+ structured_risk = run_llm_on_vlm(llm_input)
 
 
 
 
 
 
 
964
  screenings_db[screening_id].setdefault("ai_results", {})
965
  screenings_db[screening_id]["ai_results"].update({"structured_risk": structured_risk})
966
  except Exception as e:
 
979
  }
980
 
981
  # Use structured_risk for summary recommendations & simple disease inference placeholders
 
 
 
 
982
  screenings_db[screening_id].setdefault("ai_results", {})
983
  screenings_db[screening_id]["ai_results"].update({
984
  "processing_time_ms": 1200
985
  })
986
 
 
987
  disease_predictions = [
988
  {
989
+ "condition": "Anemia-like-signs",
990
  "risk_level": "Medium" if structured_risk.get("anemia_probability", 0.0) > 0.5 else "Low",
991
  "probability": structured_risk.get("anemia_probability", 0.0),
992
  "confidence": structured_risk.get("confidence", 0.0)
 
1002
  recommendations = {
1003
  "action_needed": "consult" if structured_risk.get("risk_score", 0.0) > 30.0 else "monitor",
1004
  "message_english": structured_risk.get("recommendation", "") or f"Please follow up with a health professional if concerns persist.",
1005
+ "message_hindi": ""
1006
  }
1007
 
1008
  screenings_db[screening_id].update({