dpv007 commited on
Commit
eea760c
·
verified ·
1 Parent(s): 141b1f3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +166 -59
app.py CHANGED
@@ -6,9 +6,13 @@ Pipeline:
6
  - run VLM (remote gradio / chat_fn) -> JSON feature vector + raw text
7
  - run LLM (remote gradio /chat) -> structured risk JSON (per requested schema)
8
  - continue rest of processing and store results
 
9
  Notes:
10
  - Add gradio_client==1.13.2 (or another compatible 1.x) to requirements.txt
11
  - If VLM/LLM Spaces are private, set HF_TOKEN in the environment for authentication.
 
 
 
12
  """
13
 
14
  import io
@@ -18,6 +22,7 @@ import json
18
  import asyncio
19
  import logging
20
  import traceback
 
21
  from typing import Dict, Any, Optional, Tuple
22
  from datetime import datetime
23
 
@@ -132,6 +137,127 @@ def estimate_eye_openness_from_detection(confidence: float) -> float:
132
  except Exception:
133
  return 0.0
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  # -----------------------
136
  # Gradio / VLM helper (returns parsed dict OR None, plus raw text)
137
  # -----------------------
@@ -208,7 +334,7 @@ def run_vlm_and_get_features(face_path: str, eye_path: str, prompt: Optional[str
208
  return parsed_features, text_out
209
 
210
  # -----------------------
211
- # Gradio / LLM helper (always prompts with VLM output + strict instruction)
212
  # -----------------------
213
  def run_llm_on_vlm(vlm_features_or_raw: Any,
214
  max_new_tokens: int = 1024,
@@ -219,11 +345,12 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
219
  developer_prompt: Optional[str] = None) -> Dict[str, Any]:
220
  """
221
  Call the remote LLM Space's /chat endpoint.
222
- Accepts **either**:
223
  - a dict (parsed VLM features) -> will be JSON-dumped (backwards compatible)
224
  - a raw string (the exact VLM text output) -> will be forwarded AS-IS (no extra JSON quoting)
225
 
226
- Enforces a strict instruction to OUTPUT ONLY a single valid JSON object.
 
227
  """
228
  if not GRADIO_AVAILABLE:
229
  raise RuntimeError("gradio_client not installed. Add gradio_client to requirements.txt")
@@ -285,80 +412,60 @@ def run_llm_on_vlm(vlm_features_or_raw: Any,
285
  if not text_out or len(text_out.strip()) == 0:
286
  raise RuntimeError("LLM returned empty response")
287
 
288
- # Try strict parse first (expecting exactly one JSON object)
289
- parsed = None
290
  try:
291
- parsed = json.loads(text_out)
292
- except Exception:
293
- # Forgiving extraction: find first {...} block and parse it
 
294
  try:
295
- s = text_out
296
- first = s.find("{")
297
- last = s.rfind("}")
298
- if first != -1 and last != -1 and last > first:
299
- maybe = s[first:last+1]
300
- parsed = json.loads(maybe)
301
- else:
302
- raise ValueError("No JSON object found in LLM output")
303
- except Exception as e:
304
- logging.exception("Failed to parse JSON from LLM output")
305
- raise ValueError(f"Failed to parse JSON from LLM output: {e}\nRaw output: {text_out}")
306
 
307
  if not isinstance(parsed, dict):
308
  raise ValueError("Parsed LLM output is not a JSON object/dict")
309
 
310
- # Validate and coerce expected probability fields to floats between 0..1 and risk_score 0..100
311
  def safe_prob(val):
312
  try:
313
  v = float(val)
314
- if v > 1:
315
- # if model returned 0-100 percentage, convert
316
- if v <= 100:
317
- v = v / 100.0
318
  return max(0.0, min(1.0, v))
319
  except Exception:
320
- return None
321
 
322
- expected_prob_keys = [
323
  "jaundice_probability",
324
  "anemia_probability",
325
  "hydration_issue_probability",
326
- "neurological_issue_probability",
327
- ]
328
- for k in expected_prob_keys:
329
- if k in parsed:
330
- parsed[k] = safe_prob(parsed[k])
331
- else:
332
- parsed[k] = None
333
 
334
- # risk_score: coerce to 0..100
335
- if "risk_score" in parsed:
336
- try:
337
- rs = float(parsed["risk_score"])
338
- if rs <= 1:
339
- rs = rs * 100.0
340
- parsed["risk_score"] = round(max(0.0, min(100.0, rs)), 2)
341
- except Exception:
342
- parsed["risk_score"] = None
343
- else:
344
- probs = [p for p in (parsed.get(k) for k in expected_prob_keys) if isinstance(p, (int, float))]
345
- parsed["risk_score"] = round((sum(probs) / len(probs) * 100.0) if probs else 0.0, 2)
346
 
347
- # Ensure confidence exists and is 0..1
348
- if "confidence" in parsed:
349
- try:
350
- c = float(parsed["confidence"])
351
- if c > 1 and c <= 100:
352
- c = c / 100.0
353
- parsed["confidence"] = max(0.0, min(1.0, c))
354
- except Exception:
355
- parsed["confidence"] = None
356
- else:
357
- parsed["confidence"] = None
358
 
359
- # summary and recommendation must be strings (neutral wording)
360
- parsed["summary"] = str(parsed.get("summary", "")).strip()
361
- parsed["recommendation"] = str(parsed.get("recommendation", "")).strip()
 
 
 
 
 
 
 
362
 
363
  return parsed
364
 
 
6
  - run VLM (remote gradio / chat_fn) -> JSON feature vector + raw text
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 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
  """
17
 
18
  import io
 
22
  import asyncio
23
  import logging
24
  import traceback
25
+ import re
26
  from typing import Dict, Any, Optional, Tuple
27
  from datetime import datetime
28
 
 
137
  except Exception:
138
  return 0.0
139
 
140
+ # -----------------------
141
+ # Regex-based robust extractor
142
+ # -----------------------
143
+ def extract_json_via_regex(raw_text: str) -> Dict[str, Any]:
144
+ """
145
+ 1) Finds the outermost { ... } block in raw_text.
146
+ 2) Extracts numeric values after the listed keys using regex, tolerating:
147
+ - quotes, spaces, percent signs, percent numbers like "55%", strings like "0.12", integers, or numbers in quotes.
148
+ 3) Returns a dict with numeric fields GUARANTEED to be floats (no None/NaN), and string fields for summary/recommendation.
149
+ """
150
+ # Find the first {...} block (outermost approximation)
151
+ match = re.search(r"\{[\s\S]*\}", raw_text)
152
+ if not match:
153
+ raise ValueError("No JSON-like block found in LLM output")
154
+
155
+ block = match.group(0)
156
+
157
+ def find_number_for_key(key: str) -> Optional[float]:
158
+ """
159
+ Returns a float in range 0..1 for probabilities, and raw numeric for other keys depending on usage.
160
+ This helper returns None if not found; caller will replace with defaults (0.0).
161
+ """
162
+ # Try multiple patterns to be robust
163
+ # Pattern captures numbers possibly with % and optional quotes, e.g. "45%", '0.12', 0.5, " 87 "
164
+ patterns = [
165
+ rf'"{key}"\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?', # "key": "45%" or "key": 0.45
166
+ rf"'{key}'\s*:\s*['\"]?\s*([-+]?\d+(\.\d+)?)\s*%?\s*['\"]?",
167
+ rf'\b{key}\b\s*:\s*["\']?\s*([-+]?\d+(\.\d+)?)\s*%?\s*["\']?', # key: 45%
168
+ rf'"{key}"\s*:\s*["\']([^"\']+)["\']', # capture quoted text (for non-numeric attempts)
169
+ rf"'{key}'\s*:\s*['\"]([^'\"]+)['\"]"
170
+ ]
171
+ for pat in patterns:
172
+ m = re.search(pat, block, flags=re.IGNORECASE)
173
+ if not m:
174
+ continue
175
+ g = m.group(1)
176
+ if g is None:
177
+ continue
178
+ s = str(g).strip()
179
+ # Remove percent sign if present
180
+ s = s.replace("%", "").strip()
181
+ # Try to coerce to float
182
+ try:
183
+ val = float(s)
184
+ return val
185
+ except Exception:
186
+ # not numeric
187
+ return None
188
+ return None
189
+
190
+ def find_text_for_key(key: str) -> str:
191
+ # capture "key": "some text" allowing single/double quotes and also unquoted until comma/}
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
+ # fallback: key: some text (unquoted) up to comma or }
199
+ m = re.search(rf'\b{key}\b\s*:\s*([^\n,}}]+)', block, flags=re.IGNORECASE)
200
+ if m:
201
+ return m.group(1).strip().strip('",')
202
+ return ""
203
+
204
+ # Extract raw numeric candidates
205
+ raw_risk = find_number_for_key("risk_score")
206
+ raw_jaundice = find_number_for_key("jaundice_probability")
207
+ raw_anemia = find_number_for_key("anemia_probability")
208
+ raw_hydration = find_number_for_key("hydration_issue_probability")
209
+ raw_neuro = find_number_for_key("neurological_issue_probability")
210
+ raw_conf = find_number_for_key("confidence")
211
+
212
+ # Normalize:
213
+ # - For probabilities: if value > 1 and <=100 => treat as percent -> divide by 100. If <=1 treat as fraction.
214
+ def normalize_prob(v: Optional[float]) -> float:
215
+ if v is None:
216
+ return 0.0
217
+ if v > 1.0 and v <= 100.0:
218
+ return max(0.0, min(1.0, v / 100.0))
219
+ # if v is large >100, clamp to 1.0
220
+ if v > 100.0:
221
+ return 1.0
222
+ # otherwise assume already 0..1
223
+ return max(0.0, min(1.0, v))
224
+
225
+ jaundice_probability = normalize_prob(raw_jaundice)
226
+ anemia_probability = normalize_prob(raw_anemia)
227
+ hydration_issue_probability = normalize_prob(raw_hydration)
228
+ neurological_issue_probability = normalize_prob(raw_neuro)
229
+ confidence = normalize_prob(raw_conf)
230
+
231
+ # risk_score: return in 0..100
232
+ def normalize_risk(v: Optional[float]) -> float:
233
+ if v is None:
234
+ return 0.0
235
+ if v <= 1.0:
236
+ # fraction given -> scale to 0..100
237
+ return round(max(0.0, min(100.0, v * 100.0)), 2)
238
+ # if between 1 and 100, assume it's already 0..100
239
+ if v > 1.0 and v <= 100.0:
240
+ return round(max(0.0, min(100.0, v)), 2)
241
+ # clamp anything insane
242
+ return round(max(0.0, min(100.0, v if v < float('inf') else 100.0)), 2)
243
+
244
+ risk_score = normalize_risk(raw_risk)
245
+
246
+ summary = find_text_for_key("summary")
247
+ recommendation = find_text_for_key("recommendation")
248
+
249
+ out = {
250
+ "risk_score": risk_score,
251
+ "jaundice_probability": round(jaundice_probability, 4),
252
+ "anemia_probability": round(anemia_probability, 4),
253
+ "hydration_issue_probability": round(hydration_issue_probability, 4),
254
+ "neurological_issue_probability": round(neurological_issue_probability, 4),
255
+ "confidence": round(confidence, 4),
256
+ "summary": summary,
257
+ "recommendation": recommendation
258
+ }
259
+ return out
260
+
261
  # -----------------------
262
  # Gradio / VLM helper (returns parsed dict OR None, plus raw text)
263
  # -----------------------
 
334
  return parsed_features, text_out
335
 
336
  # -----------------------
337
+ # Gradio / LLM helper (uses regex extractor on LLM output)
338
  # -----------------------
339
  def run_llm_on_vlm(vlm_features_or_raw: Any,
340
  max_new_tokens: int = 1024,
 
345
  developer_prompt: Optional[str] = None) -> Dict[str, Any]:
346
  """
347
  Call the remote LLM Space's /chat endpoint.
348
+ Accepts either:
349
  - a dict (parsed VLM features) -> will be JSON-dumped (backwards compatible)
350
  - a raw string (the exact VLM text output) -> will be forwarded AS-IS (no extra JSON quoting)
351
 
352
+ After the LLM returns, we use a regex-based extractor to pull numeric values and strings,
353
+ reconstruct a clean JSON dict with numeric defaults (no NaN).
354
  """
355
  if not GRADIO_AVAILABLE:
356
  raise RuntimeError("gradio_client not installed. Add gradio_client to requirements.txt")
 
412
  if not text_out or len(text_out.strip()) == 0:
413
  raise RuntimeError("LLM returned empty response")
414
 
415
+ # Use regex-based extraction (robust)
 
416
  try:
417
+ parsed = extract_json_via_regex(text_out)
418
+ except Exception as e:
419
+ logging.exception("Regex JSON extraction failed")
420
+ # As a last fallback, attempt naive JSON parsing; if that fails, raise with raw output
421
  try:
422
+ parsed = json.loads(text_out)
423
+ except Exception:
424
+ raise ValueError(f"Failed to extract JSON from LLM output: {e}\nRaw Output:\n{text_out}")
 
 
 
 
 
 
 
 
425
 
426
  if not isinstance(parsed, dict):
427
  raise ValueError("Parsed LLM output is not a JSON object/dict")
428
 
429
+ # Final safety clamps (already ensured by extractor, but keep defensive checks)
430
  def safe_prob(val):
431
  try:
432
  v = float(val)
 
 
 
 
433
  return max(0.0, min(1.0, v))
434
  except Exception:
435
+ return 0.0
436
 
437
+ for k in [
438
  "jaundice_probability",
439
  "anemia_probability",
440
  "hydration_issue_probability",
441
+ "neurological_issue_probability"
442
+ ]:
443
+ parsed[k] = safe_prob(parsed.get(k, 0.0))
 
 
 
 
444
 
445
+ # risk_score clamp 0..100
446
+ try:
447
+ rs = float(parsed.get("risk_score", 0.0))
448
+ parsed["risk_score"] = round(max(0.0, min(100.0, rs)), 2)
449
+ except Exception:
450
+ parsed["risk_score"] = 0.0
 
 
 
 
 
 
451
 
452
+ # confidence clamp 0..1
453
+ parsed["confidence"] = safe_prob(parsed.get("confidence", 0.0))
454
+
455
+ # Ensure summary/recommendation are strings
456
+ parsed["summary"] = str(parsed.get("summary", "") or "").strip()
457
+ parsed["recommendation"] = str(parsed.get("recommendation", "") or "").strip()
 
 
 
 
 
458
 
459
+ # Optional: add flags indicating missing values (useful for frontend)
460
+ for k in [
461
+ "jaundice_probability",
462
+ "anemia_probability",
463
+ "hydration_issue_probability",
464
+ "neurological_issue_probability",
465
+ "confidence",
466
+ "risk_score"
467
+ ]:
468
+ parsed[f"{k}_was_missing"] = False # extractor already returned defaults; mark as False
469
 
470
  return parsed
471