Update app.py
Browse files
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 (
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
#
|
| 289 |
-
parsed = None
|
| 290 |
try:
|
| 291 |
-
parsed =
|
| 292 |
-
except Exception:
|
| 293 |
-
|
|
|
|
| 294 |
try:
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 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 |
-
#
|
| 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
|
| 321 |
|
| 322 |
-
|
| 323 |
"jaundice_probability",
|
| 324 |
"anemia_probability",
|
| 325 |
"hydration_issue_probability",
|
| 326 |
-
"neurological_issue_probability"
|
| 327 |
-
]
|
| 328 |
-
|
| 329 |
-
if k in parsed:
|
| 330 |
-
parsed[k] = safe_prob(parsed[k])
|
| 331 |
-
else:
|
| 332 |
-
parsed[k] = None
|
| 333 |
|
| 334 |
-
# risk_score
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 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 |
-
#
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 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 |
-
#
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|