3v324v23 commited on
Commit
68bc1c7
·
1 Parent(s): 24cc0b8

Update backend for account workflows and calibration

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +4 -17
  2. README.md +1 -1
  3. backend/.env.example +61 -0
  4. backend/app/api/history.py +36 -0
  5. backend/app/config.py +26 -15
  6. backend/app/database.py +6 -1
  7. backend/app/main.py +157 -165
  8. backend/app/ml/efficientnet_model.py +25 -45
  9. backend/app/ml/lightweight_model.py +1 -1
  10. backend/app/ml/runtime_calibration.py +50 -0
  11. backend/app/ml/runtime_refinement.py +111 -0
  12. backend/app/ml/runtime_stack.py +5 -5
  13. backend/app/schemas.py +185 -3
  14. backend/app/services/clinical_brief.py +2 -2
  15. backend/app/services/email_report.py +243 -55
  16. backend/app/services/guidance.py +101 -31
  17. backend/app/services/image_quality.py +159 -6
  18. backend/app/services/patient_case.py +254 -0
  19. backend/app/services/prediction.py +822 -310
  20. backend/app/services/request_parsing.py +19 -1
  21. backend/app/services/runtime_status.py +33 -0
  22. backend/app/services/screening_store.py +56 -0
  23. backend/app/utils/security.py +5 -0
  24. backend/models/deployed_screening_report.json +7 -7
  25. backend/models/runtime_calibration_report.json +28 -0
  26. backend/models/runtime_refinement_report.json +24 -0
  27. backend/models/runtime_risk_calibrator.pkl +3 -0
  28. backend/models/runtime_screening_refiner.pkl +3 -0
  29. backend/scripts/analyze_efficientnet_errors.py +204 -0
  30. backend/scripts/eval_pipeline.py +80 -0
  31. backend/scripts/eval_real.py +79 -0
  32. backend/scripts/evaluate_deployed_screening.py +89 -0
  33. backend/scripts/evaluate_runtime_stack.py +184 -0
  34. backend/scripts/fit_runtime_risk_calibrator.py +225 -0
  35. backend/scripts/fit_runtime_screening_refiner.py +204 -0
  36. backend/scripts/proof_metrics.py +181 -0
  37. backend/scripts/quick_eval.py +109 -0
  38. backend/scripts/retrain_fast.py +230 -0
  39. backend/scripts/retrain_pipeline_aligned.py +302 -0
  40. backend/scripts/test_endpoint.py +36 -0
  41. backend/scripts/test_model.py +45 -0
  42. backend/scripts/train_archive_model.py +119 -0
  43. backend/scripts/train_efficientnet.py +465 -0
  44. backend/scripts/train_ensemble.py +49 -0
  45. backend/scripts/train_stacked.py +617 -0
  46. backend/start_server.py +19 -0
  47. backend/tests/test_case_insight.py +182 -0
  48. backend/tests/test_clinical_brief.py +195 -0
  49. backend/tests/test_decision_audit.py +89 -0
  50. backend/tests/test_email_report.py +414 -0
Dockerfile CHANGED
@@ -7,26 +7,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
7
  libglib2.0-0 libsm6 libxext6 libxrender-dev libgl1 \
8
  && rm -rf /var/lib/apt/lists/*
9
 
10
- # Copy and install Python deps first (layer cache)
11
  COPY backend/requirements.txt .
12
- RUN pip install --no-cache-dir -r requirements.txt
13
 
14
  # Copy backend source
15
  COPY backend/ .
16
 
17
- # Lock PyTorch to single thread for memory-constrained deployments
18
- ENV OMP_NUM_THREADS=1
19
- ENV MKL_NUM_THREADS=1
20
- ENV PYTHONUNBUFFERED=1
21
 
22
- EXPOSE 7860
23
-
24
- # Production: uvicorn with FastAPI (single worker — memory constrained)
25
- CMD ["uvicorn", "app.main:app", \
26
- "--host", "0.0.0.0", \
27
- "--port", "7860", \
28
- "--workers", "1", \
29
- "--timeout-keep-alive", "30", \
30
- "--limit-concurrency", "4", \
31
- "--proxy-headers", \
32
- "--forwarded-allow-ips=*"]
 
7
  libglib2.0-0 libsm6 libxext6 libxrender-dev libgl1 \
8
  && rm -rf /var/lib/apt/lists/*
9
 
10
+ # Copy and install backend runtime deps first.
11
  COPY backend/requirements.txt .
12
+ RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
13
 
14
  # Copy backend source
15
  COPY backend/ .
16
 
17
+ EXPOSE 5000
 
 
 
18
 
19
+ CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🩸
4
  colorFrom: red
5
  colorTo: gray
6
  sdk: docker
7
- app_port: 7860
8
  base_path: /docs
9
  pinned: false
10
  license: mit
 
4
  colorFrom: red
5
  colorTo: gray
6
  sdk: docker
7
+ app_port: 5000
8
  base_path: /docs
9
  pinned: false
10
  license: mit
backend/.env.example ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AnemiaLens backend environment variables
2
+ # Copy this file to .env and fill in the secrets.
3
+ # NEVER commit .env to version control.
4
+
5
+ # -- Mistral AI guidance --
6
+ ANEMIALENS_MISTRAL_API_KEY=
7
+ ANEMIALENS_MISTRAL_ENABLED=true
8
+ ANEMIALENS_MISTRAL_MODEL=mistral-small-latest
9
+ ANEMIALENS_GUIDANCE_TIMEOUT=20
10
+
11
+ # -- Server --
12
+ ANEMIALENS_LOG_LEVEL=INFO
13
+ ANEMIALENS_CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
14
+
15
+ # -- Database --
16
+ DATABASE_URL=sqlite+aiosqlite:///./anemialens.db
17
+
18
+ # -- Auth (JWT) --
19
+ JWT_SECRET_KEY=change-me-to-a-random-64-char-string
20
+ JWT_ALGORITHM=HS256
21
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
22
+ JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
23
+
24
+ # -- Rate limiting --
25
+ ANEMIALENS_RATE_LIMIT_ANALYZE=10
26
+ ANEMIALENS_RATE_LIMIT_QUALITY=30
27
+
28
+ # -- Hosted email reports (recommended on Hugging Face Spaces) --
29
+ # Gmail API works over HTTPS and is the current hosted delivery path.
30
+ ANEMIALENS_EMAIL_PROVIDER=gmail_api
31
+ ANEMIALENS_GMAIL_CLIENT_ID=
32
+ ANEMIALENS_GMAIL_CLIENT_SECRET=
33
+ ANEMIALENS_GMAIL_REFRESH_TOKEN=
34
+ ANEMIALENS_EMAIL_FROM_NAME=AnemiaLens
35
+ ANEMIALENS_EMAIL_FROM_EMAIL=
36
+ ANEMIALENS_EMAIL_REPLY_TO=
37
+
38
+ # -- SMTP fallback (local or non-restricted hosts) --
39
+ # ANEMIALENS_EMAIL_PROVIDER=smtp
40
+ # ANEMIALENS_SMTP_HOST=smtp.gmail.com
41
+ # ANEMIALENS_SMTP_PORT=465
42
+ # ANEMIALENS_SMTP_USE_SSL=true
43
+ # ANEMIALENS_SMTP_USE_STARTTLS=false
44
+ # ANEMIALENS_SMTP_USERNAME=your.gmail@gmail.com
45
+ # ANEMIALENS_SMTP_PASSWORD=your-16-char-app-password
46
+ # ANEMIALENS_SMTP_TIMEOUT=20
47
+
48
+ # -- HTTP API alternatives --
49
+ # Resend:
50
+ # ANEMIALENS_EMAIL_PROVIDER=resend
51
+ # ANEMIALENS_RESEND_API_KEY=
52
+ # ANEMIALENS_EMAIL_FROM_NAME=AnemiaLens
53
+ # ANEMIALENS_EMAIL_FROM_EMAIL=onboarding@resend.dev
54
+ # ANEMIALENS_EMAIL_REPLY_TO=your@email.com
55
+ #
56
+ # SendGrid:
57
+ # ANEMIALENS_EMAIL_PROVIDER=sendgrid
58
+ # ANEMIALENS_SENDGRID_API_KEY=
59
+ # ANEMIALENS_EMAIL_FROM_NAME=AnemiaLens
60
+ # ANEMIALENS_EMAIL_FROM_EMAIL=your_verified_sender@gmail.com
61
+ # ANEMIALENS_EMAIL_REPLY_TO=your_verified_sender@gmail.com
backend/app/api/history.py CHANGED
@@ -17,6 +17,8 @@ from app.database import get_db
17
  from app.dependencies import get_current_user
18
  from app.models.screening import Screening
19
  from app.models.user import User
 
 
20
 
21
  log = logging.getLogger("anemialens.history")
22
 
@@ -67,6 +69,16 @@ class DeleteResponse(BaseModel):
67
  uid: str
68
 
69
 
 
 
 
 
 
 
 
 
 
 
70
  # ---------------------------------------------------------------------------
71
  # Routes
72
  # ---------------------------------------------------------------------------
@@ -238,6 +250,30 @@ async def delete_screening(
238
  return DeleteResponse(deleted=True, uid=screening_uid)
239
 
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  # ---------------------------------------------------------------------------
242
  # CSV Export (Pro only)
243
  # ---------------------------------------------------------------------------
 
17
  from app.dependencies import get_current_user
18
  from app.models.screening import Screening
19
  from app.models.user import User
20
+ from app.schemas import AnalyzeResponse
21
+ from app.services.screening_store import persist_screening_result
22
 
23
  log = logging.getLogger("anemialens.history")
24
 
 
69
  uid: str
70
 
71
 
72
+ class SaveScreeningRequest(BaseModel):
73
+ analysis: AnalyzeResponse
74
+
75
+
76
+ class SaveScreeningResponse(BaseModel):
77
+ saved: bool
78
+ uid: str
79
+ message: str
80
+
81
+
82
  # ---------------------------------------------------------------------------
83
  # Routes
84
  # ---------------------------------------------------------------------------
 
250
  return DeleteResponse(deleted=True, uid=screening_uid)
251
 
252
 
253
+ @router.post(
254
+ "/save-current",
255
+ response_model=SaveScreeningResponse,
256
+ summary="Save the current screening result to the authenticated account",
257
+ )
258
+ async def save_current_screening(
259
+ body: SaveScreeningRequest,
260
+ user: Annotated[User, Depends(get_current_user)],
261
+ ) -> SaveScreeningResponse:
262
+ analysis = body.analysis
263
+ screening = await persist_screening_result(
264
+ request_id=analysis.analysis_meta.request_id,
265
+ analysis=analysis,
266
+ user_id=user.id,
267
+ processing_time_ms=analysis.analysis_meta.processing_time_ms,
268
+ )
269
+ log.info("Screening saved to account: %s by user %s", screening.uid, user.uid)
270
+ return SaveScreeningResponse(
271
+ saved=True,
272
+ uid=screening.uid,
273
+ message="Screening saved to your account history.",
274
+ )
275
+
276
+
277
  # ---------------------------------------------------------------------------
278
  # CSV Export (Pro only)
279
  # ---------------------------------------------------------------------------
backend/app/config.py CHANGED
@@ -30,10 +30,14 @@ DEFAULT_ENSEMBLE_PATH = MODELS_DIR / "ensemble_model.json"
30
  DEFAULT_DEEP_STACK_PATH = MODELS_DIR / "deep_stack_model.joblib"
31
  DEFAULT_ARCHIVE_MODEL_PATH = MODELS_DIR / "archive_screening_model.joblib"
32
  DEFAULT_EFFICIENTNET_MODEL_PATH = MODELS_DIR / "efficientnet_anemia.pth"
33
- DEFAULT_EFFICIENTNET_REPORT_PATH = MODELS_DIR / "efficientnet_report.json"
34
- DEFAULT_RUNTIME_STACK_REPORT_PATH = MODELS_DIR / "runtime_stack_report.json"
35
- DEFAULT_DEPLOYED_SCREENING_REPORT_PATH = MODELS_DIR / "deployed_screening_report.json"
36
- DEFAULT_TRAINING_REPORT_PATH = MODELS_DIR / "training_report.json"
 
 
 
 
37
 
38
 
39
  # ---------------------------------------------------------------------------
@@ -83,17 +87,24 @@ class Settings(BaseSettings):
83
  "PRELOAD_MODELS_ON_STARTUP",
84
  ),
85
  )
86
- warmup_models_on_startup: bool = Field(
87
- default=False,
88
- validation_alias=AliasChoices(
89
- "ANEMIALENS_WARMUP_MODELS_ON_STARTUP",
90
- "WARMUP_MODELS_ON_STARTUP",
91
- ),
92
- )
93
- cors_origins: list[str] = Field(
94
- default=[
95
- "http://localhost:5173",
96
- "http://127.0.0.1:5173",
 
 
 
 
 
 
 
97
  "http://localhost:5174",
98
  "http://127.0.0.1:5174",
99
  ]
 
30
  DEFAULT_DEEP_STACK_PATH = MODELS_DIR / "deep_stack_model.joblib"
31
  DEFAULT_ARCHIVE_MODEL_PATH = MODELS_DIR / "archive_screening_model.joblib"
32
  DEFAULT_EFFICIENTNET_MODEL_PATH = MODELS_DIR / "efficientnet_anemia.pth"
33
+ DEFAULT_EFFICIENTNET_REPORT_PATH = MODELS_DIR / "efficientnet_report.json"
34
+ DEFAULT_RUNTIME_STACK_REPORT_PATH = MODELS_DIR / "runtime_stack_report.json"
35
+ DEFAULT_RUNTIME_CALIBRATOR_PATH = MODELS_DIR / "runtime_risk_calibrator.pkl"
36
+ DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH = MODELS_DIR / "runtime_calibration_report.json"
37
+ DEFAULT_RUNTIME_REFINER_PATH = MODELS_DIR / "runtime_screening_refiner.pkl"
38
+ DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH = MODELS_DIR / "runtime_refinement_report.json"
39
+ DEFAULT_DEPLOYED_SCREENING_REPORT_PATH = MODELS_DIR / "deployed_screening_report.json"
40
+ DEFAULT_TRAINING_REPORT_PATH = MODELS_DIR / "training_report.json"
41
 
42
 
43
  # ---------------------------------------------------------------------------
 
87
  "PRELOAD_MODELS_ON_STARTUP",
88
  ),
89
  )
90
+ warmup_models_on_startup: bool = Field(
91
+ default=False,
92
+ validation_alias=AliasChoices(
93
+ "ANEMIALENS_WARMUP_MODELS_ON_STARTUP",
94
+ "WARMUP_MODELS_ON_STARTUP",
95
+ ),
96
+ )
97
+ enable_efficientnet_fallback: bool = Field(
98
+ default=False,
99
+ validation_alias=AliasChoices(
100
+ "ANEMIALENS_ENABLE_EFFICIENTNET_FALLBACK",
101
+ "ENABLE_EFFICIENTNET_FALLBACK",
102
+ ),
103
+ )
104
+ cors_origins: list[str] = Field(
105
+ default=[
106
+ "http://localhost:5173",
107
+ "http://127.0.0.1:5173",
108
  "http://localhost:5174",
109
  "http://127.0.0.1:5174",
110
  ]
backend/app/database.py CHANGED
@@ -7,13 +7,18 @@ Supports SQLite (dev) and PostgreSQL (production) via DATABASE_URL.
7
  from __future__ import annotations
8
 
9
  import os
 
10
 
 
11
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
12
  from sqlalchemy.orm import DeclarativeBase
13
 
 
 
 
14
  DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./anemialens.db").strip()
15
 
16
- # For PostgreSQL on Render, the URL starts with postgres:// but SQLAlchemy needs postgresql+asyncpg://
17
  if DATABASE_URL.startswith("postgres://"):
18
  DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql+asyncpg://", 1)
19
  elif DATABASE_URL.startswith("postgresql://") and "+asyncpg" not in DATABASE_URL:
 
7
  from __future__ import annotations
8
 
9
  import os
10
+ from pathlib import Path
11
 
12
+ from dotenv import load_dotenv
13
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
14
  from sqlalchemy.orm import DeclarativeBase
15
 
16
+ BACKEND_ROOT = Path(__file__).resolve().parents[1]
17
+ load_dotenv(BACKEND_ROOT / ".env")
18
+
19
  DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./anemialens.db").strip()
20
 
21
+ # For managed PostgreSQL providers, postgres:// must be normalized to postgresql+asyncpg://
22
  if DATABASE_URL.startswith("postgres://"):
23
  DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql+asyncpg://", 1)
24
  elif DATABASE_URL.startswith("postgresql://") and "+asyncpg" not in DATABASE_URL:
backend/app/main.py CHANGED
@@ -33,7 +33,7 @@ from typing import Annotated
33
  from dotenv import load_dotenv
34
  from fastapi import FastAPI, File, Form, Request, UploadFile, status, Depends
35
  from fastapi.middleware.cors import CORSMiddleware
36
- from fastapi.responses import JSONResponse, RedirectResponse
37
  from PIL import UnidentifiedImageError
38
 
39
  from app.config import BACKEND_ROOT, settings
@@ -43,17 +43,20 @@ from app.services.analysis_meta import build_analysis_meta
43
  from app.services.case_insight import CaseInsightService
44
  from app.services.clinical_brief import ClinicalBriefService
45
  from app.services.decision_audit import build_decision_audit
46
- from app.services.guidance import GuidanceService
47
- from app.services.handoff import HandoffSummaryService
48
- from app.services.image_quality import ImageQualityService
49
- from app.services.prediction import ScreeningPredictor
50
- from app.services.request_parsing import (
51
- InvalidRequestPayload,
52
- normalize_optional_text,
53
- parse_symptoms,
54
- )
55
- from app.services.runtime_status import build_runtime_status
56
- from app.services.triage import TriageService
 
 
 
57
 
58
  load_dotenv(BACKEND_ROOT / ".env")
59
 
@@ -111,10 +114,11 @@ async def lifespan(app: FastAPI):
111
  app.state.predictor = ScreeningPredictor()
112
  app.state.triage_service = TriageService()
113
  app.state.guidance_service = GuidanceService()
114
- app.state.case_insight_service = CaseInsightService()
115
- app.state.clinical_brief_service = ClinicalBriefService()
116
- app.state.handoff_service = HandoffSummaryService()
117
- log.info("All ML services initialised.")
 
118
 
119
  # ---------- Model warm-up ----------
120
  if app.state.predictor.is_ready():
@@ -144,7 +148,7 @@ async def lifespan(app: FastAPI):
144
  # App
145
  # ---------------------------------------------------------------------------
146
 
147
- app = FastAPI(
148
  title="AnemiaLens API",
149
  version="1.0.0",
150
  description=(
@@ -155,7 +159,7 @@ app = FastAPI(
155
  lifespan=lifespan,
156
  docs_url="/docs",
157
  redoc_url="/redoc",
158
- )
159
 
160
  # ---------------------------------------------------------------------------
161
  # Middleware stack (order matters — outermost first)
@@ -199,57 +203,57 @@ app.add_middleware(MemoryGuardMiddleware)
199
  # ---------------------------------------------------------------------------
200
 
201
  @app.middleware("http")
202
- async def request_id_middleware(request: Request, call_next):
203
- request_id = str(uuid.uuid4())[:8]
204
- request.state.request_id = request_id
205
- request.state.started_at = time.perf_counter()
206
-
207
- try:
208
- response = await call_next(request)
209
- except Exception:
210
- elapsed_ms = (time.perf_counter() - request.state.started_at) * 1000
211
- log.exception(
212
- "%s %s -> %d (%.1fms) [%s]",
213
- request.method,
214
- request.url.path,
215
- status.HTTP_500_INTERNAL_SERVER_ERROR,
216
- elapsed_ms,
217
- request_id,
218
- extra={"request_id": request_id},
219
- )
220
- raise
221
-
222
- elapsed_ms = (time.perf_counter() - request.state.started_at) * 1000
223
- response.headers["X-Request-ID"] = request_id
224
- response.headers["X-Response-Time"] = f"{elapsed_ms:.1f}ms"
225
-
226
- log.info(
227
- "%s %s -> %d (%.1fms) [%s]",
228
- request.method,
229
- request.url.path,
230
- response.status_code,
231
- elapsed_ms,
232
- request_id,
233
- extra={"request_id": request_id},
234
- )
235
- return response
236
 
237
 
238
  # ---------------------------------------------------------------------------
239
  # Include API route modules (Phase 2 & 3)
240
  # ---------------------------------------------------------------------------
241
 
242
- from app.api.auth import router as auth_router
243
- from app.api.history import router as history_router
244
- from app.api.admin import router as admin_router
245
- from app.api.billing import router as billing_router
246
- from app.api.email_report import router as email_report_router
247
-
248
- app.include_router(auth_router)
249
- app.include_router(history_router)
250
- app.include_router(admin_router)
251
- app.include_router(billing_router)
252
- app.include_router(email_report_router)
253
 
254
 
255
  # ---------------------------------------------------------------------------
@@ -277,12 +281,12 @@ def _too_large_response(request_id: str, max_mb: float) -> JSONResponse:
277
  )
278
 
279
 
280
- def _attempt_raw_frame_rescue(services, image_bytes: bytes, quality, symptom_score: float = 0.0):
281
  if quality.passed or not services.quality_service.allows_raw_frame_rescue(quality):
282
  return quality, None, False
283
 
284
  raw_image = load_image_bytes(image_bytes).convert("RGB")
285
- raw_prediction = services.predictor.predict(raw_image, quality, symptom_score=symptom_score)
286
  if not services.predictor.should_accept_raw_frame_rescue(raw_prediction):
287
  return quality, None, False
288
 
@@ -294,73 +298,37 @@ def _attempt_raw_frame_rescue(services, image_bytes: bytes, quality, symptom_sco
294
  # Screening persistence helper (Phase 2)
295
  # ---------------------------------------------------------------------------
296
 
297
- async def _persist_screening(
298
- request_id: str,
299
- analysis: AnalyzeResponse,
300
- user_id: int | None,
301
- processing_time_ms: float,
302
- ) -> None:
303
- """Save the screening result to the database."""
304
- try:
305
- from app.database import async_session_factory
306
- from app.models.screening import Screening
307
-
308
- screening = Screening(
309
- request_id=request_id,
310
- user_id=user_id,
311
- triage_band=analysis.triage.band,
312
- triage_score=analysis.triage.score,
313
- triage_label=analysis.triage.label,
314
- anemia_risk=analysis.prediction.anemia_risk if analysis.prediction else None,
315
- predicted_hemoglobin=analysis.prediction.predicted_hemoglobin if analysis.prediction else None,
316
- confidence=analysis.prediction.confidence if analysis.prediction else None,
317
- uncertainty=analysis.prediction.uncertainty if analysis.prediction else None,
318
- screening_label=analysis.prediction.screening_label if analysis.prediction else None,
319
- model_source=analysis.prediction.model_source if analysis.prediction else None,
320
- quality_passed=analysis.quality.passed,
321
- blocked=analysis.blocked,
322
- processing_path=analysis.decision_audit.processing_path,
323
- guidance_source=analysis.guidance.source,
324
- symptoms_json=json.dumps(analysis.symptoms.model_dump()),
325
- full_response_json=json.dumps(analysis.model_dump(), default=str),
326
- share_text=analysis.handoff_summary.share_text,
327
- urgency_label=analysis.handoff_summary.urgency_label,
328
- headline=analysis.handoff_summary.headline,
329
- processing_time_ms=processing_time_ms,
330
- language=analysis.language,
331
- region=analysis.region,
332
- )
333
 
334
- async with async_session_factory() as session:
335
- session.add(screening)
336
- await session.commit()
337
 
338
- # Update user scan count
339
- if user_id is not None:
340
- from app.models.user import User
341
- from sqlalchemy import select
342
- result = await session.execute(select(User).where(User.id == user_id))
343
- user = result.scalar_one_or_none()
344
- if user:
345
- user.scan_count += 1
346
- await session.commit()
347
 
348
- except Exception as exc:
349
- log.warning("Failed to persist screening (non-fatal): %s", exc)
 
 
350
 
351
 
352
- # ---------------------------------------------------------------------------
353
- # Routes Health / Meta
354
- # ---------------------------------------------------------------------------
355
-
356
- @app.get("/", include_in_schema=False)
357
- async def root() -> RedirectResponse:
358
- """Redirect the Space root to Swagger UI so Docker Space routing has a valid landing page."""
359
- return RedirectResponse(url="/docs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
360
-
361
-
362
- @app.get("/health", tags=["meta"], summary="Liveness probe")
363
- async def health(request: Request) -> dict[str, object]:
364
  """Returns 200 OK when the server is alive."""
365
  guidance_status = request.app.state.guidance_service.runtime_status()
366
  return {
@@ -436,13 +404,14 @@ async def quality_check(
436
  summary="Full conjunctiva screening pipeline",
437
  status_code=status.HTTP_200_OK,
438
  )
439
- async def analyze(
440
- request: Request,
441
- image: Annotated[UploadFile, File(description="Eye photo (JPEG or PNG).")],
442
- symptoms: Annotated[str | None, Form(description="JSON-encoded symptom flags.")] = None,
443
- language: Annotated[str | None, Form(description="Preferred language for guidance.")] = None,
444
- region: Annotated[str | None, Form(description="Geographic region for localised guidance.")] = None,
445
- ) -> AnalyzeResponse | JSONResponse:
 
446
  """
447
  Full pipeline: quality gate → ML inference → triage → guidance → insight packs.
448
  Works for both authenticated and anonymous users.
@@ -491,13 +460,14 @@ async def analyze(
491
  )
492
 
493
  # --- Input validation --------------------------------------------------
494
- try:
495
- symptom_input = parse_symptoms(symptoms)
496
- language = normalize_optional_text(language, field_name="language")
497
- region = normalize_optional_text(region, field_name="region")
498
- except InvalidRequestPayload as exc:
499
- return JSONResponse(
500
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
 
501
  content={"error": str(exc), "request_id": rid},
502
  )
503
 
@@ -513,12 +483,10 @@ async def analyze(
513
  return _image_error_response(rid)
514
 
515
  # --- Inference (skipped on quality failure) -----------------------------
516
- # Compute symptom_score early so it can influence ML prediction
517
- symptom_score = svc.triage_service._symptom_score(symptom_input)
518
- prediction = svc.predictor.predict(rgb, quality, symptom_score=symptom_score) if quality.passed else None
519
- used_raw_frame_rescue = False
520
- if prediction is None:
521
- quality, prediction, used_raw_frame_rescue = _attempt_raw_frame_rescue(svc, image_bytes, quality, symptom_score=symptom_score)
522
 
523
  # --- Triage + guidance -------------------------------------------------
524
  signal_breakdown = svc.triage_service.build_signal_breakdown(quality, prediction, symptom_input)
@@ -568,31 +536,55 @@ async def analyze(
568
 
569
  processing_time_ms = (time.perf_counter() - request.state.started_at) * 1000
570
 
571
- analysis_meta = build_analysis_meta(
572
- request_id=rid,
573
- api_version=app.version,
574
- processing_time_ms=processing_time_ms,
575
  quality=quality,
576
  decision_audit=decision_audit,
577
- guidance=guidance,
578
- used_raw_frame_rescue=used_raw_frame_rescue,
579
- )
580
-
581
- response = AnalyzeResponse(
582
- blocked=not quality.passed,
583
- quality=quality,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  prediction=prediction,
585
  decision_audit=decision_audit,
586
  triage=triage,
587
  guidance=guidance,
588
  insight_pack=insight_pack,
589
- clinical_brief=clinical_brief,
590
- handoff_summary=handoff_summary,
591
- analysis_meta=analysis_meta,
592
- symptoms=symptom_input,
593
- language=language,
594
- region=region,
595
- )
 
 
 
596
 
597
  # --- Persist to database (async, non-blocking) -------------------------
598
  import asyncio
 
33
  from dotenv import load_dotenv
34
  from fastapi import FastAPI, File, Form, Request, UploadFile, status, Depends
35
  from fastapi.middleware.cors import CORSMiddleware
36
+ from fastapi.responses import JSONResponse, RedirectResponse
37
  from PIL import UnidentifiedImageError
38
 
39
  from app.config import BACKEND_ROOT, settings
 
43
  from app.services.case_insight import CaseInsightService
44
  from app.services.clinical_brief import ClinicalBriefService
45
  from app.services.decision_audit import build_decision_audit
46
+ from app.services.guidance import GuidanceService
47
+ from app.services.handoff import HandoffSummaryService
48
+ from app.services.image_quality import ImageQualityService
49
+ from app.services.patient_case import PatientCaseService
50
+ from app.services.prediction import ScreeningPredictor
51
+ from app.services.request_parsing import (
52
+ InvalidRequestPayload,
53
+ normalize_optional_text,
54
+ parse_patient_profile,
55
+ parse_symptoms,
56
+ )
57
+ from app.services.runtime_status import build_runtime_status
58
+ from app.services.screening_store import persist_screening_result
59
+ from app.services.triage import TriageService
60
 
61
  load_dotenv(BACKEND_ROOT / ".env")
62
 
 
114
  app.state.predictor = ScreeningPredictor()
115
  app.state.triage_service = TriageService()
116
  app.state.guidance_service = GuidanceService()
117
+ app.state.case_insight_service = CaseInsightService()
118
+ app.state.clinical_brief_service = ClinicalBriefService()
119
+ app.state.handoff_service = HandoffSummaryService()
120
+ app.state.patient_case_service = PatientCaseService()
121
+ log.info("All ML services initialised.")
122
 
123
  # ---------- Model warm-up ----------
124
  if app.state.predictor.is_ready():
 
148
  # App
149
  # ---------------------------------------------------------------------------
150
 
151
+ app = FastAPI(
152
  title="AnemiaLens API",
153
  version="1.0.0",
154
  description=(
 
159
  lifespan=lifespan,
160
  docs_url="/docs",
161
  redoc_url="/redoc",
162
+ )
163
 
164
  # ---------------------------------------------------------------------------
165
  # Middleware stack (order matters — outermost first)
 
203
  # ---------------------------------------------------------------------------
204
 
205
  @app.middleware("http")
206
+ async def request_id_middleware(request: Request, call_next):
207
+ request_id = str(uuid.uuid4())[:8]
208
+ request.state.request_id = request_id
209
+ request.state.started_at = time.perf_counter()
210
+
211
+ try:
212
+ response = await call_next(request)
213
+ except Exception:
214
+ elapsed_ms = (time.perf_counter() - request.state.started_at) * 1000
215
+ log.exception(
216
+ "%s %s -> %d (%.1fms) [%s]",
217
+ request.method,
218
+ request.url.path,
219
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
220
+ elapsed_ms,
221
+ request_id,
222
+ extra={"request_id": request_id},
223
+ )
224
+ raise
225
+
226
+ elapsed_ms = (time.perf_counter() - request.state.started_at) * 1000
227
+ response.headers["X-Request-ID"] = request_id
228
+ response.headers["X-Response-Time"] = f"{elapsed_ms:.1f}ms"
229
+
230
+ log.info(
231
+ "%s %s -> %d (%.1fms) [%s]",
232
+ request.method,
233
+ request.url.path,
234
+ response.status_code,
235
+ elapsed_ms,
236
+ request_id,
237
+ extra={"request_id": request_id},
238
+ )
239
+ return response
240
 
241
 
242
  # ---------------------------------------------------------------------------
243
  # Include API route modules (Phase 2 & 3)
244
  # ---------------------------------------------------------------------------
245
 
246
+ from app.api.auth import router as auth_router
247
+ from app.api.history import router as history_router
248
+ from app.api.admin import router as admin_router
249
+ from app.api.billing import router as billing_router
250
+ from app.api.email_report import router as email_report_router
251
+
252
+ app.include_router(auth_router)
253
+ app.include_router(history_router)
254
+ app.include_router(admin_router)
255
+ app.include_router(billing_router)
256
+ app.include_router(email_report_router)
257
 
258
 
259
  # ---------------------------------------------------------------------------
 
281
  )
282
 
283
 
284
+ def _attempt_raw_frame_rescue(services, image_bytes: bytes, quality):
285
  if quality.passed or not services.quality_service.allows_raw_frame_rescue(quality):
286
  return quality, None, False
287
 
288
  raw_image = load_image_bytes(image_bytes).convert("RGB")
289
+ raw_prediction = services.predictor.predict(raw_image, quality)
290
  if not services.predictor.should_accept_raw_frame_rescue(raw_prediction):
291
  return quality, None, False
292
 
 
298
  # Screening persistence helper (Phase 2)
299
  # ---------------------------------------------------------------------------
300
 
301
+ async def _persist_screening(
302
+ request_id: str,
303
+ analysis: AnalyzeResponse,
304
+ user_id: int | None,
305
+ processing_time_ms: float,
306
+ ) -> None:
307
+ """Save the screening result to the database."""
308
+ try:
309
+ await persist_screening_result(
310
+ request_id=request_id,
311
+ analysis=analysis,
312
+ user_id=user_id,
313
+ processing_time_ms=processing_time_ms,
314
+ )
315
+
316
+ except Exception as exc:
317
+ log.warning("Failed to persist screening (non-fatal): %s", exc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
 
 
 
319
 
320
+ # ---------------------------------------------------------------------------
321
+ # Routes Health / Meta
322
+ # ---------------------------------------------------------------------------
 
 
 
 
 
 
323
 
324
+ @app.get("/", include_in_schema=False)
325
+ async def root() -> RedirectResponse:
326
+ """Redirect the Space root to Swagger UI so Docker Space routing has a valid landing page."""
327
+ return RedirectResponse(url="/docs", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
328
 
329
 
330
+ @app.get("/health", tags=["meta"], summary="Liveness probe")
331
+ async def health(request: Request) -> dict[str, object]:
 
 
 
 
 
 
 
 
 
 
332
  """Returns 200 OK when the server is alive."""
333
  guidance_status = request.app.state.guidance_service.runtime_status()
334
  return {
 
404
  summary="Full conjunctiva screening pipeline",
405
  status_code=status.HTTP_200_OK,
406
  )
407
+ async def analyze(
408
+ request: Request,
409
+ image: Annotated[UploadFile, File(description="Eye photo (JPEG or PNG).")],
410
+ symptoms: Annotated[str | None, Form(description="JSON-encoded symptom flags.")] = None,
411
+ patient_profile: Annotated[str | None, Form(description="JSON-encoded intake profile.")] = None,
412
+ language: Annotated[str | None, Form(description="Preferred language for guidance.")] = None,
413
+ region: Annotated[str | None, Form(description="Geographic region for localised guidance.")] = None,
414
+ ) -> AnalyzeResponse | JSONResponse:
415
  """
416
  Full pipeline: quality gate → ML inference → triage → guidance → insight packs.
417
  Works for both authenticated and anonymous users.
 
460
  )
461
 
462
  # --- Input validation --------------------------------------------------
463
+ try:
464
+ symptom_input = parse_symptoms(symptoms)
465
+ patient_profile_input = parse_patient_profile(patient_profile)
466
+ language = normalize_optional_text(language, field_name="language")
467
+ region = normalize_optional_text(region, field_name="region")
468
+ except InvalidRequestPayload as exc:
469
+ return JSONResponse(
470
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
471
  content={"error": str(exc), "request_id": rid},
472
  )
473
 
 
483
  return _image_error_response(rid)
484
 
485
  # --- Inference (skipped on quality failure) -----------------------------
486
+ prediction = svc.predictor.predict(rgb, quality) if quality.passed else None
487
+ used_raw_frame_rescue = False
488
+ if prediction is None:
489
+ quality, prediction, used_raw_frame_rescue = _attempt_raw_frame_rescue(svc, image_bytes, quality)
 
 
490
 
491
  # --- Triage + guidance -------------------------------------------------
492
  signal_breakdown = svc.triage_service.build_signal_breakdown(quality, prediction, symptom_input)
 
536
 
537
  processing_time_ms = (time.perf_counter() - request.state.started_at) * 1000
538
 
539
+ analysis_meta = build_analysis_meta(
540
+ request_id=rid,
541
+ api_version=app.version,
542
+ processing_time_ms=processing_time_ms,
543
  quality=quality,
544
  decision_audit=decision_audit,
545
+ guidance=guidance,
546
+ used_raw_frame_rescue=used_raw_frame_rescue,
547
+ )
548
+ patient_profile_result = svc.patient_case_service.build_profile(
549
+ rid,
550
+ patient_profile_input,
551
+ symptom_input,
552
+ )
553
+ workflow_stages = svc.patient_case_service.build_workflow_stages(
554
+ quality,
555
+ prediction,
556
+ triage,
557
+ guidance,
558
+ symptom_input,
559
+ )
560
+ structured_case = svc.patient_case_service.build_structured_case(
561
+ rid,
562
+ patient_profile_result,
563
+ quality,
564
+ prediction,
565
+ triage,
566
+ guidance,
567
+ symptom_input,
568
+ )
569
+
570
+ response = AnalyzeResponse(
571
+ blocked=not quality.passed,
572
+ quality=quality,
573
  prediction=prediction,
574
  decision_audit=decision_audit,
575
  triage=triage,
576
  guidance=guidance,
577
  insight_pack=insight_pack,
578
+ clinical_brief=clinical_brief,
579
+ handoff_summary=handoff_summary,
580
+ analysis_meta=analysis_meta,
581
+ patient_profile=patient_profile_result,
582
+ workflow_stages=workflow_stages,
583
+ structured_case=structured_case,
584
+ symptoms=symptom_input,
585
+ language=language,
586
+ region=region,
587
+ )
588
 
589
  # --- Persist to database (async, non-blocking) -------------------------
590
  import asyncio
backend/app/ml/efficientnet_model.py CHANGED
@@ -1,15 +1,11 @@
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
4
- from typing import TYPE_CHECKING, Any
5
 
6
  import numpy as np
7
  from PIL import Image
8
 
9
- if TYPE_CHECKING:
10
- import torch
11
- from torch import nn
12
-
13
 
14
  EFFICIENTNET_VERSION = "efficientnet-b0-ft-v2"
15
  IMAGE_SIZE = 224
@@ -21,56 +17,30 @@ def clamp(value: float, lower: float = 0.0, upper: float = 1.0) -> float:
21
  return max(lower, min(upper, value))
22
 
23
 
24
- def build_efficientnet_model(*, pretrained: bool = True) -> "nn.Module":
25
  from torch import nn
26
  from torchvision.models import EfficientNet_B0_Weights, efficientnet_b0
27
 
28
- class SpatialAttention(nn.Module):
29
- """
30
- Focuses the model on the most informative spatial regions (like the conjunctiva area).
31
- """
32
-
33
- def __init__(self, kernel_size: int = 7) -> None:
34
- super().__init__()
35
- self.conv = nn.Conv2d(2, 1, kernel_size=kernel_size, padding=kernel_size // 2, bias=False)
36
- self.sigmoid = nn.Sigmoid()
37
-
38
- def forward(self, x):
39
- import torch
40
-
41
- avg_out = torch.mean(x, dim=1, keepdim=True)
42
- max_out, _ = torch.max(x, dim=1, keepdim=True)
43
- combined = torch.cat([avg_out, max_out], dim=1)
44
- scale = self.sigmoid(self.conv(combined))
45
- return x * scale
46
-
47
  weights = EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None
48
  model = efficientnet_b0(weights=weights)
49
-
50
- model.features.add_module("spatial_attention", SpatialAttention())
51
  model.classifier = nn.Sequential(
52
  nn.Dropout(0.35),
53
  nn.Linear(1280, 512),
54
  nn.GELU(),
55
- nn.BatchNorm1d(512),
56
  nn.Dropout(0.25),
57
  nn.Linear(512, 128),
58
  nn.GELU(),
59
- nn.BatchNorm1d(128),
60
  nn.Dropout(0.15),
61
  nn.Linear(128, 2),
62
  )
63
 
64
  for param in model.features.parameters():
65
  param.requires_grad = False
66
-
67
  for name, param in model.features.named_parameters():
68
- if name.startswith(("4", "5", "6", "7", "8", "spatial_attention")):
69
  param.requires_grad = True
70
-
71
  for param in model.classifier.parameters():
72
  param.requires_grad = True
73
-
74
  return model
75
 
76
 
@@ -81,14 +51,27 @@ def build_train_transform():
81
  [
82
  transforms.RandomHorizontalFlip(),
83
  transforms.RandomVerticalFlip(p=0.15),
84
- transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.3, hue=0.05),
 
 
 
 
 
85
  transforms.RandomRotation(20),
86
- transforms.RandomAffine(degrees=0, translate=(0.12, 0.12), scale=(0.88, 1.12)),
 
 
 
 
87
  transforms.RandomPerspective(distortion_scale=0.15, p=0.3),
88
  transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
89
  transforms.ToTensor(),
90
  transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
91
- transforms.RandomErasing(p=0.25, scale=(0.02, 0.12), ratio=(0.3, 3.3)),
 
 
 
 
92
  ]
93
  )
94
 
@@ -108,14 +91,14 @@ def build_val_transform():
108
  def load_efficientnet_checkpoint(
109
  path: str | Path,
110
  *,
111
- map_location: str | "torch.device" = "cpu",
112
  ) -> dict[str, Any]:
113
  import torch
114
 
115
  checkpoint = torch.load(path, map_location=map_location)
116
  model = build_efficientnet_model(pretrained=False)
117
  state_dict = checkpoint["state_dict"] if "state_dict" in checkpoint else checkpoint
118
- model.load_state_dict(state_dict, strict=False)
119
  device = torch.device(map_location)
120
  model.to(device)
121
  model.eval()
@@ -164,18 +147,15 @@ def predict_with_efficientnet_model(
164
  _enable_dropout(model)
165
  output = model(tensor)
166
  probabilities.append(float(torch.sigmoid(output[:, 0]).item()))
167
- hemoglobin_values.append(float((output[:, 1].item() * hb_std_scale) + hb_mean))
 
 
168
 
169
  mean_probability = float(np.mean(probabilities))
170
  mean_hemoglobin = float(np.mean(hemoglobin_values))
171
  probability_std = float(np.std(probabilities))
172
  hemoglobin_std = float(np.std(hemoglobin_values))
173
 
174
- hb_mean_val = float(bundle.get("hb_mean", 12.8))
175
- hb_spread_factor = float(bundle.get("hb_spread_factor", 1.30))
176
- deviation = mean_hemoglobin - hb_mean_val
177
- mean_hemoglobin = float(np.clip(hb_mean_val + deviation * hb_spread_factor, 5.0, 20.0))
178
-
179
  margin_uncertainty = 1.0 - min(1.0, abs(mean_probability - 0.5) * 2.5)
180
  uncertainty = clamp(
181
  (probability_std * 2.2)
@@ -196,7 +176,7 @@ def predict_with_efficientnet_model(
196
  }
197
 
198
 
199
- def _enable_dropout(model: "nn.Module") -> None:
200
  from torch import nn
201
 
202
  for module in model.modules():
 
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
4
+ from typing import Any
5
 
6
  import numpy as np
7
  from PIL import Image
8
 
 
 
 
 
9
 
10
  EFFICIENTNET_VERSION = "efficientnet-b0-ft-v2"
11
  IMAGE_SIZE = 224
 
17
  return max(lower, min(upper, value))
18
 
19
 
20
+ def build_efficientnet_model(*, pretrained: bool = True):
21
  from torch import nn
22
  from torchvision.models import EfficientNet_B0_Weights, efficientnet_b0
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  weights = EfficientNet_B0_Weights.IMAGENET1K_V1 if pretrained else None
25
  model = efficientnet_b0(weights=weights)
 
 
26
  model.classifier = nn.Sequential(
27
  nn.Dropout(0.35),
28
  nn.Linear(1280, 512),
29
  nn.GELU(),
 
30
  nn.Dropout(0.25),
31
  nn.Linear(512, 128),
32
  nn.GELU(),
 
33
  nn.Dropout(0.15),
34
  nn.Linear(128, 2),
35
  )
36
 
37
  for param in model.features.parameters():
38
  param.requires_grad = False
 
39
  for name, param in model.features.named_parameters():
40
+ if name.startswith(("4", "5", "6", "7", "8")):
41
  param.requires_grad = True
 
42
  for param in model.classifier.parameters():
43
  param.requires_grad = True
 
44
  return model
45
 
46
 
 
51
  [
52
  transforms.RandomHorizontalFlip(),
53
  transforms.RandomVerticalFlip(p=0.15),
54
+ transforms.ColorJitter(
55
+ brightness=0.4,
56
+ contrast=0.4,
57
+ saturation=0.3,
58
+ hue=0.05,
59
+ ),
60
  transforms.RandomRotation(20),
61
+ transforms.RandomAffine(
62
+ degrees=0,
63
+ translate=(0.12, 0.12),
64
+ scale=(0.88, 1.12),
65
+ ),
66
  transforms.RandomPerspective(distortion_scale=0.15, p=0.3),
67
  transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
68
  transforms.ToTensor(),
69
  transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
70
+ transforms.RandomErasing(
71
+ p=0.25,
72
+ scale=(0.02, 0.12),
73
+ ratio=(0.3, 3.3),
74
+ ),
75
  ]
76
  )
77
 
 
91
  def load_efficientnet_checkpoint(
92
  path: str | Path,
93
  *,
94
+ map_location: str = "cpu",
95
  ) -> dict[str, Any]:
96
  import torch
97
 
98
  checkpoint = torch.load(path, map_location=map_location)
99
  model = build_efficientnet_model(pretrained=False)
100
  state_dict = checkpoint["state_dict"] if "state_dict" in checkpoint else checkpoint
101
+ model.load_state_dict(state_dict)
102
  device = torch.device(map_location)
103
  model.to(device)
104
  model.eval()
 
147
  _enable_dropout(model)
148
  output = model(tensor)
149
  probabilities.append(float(torch.sigmoid(output[:, 0]).item()))
150
+ hemoglobin_values.append(
151
+ float((output[:, 1].item() * hb_std_scale) + hb_mean)
152
+ )
153
 
154
  mean_probability = float(np.mean(probabilities))
155
  mean_hemoglobin = float(np.mean(hemoglobin_values))
156
  probability_std = float(np.std(probabilities))
157
  hemoglobin_std = float(np.std(hemoglobin_values))
158
 
 
 
 
 
 
159
  margin_uncertainty = 1.0 - min(1.0, abs(mean_probability - 0.5) * 2.5)
160
  uncertainty = clamp(
161
  (probability_std * 2.2)
 
176
  }
177
 
178
 
179
+ def _enable_dropout(model) -> None:
180
  from torch import nn
181
 
182
  for module in model.modules():
backend/app/ml/lightweight_model.py CHANGED
@@ -2,7 +2,7 @@
2
  Lightweight fallback model for AnemiaLens.
3
 
4
  Used when:
5
- - Available RAM < 512MB (Render free tier)
6
  - Inference time budget is tight
7
  - Primary model artifacts are unavailable
8
 
 
2
  Lightweight fallback model for AnemiaLens.
3
 
4
  Used when:
5
+ - Available RAM < 512MB on constrained free hosts
6
  - Inference time budget is tight
7
  - Primary model artifacts are unavailable
8
 
backend/app/ml/runtime_calibration.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pickle
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from app.ml.archive_model import clamp
9
+ from app.ml.calibration import CompositeCalibrator
10
+
11
+ SourceHint = Literal["roi_original", "palpebral", "forniceal_palpebral"]
12
+
13
+
14
+ @dataclass
15
+ class RuntimeRiskCalibrator:
16
+ version: str = "runtime-risk-calibrator-v1"
17
+ method: str = "temperature"
18
+ calibrator: CompositeCalibrator = field(
19
+ default_factory=lambda: CompositeCalibrator(method="temperature")
20
+ )
21
+ source_thresholds: dict[str, float] = field(default_factory=dict)
22
+ report: dict[str, object] = field(default_factory=dict)
23
+
24
+ def calibrate(
25
+ self,
26
+ probability: float,
27
+ *,
28
+ source_hint: SourceHint = "roi_original",
29
+ ) -> float:
30
+ _ = source_hint
31
+ return clamp(float(self.calibrator.calibrate(probability)), 0.0, 1.0)
32
+
33
+ def threshold_for_source(
34
+ self,
35
+ source_hint: SourceHint,
36
+ *,
37
+ fallback: float,
38
+ ) -> float:
39
+ return float(self.source_thresholds.get(source_hint, fallback))
40
+
41
+ def save(self, path: str | Path) -> None:
42
+ path = Path(path)
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ with path.open("wb") as handle:
45
+ pickle.dump(self, handle)
46
+
47
+ @classmethod
48
+ def load(cls, path: str | Path) -> "RuntimeRiskCalibrator":
49
+ with Path(path).open("rb") as handle:
50
+ return pickle.load(handle)
backend/app/ml/runtime_refinement.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pickle
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from app.ml.archive_model import clamp
11
+
12
+ FEATURE_ORDER: tuple[str, ...] = (
13
+ "base_anemia_risk",
14
+ "uncertainty",
15
+ "predicted_hemoglobin",
16
+ "predicted_hemoglobin_missing",
17
+ "brightness_score",
18
+ "contrast_score",
19
+ "blur_score",
20
+ "framing_score",
21
+ "lighting_score",
22
+ "glare_risk",
23
+ "shadow_risk",
24
+ "lighting_balanced",
25
+ "lighting_overexposed",
26
+ "lighting_glare_heavy",
27
+ "lighting_shadow_heavy",
28
+ "lighting_flat_contrast",
29
+ "lighting_dim",
30
+ "base_likely",
31
+ )
32
+
33
+
34
+ @dataclass
35
+ class RuntimeScreeningRefiner:
36
+ version: str = "runtime-screening-refiner-v1"
37
+ method: str = "logistic-regression"
38
+ threshold: float = 0.53
39
+ feature_order: tuple[str, ...] = FEATURE_ORDER
40
+ model: Any = None
41
+ report: dict[str, object] = field(default_factory=dict)
42
+
43
+ def _feature_vector(
44
+ self,
45
+ *,
46
+ base_anemia_risk: float,
47
+ uncertainty: float,
48
+ predicted_hemoglobin: float | None,
49
+ quality,
50
+ base_likely: bool,
51
+ ) -> list[float]:
52
+ hb_missing = predicted_hemoglobin is None
53
+ hb_value = 13.5 if predicted_hemoglobin is None else float(predicted_hemoglobin)
54
+ lighting = str(getattr(quality, "lighting_condition", "balanced"))
55
+ return [
56
+ float(base_anemia_risk),
57
+ float(uncertainty),
58
+ hb_value,
59
+ float(hb_missing),
60
+ float(getattr(quality, "brightness_score", 0.0)),
61
+ float(getattr(quality, "contrast_score", 0.0)),
62
+ float(getattr(quality, "blur_score", 0.0)),
63
+ float(getattr(quality, "framing_score", 0.0)),
64
+ float(getattr(quality, "lighting_score", 0.0)),
65
+ float(getattr(quality, "glare_risk", 0.0)),
66
+ float(getattr(quality, "shadow_risk", 0.0)),
67
+ float(lighting == "balanced"),
68
+ float(lighting == "overexposed"),
69
+ float(lighting == "glare_heavy"),
70
+ float(lighting == "shadow_heavy"),
71
+ float(lighting == "flat_contrast"),
72
+ float(lighting == "dim"),
73
+ float(base_likely),
74
+ ]
75
+
76
+ def refine(
77
+ self,
78
+ *,
79
+ base_anemia_risk: float,
80
+ uncertainty: float,
81
+ predicted_hemoglobin: float | None,
82
+ quality,
83
+ base_likely: bool,
84
+ ) -> float:
85
+ if self.model is None:
86
+ return clamp(float(base_anemia_risk), 0.0, 1.0)
87
+ vector = np.asarray(
88
+ [
89
+ self._feature_vector(
90
+ base_anemia_risk=base_anemia_risk,
91
+ uncertainty=uncertainty,
92
+ predicted_hemoglobin=predicted_hemoglobin,
93
+ quality=quality,
94
+ base_likely=base_likely,
95
+ )
96
+ ],
97
+ dtype=np.float32,
98
+ )
99
+ probability = float(self.model.predict_proba(vector)[0, 1])
100
+ return clamp(probability, 0.0, 1.0)
101
+
102
+ def save(self, path: str | Path) -> None:
103
+ path = Path(path)
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ with path.open("wb") as handle:
106
+ pickle.dump(self, handle)
107
+
108
+ @classmethod
109
+ def load(cls, path: str | Path) -> "RuntimeScreeningRefiner":
110
+ with Path(path).open("rb") as handle:
111
+ return pickle.load(handle)
backend/app/ml/runtime_stack.py CHANGED
@@ -8,11 +8,11 @@ from app.ml.archive_model import clamp
8
  RUNTIME_STACK_VERSION = "archive-evidence-fusion-v4"
9
  SourceHint = Literal["roi_original", "palpebral", "forniceal_palpebral"]
10
 
11
- DEFAULT_SOURCE_THRESHOLDS: dict[SourceHint, float] = {
12
- "roi_original": 0.65,
13
- "palpebral": 0.65,
14
- "forniceal_palpebral": 0.65,
15
- }
16
 
17
  DEFAULT_RISK_ARCHIVE_WEIGHTS: dict[SourceHint, float] = {
18
  "roi_original": 0.55,
 
8
  RUNTIME_STACK_VERSION = "archive-evidence-fusion-v4"
9
  SourceHint = Literal["roi_original", "palpebral", "forniceal_palpebral"]
10
 
11
+ DEFAULT_SOURCE_THRESHOLDS: dict[SourceHint, float] = {
12
+ "roi_original": 0.495,
13
+ "palpebral": 0.65,
14
+ "forniceal_palpebral": 0.65,
15
+ }
16
 
17
  DEFAULT_RISK_ARCHIVE_WEIGHTS: dict[SourceHint, float] = {
18
  "roi_original": 0.55,
backend/app/schemas.py CHANGED
@@ -66,6 +66,9 @@ def _coerce_boolean(value: object, *, allow_none: bool = False) -> bool | None:
66
  # Request schemas
67
  # ---------------------------------------------------------------------------
68
 
 
 
 
69
  class SymptomInput(BaseModel):
70
  """
71
  Self-reported symptoms submitted alongside an eye image.
@@ -174,6 +177,58 @@ class SymptomInput(BaseModel):
174
  }
175
 
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  # ---------------------------------------------------------------------------
178
  # Quality assessment
179
  # ---------------------------------------------------------------------------
@@ -221,6 +276,32 @@ class QualityAssessment(BaseModel):
221
  brightness_score: float = Field(ge=0.0, le=1.0, description="Mean luminance in [0, 1].")
222
  contrast_score: float = Field(ge=0.0, le=1.0, description="Normalised RMS contrast.")
223
  framing_score: float = Field(ge=0.0, description="Eye-region occupancy ratio.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  issues: list[QualityIssue] = Field(default_factory=list)
225
 
226
  @cached_property
@@ -297,6 +378,10 @@ class PredictionResult(BaseModel):
297
  model_source: ModelSource = Field(
298
  description="Which model or pipeline produced this prediction."
299
  )
 
 
 
 
300
 
301
  @model_validator(mode="after")
302
  def _confidence_uncertainty_consistent(self) -> "PredictionResult":
@@ -573,6 +658,82 @@ class ClinicalBrief(BaseModel):
573
  )
574
 
575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  class AnalysisMeta(BaseModel):
577
  request_id: str = Field(description="Short request identifier copied from the API response headers.")
578
  generated_at: str = Field(description="Local timestamp when the response payload was assembled.")
@@ -585,7 +746,7 @@ class AnalysisMeta(BaseModel):
585
  description="Which inference path reached the final result."
586
  )
587
  guidance_source: GuidanceSource = Field(
588
- description="Whether guidance came from Qwen or the rule-based fallback."
589
  )
590
  used_raw_frame_rescue: bool = Field(
591
  description="True when the backend rescued a framing-limited case using the full-frame path."
@@ -603,10 +764,9 @@ class AnalysisMeta(BaseModel):
603
  class GuidanceRuntimeStatus(BaseModel):
604
  active_strategy: GuidanceSource
605
  mistral_enabled: bool = False
606
- qwen_enabled: bool = False # kept for backwards compat
607
  client_ready: bool = False
608
  api_key_configured: bool = False
609
- qwen_model: str | None = None
610
  provider: str | None = None
611
  fallback_reason: str | None = None
612
  last_provider_error: str | None = None
@@ -619,6 +779,20 @@ class ModelRuntimeStatus(BaseModel):
619
  artifact_ready: bool = False
620
  artifact_path: str | None = None
621
  load_error: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  record_count: int | None = None
623
  validation_accuracy: float | None = None
624
  validation_f1: float | None = None
@@ -671,6 +845,14 @@ class AnalyzeResponse(BaseModel):
671
  clinical_brief: ClinicalBrief
672
  handoff_summary: HandoffSummary
673
  analysis_meta: AnalysisMeta
 
 
 
 
 
 
 
 
674
  symptoms: SymptomInput
675
  language: str | None = Field(default=None, description="BCP-47 language tag or plain name.")
676
  region: str | None = Field(default=None, description="Geographic region for localised guidance.")
 
66
  # Request schemas
67
  # ---------------------------------------------------------------------------
68
 
69
+ SexType = Literal["female", "male", "other", "not_specified"]
70
+ DietType = Literal["omnivore", "vegetarian", "vegan", "mixed", "not_specified"]
71
+
72
  class SymptomInput(BaseModel):
73
  """
74
  Self-reported symptoms submitted alongside an eye image.
 
177
  }
178
 
179
 
180
+ # ---------------------------------------------------------------------------
181
+ # Intake context
182
+ # ---------------------------------------------------------------------------
183
+
184
+ class PatientProfileInput(BaseModel):
185
+ """
186
+ Lightweight intake details that make the screening flow feel closer to a
187
+ real healthcare workflow without pretending to be a full medical record.
188
+ """
189
+
190
+ model_config = ConfigDict(extra="forbid")
191
+
192
+ age: int | None = Field(
193
+ default=None,
194
+ ge=1,
195
+ le=120,
196
+ description="Approximate patient age in years, if provided.",
197
+ )
198
+ sex: SexType = Field(
199
+ default="not_specified",
200
+ description="Self-reported sex used only for screening context.",
201
+ )
202
+ diet_type: DietType = Field(
203
+ default="not_specified",
204
+ description="Self-reported diet pattern relevant to iron intake context.",
205
+ )
206
+
207
+ @field_validator("age", mode="before")
208
+ @classmethod
209
+ def _normalise_age(cls, value: object) -> int | None:
210
+ if value is None:
211
+ return None
212
+ if isinstance(value, str):
213
+ normalised = value.strip()
214
+ if not normalised:
215
+ return None
216
+ return int(normalised)
217
+ if isinstance(value, (int, float)):
218
+ return int(value)
219
+ raise ValueError("age must be an integer or null")
220
+
221
+ @field_validator("sex", "diet_type", mode="before")
222
+ @classmethod
223
+ def _normalise_intake_enum(cls, value: object) -> str:
224
+ if value is None:
225
+ return "not_specified"
226
+ if isinstance(value, str):
227
+ normalised = value.strip().lower()
228
+ return normalised or "not_specified"
229
+ raise ValueError("Expected a string value")
230
+
231
+
232
  # ---------------------------------------------------------------------------
233
  # Quality assessment
234
  # ---------------------------------------------------------------------------
 
276
  brightness_score: float = Field(ge=0.0, le=1.0, description="Mean luminance in [0, 1].")
277
  contrast_score: float = Field(ge=0.0, le=1.0, description="Normalised RMS contrast.")
278
  framing_score: float = Field(ge=0.0, description="Eye-region occupancy ratio.")
279
+ lighting_score: float = Field(
280
+ default=0.0,
281
+ ge=0.0,
282
+ le=1.0,
283
+ description="Composite lighting quality score, where higher means more usable lighting.",
284
+ )
285
+ lighting_condition: str = Field(
286
+ default="balanced",
287
+ description="Lighting classification inferred from exposure, glare, shadows, and contrast.",
288
+ )
289
+ lighting_summary: str = Field(
290
+ default="Lighting details unavailable.",
291
+ description="Plain-language explanation of the current lighting condition and what it means for screening.",
292
+ )
293
+ glare_risk: float = Field(
294
+ default=0.0,
295
+ ge=0.0,
296
+ le=1.0,
297
+ description="Estimated risk that glare or clipped highlights are harming the capture.",
298
+ )
299
+ shadow_risk: float = Field(
300
+ default=0.0,
301
+ ge=0.0,
302
+ le=1.0,
303
+ description="Estimated risk that shadows or underexposure are hiding useful signal.",
304
+ )
305
  issues: list[QualityIssue] = Field(default_factory=list)
306
 
307
  @cached_property
 
378
  model_source: ModelSource = Field(
379
  description="Which model or pipeline produced this prediction."
380
  )
381
+ confidence_breakdown: dict[str, float | bool | str] | None = Field(
382
+ default=None,
383
+ description="Decomposed confidence view covering capture quality, model stability, threshold stability, and guardrail effects.",
384
+ )
385
 
386
  @model_validator(mode="after")
387
  def _confidence_uncertainty_consistent(self) -> "PredictionResult":
 
658
  )
659
 
660
 
661
+ WorkflowStageKey = Literal[
662
+ "image_quality_agent",
663
+ "screening_agent",
664
+ "triage_agent",
665
+ "guidance_agent",
666
+ ]
667
+ WorkflowStageStatus = Literal["passed", "warning", "blocked", "complete"]
668
+
669
+
670
+ class PatientProfile(BaseModel):
671
+ patient_id: str = Field(description="Share-safe case identifier generated for this screening run.")
672
+ age: int | None = Field(default=None, description="Approximate patient age in years, if provided.")
673
+ sex: SexType = Field(description="Self-reported sex captured during intake.")
674
+ diet_type: DietType = Field(description="Self-reported diet pattern captured during intake.")
675
+ reported_symptoms: list[str] = Field(
676
+ default_factory=list,
677
+ description="Human-readable symptom labels captured during intake.",
678
+ )
679
+ summary: str = Field(description="Short patient-context summary for the workflow UI.")
680
+
681
+
682
+ class WorkflowStage(BaseModel):
683
+ key: WorkflowStageKey = Field(description="Stable workflow-stage identifier.")
684
+ agent_label: str = Field(description="User-facing module name, presented as an agent-like stage.")
685
+ title: str = Field(description="Short workflow stage title.")
686
+ status: WorkflowStageStatus = Field(description="Outcome of this stage for the current run.")
687
+ summary: str = Field(description="One-sentence explanation of what happened at this stage.")
688
+
689
+
690
+ class StructuredCaseImageQuality(BaseModel):
691
+ status: Literal["acceptable", "warning", "blocked"] = Field(
692
+ description="Image usability status for the final screening flow."
693
+ )
694
+ lighting_condition: str = Field(description="Lighting classification for the capture.")
695
+ lighting_score: Annotated[float, Field(ge=0.0, le=1.0)] = Field(
696
+ description="Composite lighting quality score for the case."
697
+ )
698
+ blur_detected: bool = Field(description="Whether the pipeline flagged blur as an issue.")
699
+ eye_region_visible: bool = Field(description="Whether the eye / conjunctiva region was adequately visible.")
700
+ primary_issue: str | None = Field(default=None, description="Most important quality issue, if any.")
701
+ warnings: list[str] = Field(default_factory=list, description="Non-blocking quality issue titles.")
702
+
703
+
704
+ class StructuredCaseScreeningResult(BaseModel):
705
+ risk_level: TriageBand = Field(description="Final triage band used as the case risk level.")
706
+ confidence: Annotated[float, Field(ge=0.0, le=1.0)] | None = Field(
707
+ default=None,
708
+ description="Final model confidence, when inference ran.",
709
+ )
710
+ reliability: ReliabilityFlag | None = Field(
711
+ default=None,
712
+ description="Reliability tier attached to the prediction, when inference ran.",
713
+ )
714
+ predicted_hemoglobin: float | None = Field(
715
+ default=None,
716
+ description="Estimated hemoglobin value in g/dL, when available.",
717
+ )
718
+ anemia_risk: Annotated[float, Field(ge=0.0, le=1.0)] | None = Field(
719
+ default=None,
720
+ description="Raw anemia-like risk score from the image model, when inference ran.",
721
+ )
722
+
723
+
724
+ class StructuredCaseRecord(BaseModel):
725
+ case_id: str = Field(description="Stable case identifier for export, demo, or interoperability surfaces.")
726
+ patient_id: str = Field(description="Patient identifier copied from the intake profile.")
727
+ age: int | None = Field(default=None, description="Approximate patient age in years, if provided.")
728
+ sex: SexType = Field(description="Self-reported sex captured during intake.")
729
+ diet_type: DietType = Field(description="Self-reported diet pattern captured during intake.")
730
+ symptoms: list[str] = Field(default_factory=list, description="Active symptoms captured for this case.")
731
+ image_quality: StructuredCaseImageQuality = Field(description="Structured image quality summary.")
732
+ screening_result: StructuredCaseScreeningResult = Field(description="Structured screening result summary.")
733
+ recommendation: str = Field(description="Primary next-step recommendation for this case.")
734
+ case_summary: str = Field(description="Short clinician-facing summary sentence.")
735
+
736
+
737
  class AnalysisMeta(BaseModel):
738
  request_id: str = Field(description="Short request identifier copied from the API response headers.")
739
  generated_at: str = Field(description="Local timestamp when the response payload was assembled.")
 
746
  description="Which inference path reached the final result."
747
  )
748
  guidance_source: GuidanceSource = Field(
749
+ description="Whether guidance came from Mistral or the rule-based fallback."
750
  )
751
  used_raw_frame_rescue: bool = Field(
752
  description="True when the backend rescued a framing-limited case using the full-frame path."
 
764
  class GuidanceRuntimeStatus(BaseModel):
765
  active_strategy: GuidanceSource
766
  mistral_enabled: bool = False
 
767
  client_ready: bool = False
768
  api_key_configured: bool = False
769
+ mistral_model: str | None = None
770
  provider: str | None = None
771
  fallback_reason: str | None = None
772
  last_provider_error: str | None = None
 
779
  artifact_ready: bool = False
780
  artifact_path: str | None = None
781
  load_error: str | None = None
782
+ runtime_calibration_ready: bool | None = None
783
+ runtime_calibration_method: str | None = None
784
+ runtime_calibrated_threshold: float | None = None
785
+ runtime_calibration_ece_before: float | None = None
786
+ runtime_calibration_ece_after: float | None = None
787
+ runtime_calibration_brier_before: float | None = None
788
+ runtime_calibration_brier_after: float | None = None
789
+ runtime_refiner_ready: bool | None = None
790
+ runtime_refiner_method: str | None = None
791
+ runtime_refined_threshold: float | None = None
792
+ runtime_refined_accuracy: float | None = None
793
+ runtime_refined_precision: float | None = None
794
+ runtime_refined_recall: float | None = None
795
+ runtime_refined_f1: float | None = None
796
  record_count: int | None = None
797
  validation_accuracy: float | None = None
798
  validation_f1: float | None = None
 
845
  clinical_brief: ClinicalBrief
846
  handoff_summary: HandoffSummary
847
  analysis_meta: AnalysisMeta
848
+ patient_profile: PatientProfile
849
+ workflow_stages: list[WorkflowStage] = Field(
850
+ description="Explicit multi-step screening workflow stages for this run.",
851
+ min_length=4,
852
+ )
853
+ structured_case: StructuredCaseRecord = Field(
854
+ description="FHIR-style structured case summary suitable for provider-facing views or export."
855
+ )
856
  symptoms: SymptomInput
857
  language: str | None = Field(default=None, description="BCP-47 language tag or plain name.")
858
  region: str | None = Field(default=None, description="Geographic region for localised guidance.")
backend/app/services/clinical_brief.py CHANGED
@@ -216,9 +216,9 @@ class ClinicalBriefService:
216
  checks.append("The fallback rescue path was labeled transparently in the audit trail.")
217
  if decision_audit.review_flags:
218
  checks.append("Structured review flags were generated for follow-up and UI display.")
219
- if guidance.source == "qwen":
220
  checks.append(
221
- "Qwen guidance was constrained to the screening result, uncertainty, symptoms, and locale context."
222
  )
223
  else:
224
  checks.append("Rule-based fallback guidance stayed grounded to the current result and symptoms.")
 
216
  checks.append("The fallback rescue path was labeled transparently in the audit trail.")
217
  if decision_audit.review_flags:
218
  checks.append("Structured review flags were generated for follow-up and UI display.")
219
+ if guidance.source == "mistral":
220
  checks.append(
221
+ "Mistral guidance was constrained to the screening result, uncertainty, symptoms, and locale context."
222
  )
223
  else:
224
  checks.append("Rule-based fallback guidance stayed grounded to the current result and symptoms.")
backend/app/services/email_report.py CHANGED
@@ -353,64 +353,252 @@ class EmailReportService:
353
  def _build_plain_text(self, payload: EmailReportContent) -> str:
354
  hb_line = self._hemoglobin_line(payload.predicted_hemoglobin)
355
  risk_pct = round(payload.anemia_risk * 100)
 
 
356
  return (
357
- "AnemiaLens Screening Result Report\n"
358
- "================================\n\n"
359
- f"Triage Label: {payload.triage_label}\n"
360
- f"Anemia Risk Score: {risk_pct}%\n"
361
- f"{hb_line}\n\n"
362
- "Summary\n"
363
- "-------\n"
364
- f"{payload.share_text.strip()}\n\n"
365
- "Important\n"
366
- "---------\n"
367
- f"{SCREENING_DISCLAIMER} Please confirm results with a clinical blood test (CBC).\n\n"
368
- "AnemiaLens\n"
369
- "https://anemia-lens.vercel.app\n"
 
 
 
370
  )
371
 
372
- def _build_html(self, payload: EmailReportContent) -> str:
373
- hb_label, hb_value = self._hemoglobin_parts(payload.predicted_hemoglobin)
374
- risk_pct = round(payload.anemia_risk * 100)
375
- share_html = "<br />".join(
376
- escape(line) for line in payload.share_text.strip().splitlines() if line.strip()
377
- )
378
- return f"""<!DOCTYPE html>
379
- <html lang="en">
380
- <head>
381
- <meta charset="utf-8" />
382
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
383
- <title>AnemiaLens Screening Report</title>
384
- </head>
385
- <body style="margin:0;padding:0;background:#0d0d0d;color:#edede8;font-family:Arial,sans-serif;">
386
- <div style="max-width:600px;margin:0 auto;padding:32px 24px;">
387
- <div style="border-bottom:2px solid #c8001e;padding-bottom:16px;margin-bottom:24px;">
388
- <div style="font-size:22px;font-weight:700;color:#c8001e;letter-spacing:-0.03em;">AnemiaLens</div>
389
- <div style="font-size:12px;color:#888;margin-top:4px;">Screening Result Report</div>
390
- </div>
391
- <div style="display:inline-block;padding:6px 16px;border-radius:999px;font-size:12px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;background:rgba(200,0,30,0.15);border:1px solid rgba(200,0,30,0.4);color:#ff6b7b;">
392
- {escape(payload.triage_label)}
393
- </div>
394
- <div style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:16px 20px;margin:12px 0;">
395
- <div style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:0.12em;margin-bottom:4px;">Anemia Risk Score</div>
396
- <div style="font-size:20px;font-weight:700;color:#edede8;">{risk_pct}%</div>
397
- </div>
398
- <div style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:16px 20px;margin:12px 0;">
399
- <div style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:0.12em;margin-bottom:4px;">{escape(hb_label)}</div>
400
- <div style="font-size:20px;font-weight:700;color:#edede8;">{escape(hb_value)}</div>
401
- </div>
402
- <div style="background:rgba(255,255,255,0.02);border-left:3px solid #c8001e;border-radius:8px;padding:16px 20px;margin:20px 0;font-size:13px;line-height:1.7;color:#d6d6cf;">
403
- {share_html}
404
- </div>
405
- <div style="font-size:11px;color:#9b9b93;line-height:1.6;margin-top:24px;padding:12px 16px;border:1px solid rgba(255,255,255,0.06);border-radius:8px;">
406
- <strong>Important:</strong> {escape(SCREENING_DISCLAIMER)} Please confirm results with a clinical blood test (CBC).
407
- </div>
408
- <div style="margin-top:32px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.06);font-size:11px;color:#777;text-align:center;">
409
- AnemiaLens · https://anemia-lens.vercel.app · No lab. No needle. Just a smartphone.
410
- </div>
411
- </div>
412
- </body>
413
- </html>"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
 
415
  def _hemoglobin_parts(self, predicted_hemoglobin: float | None) -> tuple[str, str]:
416
  if predicted_hemoglobin is None:
 
353
  def _build_plain_text(self, payload: EmailReportContent) -> str:
354
  hb_line = self._hemoglobin_line(payload.predicted_hemoglobin)
355
  risk_pct = round(payload.anemia_risk * 100)
356
+ next_steps = "\n".join(f"- {step}" for step in self._recommended_steps(payload))
357
+ summary_line = self._email_result_story(payload)
358
  return (
359
+ "AnemiaLens Screening Result Report\n"
360
+ "================================\n\n"
361
+ f"Triage Label: {payload.triage_label}\n"
362
+ f"Anemia Risk Score: {risk_pct}%\n"
363
+ f"{hb_line}\n\n"
364
+ "Summary\n"
365
+ "-------\n"
366
+ f"{summary_line}\n\n"
367
+ "Recommended Next Steps\n"
368
+ "----------------------\n"
369
+ f"{next_steps}\n\n"
370
+ "Important\n"
371
+ "---------\n"
372
+ f"{SCREENING_DISCLAIMER} Please confirm results with a clinical blood test (CBC).\n\n"
373
+ "AnemiaLens\n"
374
+ "https://anemia-lens.vercel.app\n"
375
  )
376
 
377
+ def _build_html(self, payload: EmailReportContent) -> str:
378
+ hb_label, hb_value = self._hemoglobin_parts(payload.predicted_hemoglobin)
379
+ risk_pct = round(payload.anemia_risk * 100)
380
+ accent, accent_soft, accent_border, status_line = self._triage_theme(payload)
381
+ summary_line = self._email_result_story(payload)
382
+ detail_line = self._email_supporting_detail(payload)
383
+ steps_html = "".join(
384
+ f"""
385
+ <tr>
386
+ <td style="padding:0 0 10px 0;">
387
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0">
388
+ <tr>
389
+ <td width="28" valign="top" style="padding-top:2px;">
390
+ <div style="width:18px;height:18px;border-radius:999px;background:{accent_soft};border:1px solid {accent_border};font-size:11px;line-height:18px;text-align:center;color:{accent};font-weight:700;">•</div>
391
+ </td>
392
+ <td style="font-size:14px;line-height:1.6;color:#1e293b;">
393
+ {escape(step)}
394
+ </td>
395
+ </tr>
396
+ </table>
397
+ </td>
398
+ </tr>"""
399
+ for step in self._recommended_steps(payload)
400
+ )
401
+ return f"""<!DOCTYPE html>
402
+ <html lang="en">
403
+ <head>
404
+ <meta charset="utf-8" />
405
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
406
+ <title>AnemiaLens Screening Report</title>
407
+ </head>
408
+ <body style="margin:0;padding:0;background:#eef2f7;color:#0f172a;font-family:Arial,sans-serif;">
409
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#eef2f7;">
410
+ <tr>
411
+ <td align="center" style="padding:32px 16px;">
412
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border:1px solid #dbe4ee;border-radius:24px;overflow:hidden;box-shadow:0 20px 60px rgba(15,23,42,0.08);">
413
+ <tr>
414
+ <td style="padding:28px 32px;background:#0f172a;">
415
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0">
416
+ <tr>
417
+ <td align="left">
418
+ <div style="font-size:24px;font-weight:700;letter-spacing:-0.03em;color:#ffffff;">AnemiaLens</div>
419
+ <div style="margin-top:6px;font-size:13px;line-height:1.6;color:#94a3b8;">Smartphone-first screening summary, ready to review or share.</div>
420
+ </td>
421
+ <td align="right" valign="top">
422
+ <div style="display:inline-block;padding:8px 14px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;background:{accent_soft};border:1px solid {accent_border};color:{accent};">
423
+ {escape(payload.triage_label)}
424
+ </div>
425
+ </td>
426
+ </tr>
427
+ </table>
428
+ </td>
429
+ </tr>
430
+ <tr>
431
+ <td style="padding:28px 32px 12px 32px;">
432
+ <div style="font-size:18px;font-weight:700;line-height:1.4;color:#0f172a;">{escape(status_line)}</div>
433
+ <div style="margin-top:8px;font-size:14px;line-height:1.7;color:#475569;">
434
+ This email keeps the screening story short: what the result means, the estimated hemoglobin context, and what to do next.
435
+ </div>
436
+ </td>
437
+ </tr>
438
+ <tr>
439
+ <td style="padding:8px 32px 0 32px;">
440
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0">
441
+ <tr>
442
+ <td width="50%" style="padding-right:8px;padding-bottom:16px;">
443
+ <div style="padding:18px;border:1px solid #dbe4ee;border-radius:18px;background:#f8fafc;">
444
+ <div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin-bottom:8px;">Anemia Risk Score</div>
445
+ <div style="font-size:30px;font-weight:700;color:{accent};line-height:1;">{risk_pct}%</div>
446
+ </div>
447
+ </td>
448
+ <td width="50%" style="padding-left:8px;padding-bottom:16px;">
449
+ <div style="padding:18px;border:1px solid #dbe4ee;border-radius:18px;background:#f8fafc;">
450
+ <div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin-bottom:8px;">{escape(hb_label)}</div>
451
+ <div style="font-size:24px;font-weight:700;color:#0f172a;line-height:1.2;">{escape(hb_value)}</div>
452
+ </div>
453
+ </td>
454
+ </tr>
455
+ </table>
456
+ </td>
457
+ </tr>
458
+ <tr>
459
+ <td style="padding:8px 32px 0 32px;">
460
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #dbe4ee;border-radius:18px;background:#ffffff;">
461
+ <tr>
462
+ <td style="padding:20px;">
463
+ <div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin-bottom:10px;">Why this result</div>
464
+ <div style="font-size:15px;line-height:1.75;color:#1e293b;font-weight:600;margin-bottom:12px;">
465
+ {escape(summary_line)}
466
+ </div>
467
+ <div style="font-size:13px;line-height:1.7;color:#475569;">
468
+ {escape(detail_line)}
469
+ </div>
470
+ </td>
471
+ </tr>
472
+ </table>
473
+ </td>
474
+ </tr>
475
+ <tr>
476
+ <td style="padding:18px 32px 0 32px;">
477
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #dbe4ee;border-radius:18px;background:#f8fafc;">
478
+ <tr>
479
+ <td style="padding:20px 20px 8px 20px;">
480
+ <div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin-bottom:10px;">Recommended next steps</div>
481
+ </td>
482
+ </tr>
483
+ {steps_html}
484
+ </table>
485
+ </td>
486
+ </tr>
487
+ <tr>
488
+ <td style="padding:18px 32px 0 32px;">
489
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#fff7ed;border:1px solid #fed7aa;border-radius:18px;">
490
+ <tr>
491
+ <td style="padding:18px 20px;font-size:13px;line-height:1.7;color:#7c2d12;">
492
+ <strong>Important:</strong> {escape(SCREENING_DISCLAIMER)} Please confirm results with a clinical blood test (CBC).
493
+ </td>
494
+ </tr>
495
+ </table>
496
+ </td>
497
+ </tr>
498
+ <tr>
499
+ <td style="padding:24px 32px 32px 32px;">
500
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0">
501
+ <tr>
502
+ <td align="left" style="font-size:12px;line-height:1.7;color:#64748b;">
503
+ Sent by AnemiaLens for quick review and clinician handoff.
504
+ </td>
505
+ <td align="right">
506
+ <a href="https://anemia-lens.vercel.app" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#0f172a;color:#ffffff;text-decoration:none;font-size:13px;font-weight:700;">Open AnemiaLens</a>
507
+ </td>
508
+ </tr>
509
+ </table>
510
+ </td>
511
+ </tr>
512
+ </table>
513
+ </td>
514
+ </tr>
515
+ </table>
516
+ </body>
517
+ </html>"""
518
+
519
+ def _triage_theme(self, payload: EmailReportContent) -> tuple[str, str, str, str]:
520
+ triage = payload.triage_label.lower()
521
+ if "high" in triage:
522
+ return (
523
+ "#dc2626",
524
+ "#fee2e2",
525
+ "#fecaca",
526
+ "High concern detected. Please prioritize follow-up quickly.",
527
+ )
528
+ if "moderate" in triage:
529
+ return (
530
+ "#d97706",
531
+ "#fef3c7",
532
+ "#fde68a",
533
+ "Moderate risk detected. A clinical follow-up is worth arranging soon.",
534
+ )
535
+ if "uncertain" in triage:
536
+ return (
537
+ "#7c3aed",
538
+ "#ede9fe",
539
+ "#ddd6fe",
540
+ "The scan was not strong enough for a confident call, so a retake is the safest next step.",
541
+ )
542
+ return (
543
+ "#059669",
544
+ "#dcfce7",
545
+ "#bbf7d0",
546
+ "No urgent concern was detected, but routine monitoring is still sensible.",
547
+ )
548
+
549
+ def _email_result_story(self, payload: EmailReportContent) -> str:
550
+ triage = payload.triage_label.lower()
551
+ if "high" in triage:
552
+ return (
553
+ "The screening found a strong low-hemoglobin pattern, so prompt clinical follow-up is the safest next step."
554
+ )
555
+ if "moderate" in triage:
556
+ return (
557
+ "The screening found a moderate low-hemoglobin pattern. It is not an emergency alert, but it is worth reviewing with a clinician soon."
558
+ )
559
+ if "uncertain" in triage:
560
+ return (
561
+ "The current image was not reliable enough for a confident screening call, so a cleaner retake or clinician review is safer than over-interpreting it."
562
+ )
563
+ return (
564
+ "The screening did not show a strong urgent low-hemoglobin pattern, though routine monitoring remains sensible."
565
+ )
566
+
567
+ def _email_supporting_detail(self, payload: EmailReportContent) -> str:
568
+ risk_pct = round(payload.anemia_risk * 100)
569
+ if payload.predicted_hemoglobin is None:
570
+ hb_detail = "The hemoglobin estimate was withheld because confidence was limited."
571
+ else:
572
+ hb_detail = f"The estimated hemoglobin for this run was {payload.predicted_hemoglobin:.1f} g/dL."
573
+ return (
574
+ f"This run produced a {risk_pct}% anemia-risk score. {hb_detail} Use the next steps below as a simple follow-up guide, not as a diagnosis."
575
+ )
576
+
577
+ def _recommended_steps(self, payload: EmailReportContent) -> list[str]:
578
+ triage = payload.triage_label.lower()
579
+ if "high" in triage:
580
+ return [
581
+ "Arrange a CBC blood test as soon as possible and avoid delaying clinical review.",
582
+ "Share this summary with a clinician or family member who can help coordinate care.",
583
+ "Seek urgent medical attention sooner if symptoms worsen or new warning signs appear.",
584
+ ]
585
+ if "moderate" in triage:
586
+ return [
587
+ "Book a follow-up with a healthcare provider within 1-2 weeks and discuss confirmatory blood work.",
588
+ "Keep track of fatigue, dizziness, or shortness of breath if they continue.",
589
+ "Consider a clearer retake if the original scan had quality warnings.",
590
+ ]
591
+ if "uncertain" in triage:
592
+ return [
593
+ "Retake the image in brighter, steadier lighting with the lower eyelid fully visible.",
594
+ "Use this summary only as a retake reminder, not as a final decision.",
595
+ "If symptoms are present, do not wait for a retake before speaking with a clinician.",
596
+ ]
597
+ return [
598
+ "Maintain a balanced diet and monitor for any new or worsening symptoms.",
599
+ "Repeat screening in 3-6 months or sooner if your health changes.",
600
+ "Use this report as a simple summary if you want to discuss the result with a provider later.",
601
+ ]
602
 
603
  def _hemoglobin_parts(self, predicted_hemoglobin: float | None) -> tuple[str, str]:
604
  if predicted_hemoglobin is None:
backend/app/services/guidance.py CHANGED
@@ -23,12 +23,13 @@ _FIELD_LIMITS = {
23
  "urgency_guidance": 280,
24
  "food_advice": 300,
25
  }
26
- _UNSAFE_CLAIM_PATTERN = re.compile(
27
- r"\b(definitely\s+(?:have|has|anemic|anaemic)|confirmed\s+(?:anemia|anaemia)|"
28
- r"you\s+(?:have|are)\s+(?:anemia|anaemia|anemic|anaemic)|"
29
- r"proves?\s+(?:anemia|anaemia)|proof\s+of\s+anemia)\b",
30
- flags=re.IGNORECASE,
31
- )
 
32
  _SAFE_DIAGNOSTIC_CONTEXT_PATTERNS = (
33
  re.compile(r"\bnot a diagnos(?:is|tic)\b", flags=re.IGNORECASE),
34
  re.compile(r"\bnon-diagnostic\b", flags=re.IGNORECASE),
@@ -220,10 +221,80 @@ class GuidanceService:
220
  "This is screening guidance, not a diagnosis."
221
  )
222
 
223
- def _generate_mistral(
224
- self,
225
- payload: dict[str, object],
226
- *,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  triage_band: str,
228
  predicted_hemoglobin: float | None,
229
  confidence: float | None,
@@ -254,16 +325,16 @@ class GuidanceService:
254
  "Authorization": f"Bearer {settings.mistral_api_key}",
255
  "Content-Type": "application/json",
256
  }
257
- body = {
258
- "model": self.mistral_model,
259
- "messages": [
260
- {"role": "system", "content": self._system_prompt()},
261
- {"role": "user", "content": self._user_prompt(payload)},
262
- ],
263
- "max_tokens": self.guidance_max_tokens,
264
- "temperature": 0.4,
265
- "response_format": {"type": "json_object"},
266
- }
267
  log.info("POST %s model=%s max_tokens=%s", _MISTRAL_API_URL, self.mistral_model, self.guidance_max_tokens)
268
  resp = _requests.post(_MISTRAL_API_URL, headers=headers, json=body, timeout=self.guidance_timeout)
269
  log.info("Mistral HTTP %s", resp.status_code)
@@ -473,14 +544,13 @@ class GuidanceService:
473
  def runtime_status(self) -> GuidanceRuntimeStatus:
474
  provider_healthy = self._mistral_ready() and self._last_provider_error is None
475
  active_strategy: Literal["mistral", "fallback"] = "mistral" if provider_healthy else "fallback"
476
- return GuidanceRuntimeStatus(
477
- active_strategy=active_strategy,
478
- mistral_enabled=self.mistral_enabled,
479
- qwen_enabled=self.mistral_enabled,
480
- client_ready=self._mistral_ready(),
481
- api_key_configured=self.api_key_configured,
482
- qwen_model=self.mistral_model if self.mistral_enabled else None,
483
- provider="mistral" if self.mistral_enabled else None,
484
- fallback_reason=self._fallback_reason or (self._last_provider_error if not provider_healthy else None),
485
- last_provider_error=self._last_provider_error,
486
- )
 
23
  "urgency_guidance": 280,
24
  "food_advice": 300,
25
  }
26
+ _UNSAFE_CLAIM_PATTERN = re.compile(
27
+ r"\b(definitely\s+(?:confirms?|have|has|anemic|anaemic)|confirm(?:ed|s)?\s+(?:anemia|anaemia)|"
28
+ r"you\s+(?:have|are)\s+(?:anemia|anaemia|anemic|anaemic)|"
29
+ r"diagnoses?\s+(?:anemia|anaemia|iron deficiency)|"
30
+ r"proves?\s+(?:anemia|anaemia)|proof\s+of\s+anemia)\b",
31
+ flags=re.IGNORECASE,
32
+ )
33
  _SAFE_DIAGNOSTIC_CONTEXT_PATTERNS = (
34
  re.compile(r"\bnot a diagnos(?:is|tic)\b", flags=re.IGNORECASE),
35
  re.compile(r"\bnon-diagnostic\b", flags=re.IGNORECASE),
 
221
  "This is screening guidance, not a diagnosis."
222
  )
223
 
224
+ def _mistral_system_prompt(self) -> str:
225
+ return (
226
+ "You are Mistral, writing the guidance section for AnemiaLens, a smartphone anemia screening tool. "
227
+ "The system analyzes the inner lower eyelid, combines that signal with symptom input, and returns a screening band: low_risk, moderate_risk, high_concern, or uncertain_retake_needed. "
228
+ "This is screening only, never a diagnosis, and every answer must stay medically cautious.\n\n"
229
+ "Write like a calm clinician or health educator speaking to one person right after their screening. "
230
+ "Sound natural, specific, and grounded in the payload. "
231
+ "Do not sound like a marketing blurb, a lab report template, or a generic wellness article. "
232
+ "Use the hemoglobin estimate, risk score, symptom pattern, and reliability limits to explain what this case means.\n\n"
233
+ "STYLE RULES:\n"
234
+ "- Never say 'you have anemia', 'you are anemic', or any other diagnostic claim\n"
235
+ "- Prefer phrases like 'this screening leans toward', 'this result suggests', or 'this pattern points to'\n"
236
+ "- Mention uncertainty when confidence is limited or reliability is low\n"
237
+ "- Avoid stock phrases like 'calls for closer attention', 'maintain a balanced diet', or 'monitor symptoms' unless you also say why or when\n"
238
+ "- Never invent symptoms, treatments, lab values, or medical history not present in the payload\n"
239
+ "- Keep the tone human, direct, and reassuring without sounding casual\n\n"
240
+ "Return ONLY valid JSON with exactly these keys: explanation, urgency_guidance, food_advice, next_steps.\n"
241
+ "explanation: 2 or 3 sentences. Sentence 1 says what the screening leans toward. Sentence 2 explains why using the actual signal, symptoms, or risk. Sentence 3 is optional and should only be used to explain uncertainty or reassurance.\n"
242
+ "urgency_guidance: 1 or 2 sentences with a concrete follow-up window tied to the triage band.\n"
243
+ "food_advice: 1 sentence with concrete iron-supportive foods, adapted to the region when possible.\n"
244
+ "next_steps: array of 3 or 4 short actions that are specific, non-repetitive, and realistic.\n"
245
+ "No markdown, no extra keys, no preamble."
246
+ )
247
+
248
+ def _mistral_user_prompt(self, payload: dict[str, object]) -> str:
249
+ hb = payload.get("predicted_hemoglobin")
250
+ risk_pct = payload.get("prediction_risk_percent")
251
+ conf_pct = payload.get("confidence_percent")
252
+ uncertainty_pct = payload.get("uncertainty_percent")
253
+ reliability_flag = payload.get("reliability_flag") or "unknown"
254
+ band = payload.get("triage_band", "unknown")
255
+ label = payload.get("triage_label", "")
256
+ active_symptoms = payload.get("active_symptoms") or []
257
+ region = payload.get("region") or "not specified"
258
+ screening_text = payload.get("screening_text") or ""
259
+ screening_label = payload.get("screening_label") or "unknown"
260
+
261
+ hb_str = f"{hb} g/dL" if hb is not None else "not available"
262
+ if hb is not None:
263
+ if hb >= 12.0:
264
+ hb_context = "within normal range"
265
+ elif hb >= 10.0:
266
+ hb_context = "mildly below normal"
267
+ elif hb >= 8.0:
268
+ hb_context = "moderately below normal"
269
+ else:
270
+ hb_context = "severely below normal"
271
+ else:
272
+ hb_context = "unknown"
273
+
274
+ symptom_str = ", ".join(active_symptoms) if active_symptoms else "none reported"
275
+
276
+ return (
277
+ f"AnemiaLens Screening Result:\n"
278
+ f"- Hemoglobin estimate: {hb_str} ({hb_context})\n"
279
+ f"- Anemia risk score: {risk_pct}%\n"
280
+ f"- Screening label: {screening_label}\n"
281
+ f"- Model confidence: {conf_pct}%\n"
282
+ f"- Uncertainty: {uncertainty_pct}%\n"
283
+ f"- Reliability flag: {reliability_flag}\n"
284
+ f"- Triage band: {band} ({label})\n"
285
+ f"- Active symptoms: {symptom_str}\n"
286
+ f"- Region: {region}\n"
287
+ f"- Model screening text: {screening_text}\n\n"
288
+ "Write personalized guidance for this person based on the above. "
289
+ "Interpret what these findings mean instead of repeating them. "
290
+ "If reliability is limited, say that clearly in plain language. "
291
+ "Make it sound like a real clinician explaining a screening result, not a report template."
292
+ )
293
+
294
+ def _generate_mistral(
295
+ self,
296
+ payload: dict[str, object],
297
+ *,
298
  triage_band: str,
299
  predicted_hemoglobin: float | None,
300
  confidence: float | None,
 
325
  "Authorization": f"Bearer {settings.mistral_api_key}",
326
  "Content-Type": "application/json",
327
  }
328
+ body = {
329
+ "model": self.mistral_model,
330
+ "messages": [
331
+ {"role": "system", "content": self._mistral_system_prompt()},
332
+ {"role": "user", "content": self._mistral_user_prompt(payload)},
333
+ ],
334
+ "max_tokens": self.guidance_max_tokens,
335
+ "temperature": 0.55,
336
+ "response_format": {"type": "json_object"},
337
+ }
338
  log.info("POST %s model=%s max_tokens=%s", _MISTRAL_API_URL, self.mistral_model, self.guidance_max_tokens)
339
  resp = _requests.post(_MISTRAL_API_URL, headers=headers, json=body, timeout=self.guidance_timeout)
340
  log.info("Mistral HTTP %s", resp.status_code)
 
544
  def runtime_status(self) -> GuidanceRuntimeStatus:
545
  provider_healthy = self._mistral_ready() and self._last_provider_error is None
546
  active_strategy: Literal["mistral", "fallback"] = "mistral" if provider_healthy else "fallback"
547
+ return GuidanceRuntimeStatus(
548
+ active_strategy=active_strategy,
549
+ mistral_enabled=self.mistral_enabled,
550
+ client_ready=self._mistral_ready(),
551
+ api_key_configured=self.api_key_configured,
552
+ mistral_model=self.mistral_model if self.mistral_enabled else None,
553
+ provider="mistral" if self.mistral_enabled else None,
554
+ fallback_reason=self._fallback_reason or (self._last_provider_error if not provider_healthy else None),
555
+ last_provider_error=self._last_provider_error,
556
+ )
 
backend/app/services/image_quality.py CHANGED
@@ -42,8 +42,18 @@ class ImageQualityService:
42
  center_contrast = float(feature_map["center_contrast"])
43
  bright_region_ratio = float(feature_map["hist_bright"])
44
  highlight_ratio = float(feature_map["hist_highlight"])
 
45
  frame_score = float(framing_score(feature_map))
46
  edge_blur = edge_blur_baseline(image)
 
 
 
 
 
 
 
 
 
47
 
48
  issues: list[QualityIssue] = []
49
 
@@ -94,6 +104,11 @@ class ImageQualityService:
94
  brightness_score=round(brightness_score, 3),
95
  contrast_score=round(contrast_score, 3),
96
  framing_score=round(frame_score, 3),
 
 
 
 
 
97
  issues=issues,
98
  )
99
  return assessment, image
@@ -173,8 +188,8 @@ class ImageQualityService:
173
  QualityIssue(
174
  code="poor_lighting",
175
  severity="blocking",
176
- title="Lighting is not usable",
177
- message="Use bright, even light without flash glare or heavy shadows.",
178
  )
179
  )
180
  elif lighting_warn:
@@ -182,8 +197,8 @@ class ImageQualityService:
182
  QualityIssue(
183
  code="poor_lighting",
184
  severity="warning",
185
- title="Lighting could be better",
186
- message="The model can try this image, but even light will improve reliability.",
187
  )
188
  )
189
 
@@ -229,10 +244,149 @@ class ImageQualityService:
229
  brightness_score=round(brightness_score, 3),
230
  contrast_score=round(contrast_score, 3),
231
  framing_score=round(frame_score, 3),
 
 
 
 
 
232
  issues=issues,
233
  )
234
  return assessment, image
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  def _soften_salvageable_roi_blocks(
237
  self,
238
  issues: list[QualityIssue],
@@ -274,8 +428,7 @@ class ImageQualityService:
274
  def allows_raw_frame_rescue(self, assessment: QualityAssessment) -> bool:
275
  blocking_codes = {issue.code for issue in assessment.issues if issue.severity == "blocking"}
276
  return bool(blocking_codes) and (
277
- blocking_codes.issubset({"bad_framing", "eye_not_visible"})
278
- or blocking_codes == {"poor_lighting"}
279
  )
280
 
281
  def build_raw_frame_rescue_assessment(self, assessment: QualityAssessment) -> QualityAssessment:
 
42
  center_contrast = float(feature_map["center_contrast"])
43
  bright_region_ratio = float(feature_map["hist_bright"])
44
  highlight_ratio = float(feature_map["hist_highlight"])
45
+ dark_region_ratio = float(feature_map["hist_dark"])
46
  frame_score = float(framing_score(feature_map))
47
  edge_blur = edge_blur_baseline(image)
48
+ lighting_score, lighting_condition, lighting_summary, glare_risk, shadow_risk = self._lighting_intelligence(
49
+ brightness_score=brightness_score,
50
+ contrast_score=contrast_score,
51
+ center_brightness=center_brightness,
52
+ center_contrast=center_contrast,
53
+ bright_region_ratio=bright_region_ratio,
54
+ highlight_ratio=highlight_ratio,
55
+ dark_region_ratio=dark_region_ratio,
56
+ )
57
 
58
  issues: list[QualityIssue] = []
59
 
 
104
  brightness_score=round(brightness_score, 3),
105
  contrast_score=round(contrast_score, 3),
106
  framing_score=round(frame_score, 3),
107
+ lighting_score=round(lighting_score, 3),
108
+ lighting_condition=lighting_condition,
109
+ lighting_summary=lighting_summary,
110
+ glare_risk=round(glare_risk, 3),
111
+ shadow_risk=round(shadow_risk, 3),
112
  issues=issues,
113
  )
114
  return assessment, image
 
188
  QualityIssue(
189
  code="poor_lighting",
190
  severity="blocking",
191
+ title=self._lighting_issue_title(lighting_condition, blocking=True),
192
+ message=self._lighting_issue_message(lighting_condition, blocking=True),
193
  )
194
  )
195
  elif lighting_warn:
 
197
  QualityIssue(
198
  code="poor_lighting",
199
  severity="warning",
200
+ title=self._lighting_issue_title(lighting_condition, blocking=False),
201
+ message=self._lighting_issue_message(lighting_condition, blocking=False),
202
  )
203
  )
204
 
 
244
  brightness_score=round(brightness_score, 3),
245
  contrast_score=round(contrast_score, 3),
246
  framing_score=round(frame_score, 3),
247
+ lighting_score=round(lighting_score, 3),
248
+ lighting_condition=lighting_condition,
249
+ lighting_summary=lighting_summary,
250
+ glare_risk=round(glare_risk, 3),
251
+ shadow_risk=round(shadow_risk, 3),
252
  issues=issues,
253
  )
254
  return assessment, image
255
 
256
+ def _lighting_intelligence(
257
+ self,
258
+ *,
259
+ brightness_score: float,
260
+ contrast_score: float,
261
+ center_brightness: float,
262
+ center_contrast: float,
263
+ bright_region_ratio: float,
264
+ highlight_ratio: float,
265
+ dark_region_ratio: float,
266
+ ) -> tuple[float, str, str, float, float]:
267
+ glare_risk = min(
268
+ 1.0,
269
+ highlight_ratio * 7.5
270
+ + max(0.0, bright_region_ratio - 0.18) * 1.9
271
+ + max(0.0, center_brightness - 0.48) * 2.2,
272
+ )
273
+ shadow_risk = min(
274
+ 1.0,
275
+ dark_region_ratio * 1.1
276
+ + max(0.0, 0.18 - center_brightness) * 3.0
277
+ + max(0.0, 0.1 - brightness_score) * 2.0,
278
+ )
279
+ exposure_balance = max(0.0, 1.0 - (abs(center_brightness - 0.28) / 0.24))
280
+ contrast_health = max(0.0, min(1.0, self._scaled(center_contrast, 0.06, 0.19)))
281
+ lighting_score = max(
282
+ 0.0,
283
+ min(
284
+ 1.0,
285
+ exposure_balance * 0.38
286
+ + contrast_health * 0.27
287
+ + (1.0 - glare_risk) * 0.2
288
+ + (1.0 - shadow_risk) * 0.15,
289
+ ),
290
+ )
291
+
292
+ if glare_risk >= 0.72:
293
+ return (
294
+ lighting_score,
295
+ "glare_heavy",
296
+ "Bright highlights or flash glare are washing out the eyelid surface, so the redness signal is less trustworthy.",
297
+ glare_risk,
298
+ shadow_risk,
299
+ )
300
+ if shadow_risk >= 0.72:
301
+ return (
302
+ lighting_score,
303
+ "shadow_heavy",
304
+ "Shadows are covering part of the eyelid, so the model may miss the true pallor signal.",
305
+ glare_risk,
306
+ shadow_risk,
307
+ )
308
+ if brightness_score < 0.12 or center_brightness < 0.16:
309
+ return (
310
+ lighting_score,
311
+ "dim",
312
+ "The capture is underexposed, which makes fine color differences harder to measure reliably.",
313
+ glare_risk,
314
+ shadow_risk,
315
+ )
316
+ if brightness_score > 0.46 or center_brightness > 0.52:
317
+ return (
318
+ lighting_score,
319
+ "overexposed",
320
+ "The image is brighter than ideal, so the conjunctiva can lose detail even without obvious glare.",
321
+ glare_risk,
322
+ shadow_risk,
323
+ )
324
+ if contrast_score < 0.12 or center_contrast < 0.08:
325
+ return (
326
+ lighting_score,
327
+ "flat_contrast",
328
+ "The lighting is too flat, so the conjunctival tissue boundaries are less distinct than ideal.",
329
+ glare_risk,
330
+ shadow_risk,
331
+ )
332
+ return (
333
+ lighting_score,
334
+ "balanced",
335
+ "Lighting is balanced enough for the model to read color and texture without strong glare or shadows.",
336
+ glare_risk,
337
+ shadow_risk,
338
+ )
339
+
340
+ def _lighting_issue_title(self, lighting_condition: str, *, blocking: bool) -> str:
341
+ if lighting_condition == "glare_heavy":
342
+ return "Glare is covering the eyelid" if blocking else "Glare is slightly affecting the scan"
343
+ if lighting_condition == "shadow_heavy":
344
+ return "Shadows are hiding the eyelid" if blocking else "Shadows are reducing clarity"
345
+ if lighting_condition == "dim":
346
+ return "Image is too dim" if blocking else "Lighting is a little dim"
347
+ if lighting_condition == "overexposed":
348
+ return "Image is overexposed" if blocking else "Lighting is a little bright"
349
+ if lighting_condition == "flat_contrast":
350
+ return "Image lacks contrast" if blocking else "Contrast could be stronger"
351
+ return "Lighting is not usable" if blocking else "Lighting could be better"
352
+
353
+ def _lighting_issue_message(self, lighting_condition: str, *, blocking: bool) -> str:
354
+ if lighting_condition == "glare_heavy":
355
+ return (
356
+ "Turn off flash, tilt away from shiny reflections, and use soft room light or window light."
357
+ if blocking
358
+ else "The model can try this image, but removing glare will improve reliability."
359
+ )
360
+ if lighting_condition == "shadow_heavy":
361
+ return (
362
+ "Face a window or room light so the eyelid is evenly lit without one side falling into shadow."
363
+ if blocking
364
+ else "The model can try this image, but even front lighting will improve reliability."
365
+ )
366
+ if lighting_condition == "dim":
367
+ return (
368
+ "Move to brighter light and keep the phone steady so the inner eyelid stays visible."
369
+ if blocking
370
+ else "The model can try this image, but brighter light will improve reliability."
371
+ )
372
+ if lighting_condition == "overexposed":
373
+ return (
374
+ "Step away from direct flash or strong overhead light so the eyelid texture is not washed out."
375
+ if blocking
376
+ else "The model can try this image, but slightly softer light will improve reliability."
377
+ )
378
+ if lighting_condition == "flat_contrast":
379
+ return (
380
+ "Use clearer side-neutral daylight or room lighting so the eyelid tissue stands out better."
381
+ if blocking
382
+ else "The model can try this image, but better contrast will improve reliability."
383
+ )
384
+ return (
385
+ "Use bright, even light without flash glare or heavy shadows."
386
+ if blocking
387
+ else "The model can try this image, but even light will improve reliability."
388
+ )
389
+
390
  def _soften_salvageable_roi_blocks(
391
  self,
392
  issues: list[QualityIssue],
 
428
  def allows_raw_frame_rescue(self, assessment: QualityAssessment) -> bool:
429
  blocking_codes = {issue.code for issue in assessment.issues if issue.severity == "blocking"}
430
  return bool(blocking_codes) and (
431
+ blocking_codes.issubset({"bad_framing", "eye_not_visible", "poor_lighting"})
 
432
  )
433
 
434
  def build_raw_frame_rescue_assessment(self, assessment: QualityAssessment) -> QualityAssessment:
backend/app/services/patient_case.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from app.schemas import (
4
+ GuidanceResult,
5
+ PatientProfile,
6
+ PatientProfileInput,
7
+ PredictionResult,
8
+ QualityAssessment,
9
+ StructuredCaseImageQuality,
10
+ StructuredCaseRecord,
11
+ StructuredCaseScreeningResult,
12
+ SymptomInput,
13
+ TriageResult,
14
+ WorkflowStage,
15
+ )
16
+
17
+ _SYMPTOM_LABELS = {
18
+ "fatigue": "Fatigue",
19
+ "dizziness": "Dizziness",
20
+ "pale_skin": "Pale skin",
21
+ "shortness_of_breath": "Shortness of breath",
22
+ "heavy_menstrual_bleeding": "Heavy menstrual bleeding",
23
+ "poor_diet_low_iron": "Low iron intake",
24
+ }
25
+
26
+
27
+ class PatientCaseService:
28
+ def build_profile(
29
+ self,
30
+ request_id: str,
31
+ patient_input: PatientProfileInput,
32
+ symptoms: SymptomInput,
33
+ ) -> PatientProfile:
34
+ patient_id = f"ANM-{request_id.upper()[-6:]}"
35
+ reported_symptoms = self._active_symptoms(symptoms)
36
+ descriptor = self._patient_descriptor(patient_input)
37
+ symptom_line = (
38
+ f"Reported symptoms: {self._join_human(reported_symptoms)}."
39
+ if reported_symptoms
40
+ else "No symptoms were reported in intake."
41
+ )
42
+ summary = f"{descriptor} {symptom_line}".strip()
43
+
44
+ return PatientProfile(
45
+ patient_id=patient_id,
46
+ age=patient_input.age,
47
+ sex=patient_input.sex,
48
+ diet_type=patient_input.diet_type,
49
+ reported_symptoms=reported_symptoms,
50
+ summary=summary,
51
+ )
52
+
53
+ def build_workflow_stages(
54
+ self,
55
+ quality: QualityAssessment,
56
+ prediction: PredictionResult | None,
57
+ triage: TriageResult,
58
+ guidance: GuidanceResult,
59
+ symptoms: SymptomInput,
60
+ ) -> list[WorkflowStage]:
61
+ return [
62
+ WorkflowStage(
63
+ key="image_quality_agent",
64
+ agent_label="Image Quality Agent",
65
+ title="Capture validation",
66
+ status=self._quality_status(quality),
67
+ summary=self._quality_summary(quality),
68
+ ),
69
+ WorkflowStage(
70
+ key="screening_agent",
71
+ agent_label="Screening Agent",
72
+ title="Conjunctiva screening",
73
+ status=self._screening_status(quality, prediction),
74
+ summary=self._screening_summary(quality, prediction),
75
+ ),
76
+ WorkflowStage(
77
+ key="triage_agent",
78
+ agent_label="Triage Agent",
79
+ title="Symptom + image fusion",
80
+ status="complete",
81
+ summary=self._triage_summary(triage, symptoms),
82
+ ),
83
+ WorkflowStage(
84
+ key="guidance_agent",
85
+ agent_label="Guidance Agent",
86
+ title="Next-step guidance",
87
+ status="complete",
88
+ summary=self._guidance_summary(guidance),
89
+ ),
90
+ ]
91
+
92
+ def build_structured_case(
93
+ self,
94
+ request_id: str,
95
+ patient_profile: PatientProfile,
96
+ quality: QualityAssessment,
97
+ prediction: PredictionResult | None,
98
+ triage: TriageResult,
99
+ guidance: GuidanceResult,
100
+ symptoms: SymptomInput,
101
+ ) -> StructuredCaseRecord:
102
+ active_symptoms = self._active_symptoms(symptoms)
103
+ primary_issue = quality.issues[0].title if quality.issues else None
104
+ warnings = [issue.title for issue in quality.warning_issues]
105
+ recommendation = guidance.next_steps[0] if guidance.next_steps else guidance.urgency_guidance
106
+
107
+ return StructuredCaseRecord(
108
+ case_id=f"CASE-{request_id.upper()[-6:]}",
109
+ patient_id=patient_profile.patient_id,
110
+ age=patient_profile.age,
111
+ sex=patient_profile.sex,
112
+ diet_type=patient_profile.diet_type,
113
+ symptoms=active_symptoms,
114
+ image_quality=StructuredCaseImageQuality(
115
+ status=self._structured_quality_status(quality),
116
+ lighting_condition=quality.lighting_condition,
117
+ lighting_score=quality.lighting_score,
118
+ blur_detected=any(issue.code == "blur_detected" for issue in quality.issues),
119
+ eye_region_visible=not any(issue.code == "eye_not_visible" for issue in quality.issues),
120
+ primary_issue=primary_issue,
121
+ warnings=warnings,
122
+ ),
123
+ screening_result=StructuredCaseScreeningResult(
124
+ risk_level=triage.band,
125
+ confidence=prediction.confidence if prediction else None,
126
+ reliability=prediction.reliability_flag if prediction else None,
127
+ predicted_hemoglobin=prediction.predicted_hemoglobin if prediction else None,
128
+ anemia_risk=prediction.anemia_risk if prediction else None,
129
+ ),
130
+ recommendation=recommendation,
131
+ case_summary=self._case_summary(triage, active_symptoms, prediction),
132
+ )
133
+
134
+ def _active_symptoms(self, symptoms: SymptomInput) -> list[str]:
135
+ return [
136
+ label
137
+ for field, label in _SYMPTOM_LABELS.items()
138
+ if getattr(symptoms, field) is True
139
+ ]
140
+
141
+ def _patient_descriptor(self, patient_input: PatientProfileInput) -> str:
142
+ parts: list[str] = []
143
+ if patient_input.age is not None:
144
+ parts.append(f"{patient_input.age}-year-old")
145
+ if patient_input.sex != "not_specified":
146
+ parts.append(patient_input.sex.replace("_", " "))
147
+ descriptor = " ".join(parts).strip()
148
+
149
+ diet = (
150
+ f"{patient_input.diet_type.replace('_', ' ')} diet"
151
+ if patient_input.diet_type != "not_specified"
152
+ else None
153
+ )
154
+
155
+ if descriptor and diet:
156
+ return f"{descriptor.capitalize()} on a {diet}."
157
+ if descriptor:
158
+ return f"{descriptor.capitalize()}."
159
+ if diet:
160
+ return f"Intake recorded with a {diet}."
161
+ return "Basic intake context captured."
162
+
163
+ def _join_human(self, items: list[str]) -> str:
164
+ if not items:
165
+ return "none"
166
+ if len(items) == 1:
167
+ return items[0]
168
+ if len(items) == 2:
169
+ return f"{items[0]} and {items[1]}"
170
+ return f"{', '.join(items[:-1])}, and {items[-1]}"
171
+
172
+ def _quality_status(self, quality: QualityAssessment) -> str:
173
+ if not quality.passed:
174
+ return "blocked"
175
+ if quality.warning_issues:
176
+ return "warning"
177
+ return "passed"
178
+
179
+ def _quality_summary(self, quality: QualityAssessment) -> str:
180
+ if not quality.passed:
181
+ issue = quality.issues[0] if quality.issues else None
182
+ if issue is None:
183
+ return "The capture failed the safety gate and needs a retake before screening can continue."
184
+ return f"{issue.title} blocked the capture, so the workflow stayed retake-first."
185
+ if quality.warning_issues:
186
+ warnings = ", ".join(issue.title.lower() for issue in quality.warning_issues[:2])
187
+ return (
188
+ f"The image passed with {quality.lighting_condition.replace('_', ' ')} lighting, "
189
+ f"but warnings remained: {warnings}."
190
+ )
191
+ return (
192
+ f"The image passed the safety gate with {quality.lighting_condition.replace('_', ' ')} lighting "
193
+ "and a usable conjunctiva view."
194
+ )
195
+
196
+ def _screening_status(self, quality: QualityAssessment, prediction: PredictionResult | None) -> str:
197
+ if not quality.passed or prediction is None:
198
+ return "blocked"
199
+ if prediction.reliability_flag == "low":
200
+ return "warning"
201
+ return "passed"
202
+
203
+ def _screening_summary(self, quality: QualityAssessment, prediction: PredictionResult | None) -> str:
204
+ if not quality.passed or prediction is None:
205
+ return "Screening inference was skipped because the image quality gate did not allow a safe prediction."
206
+ hb_text = (
207
+ "hemoglobin estimate withheld"
208
+ if prediction.predicted_hemoglobin is None
209
+ else f"estimated hemoglobin {prediction.predicted_hemoglobin:.1f} g/dL"
210
+ )
211
+ return (
212
+ f"The screening model produced a {round(prediction.anemia_risk * 100)}% anemia-like signal, "
213
+ f"{hb_text}, and {round(prediction.confidence * 100)}% confidence."
214
+ )
215
+
216
+ def _triage_summary(self, triage: TriageResult, symptoms: SymptomInput) -> str:
217
+ symptom_count = symptoms.active_count
218
+ symptom_text = (
219
+ "no active symptoms"
220
+ if symptom_count == 0
221
+ else f"{symptom_count} symptom{'s' if symptom_count != 1 else ''}"
222
+ )
223
+ return (
224
+ f"The triage layer combined the image signal with {symptom_text} and assigned {triage.label.lower()}."
225
+ )
226
+
227
+ def _guidance_summary(self, guidance: GuidanceResult) -> str:
228
+ first_step = guidance.next_steps[0] if guidance.next_steps else guidance.urgency_guidance
229
+ source_label = "Mistral guidance" if guidance.source == "mistral" else "Rule-based guidance"
230
+ return f"{source_label} translated the case into a next step: {first_step}"
231
+
232
+ def _structured_quality_status(self, quality: QualityAssessment) -> str:
233
+ if not quality.passed:
234
+ return "blocked"
235
+ if quality.warning_issues:
236
+ return "warning"
237
+ return "acceptable"
238
+
239
+ def _case_summary(
240
+ self,
241
+ triage: TriageResult,
242
+ active_symptoms: list[str],
243
+ prediction: PredictionResult | None,
244
+ ) -> str:
245
+ if prediction is None:
246
+ return "Case requires repeat image capture because the quality gate blocked a safe screening interpretation."
247
+ symptom_context = (
248
+ "with no additional symptom burden"
249
+ if not active_symptoms
250
+ else f"with reported symptoms including {self._join_human(active_symptoms)}"
251
+ )
252
+ return (
253
+ f"Patient shows {triage.label.lower()} based on the conjunctiva image signal {symptom_context}."
254
+ )
backend/app/services/prediction.py CHANGED
@@ -1,191 +1,235 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
- from typing import Literal
5
-
6
- import numpy as np
7
  from PIL import Image
8
 
9
- from app.config import DEFAULT_ARCHIVE_MODEL_PATH, DEFAULT_EFFICIENTNET_MODEL_PATH
10
- from app.ml.calibration import CompositeCalibrator
 
 
 
 
 
 
11
  from app.ml.features import extract_eye_features
12
- from app.ml.roi_confidence import RoiConfidenceScorer
13
  from app.schemas import ModelRuntimeStatus, PredictionResult, QualityAssessment
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- _CALIBRATOR_PATH = Path(__file__).parent.parent / "artifacts" / "calibrator.pkl"
16
-
17
-
18
- def clamp(value: float, lower: float = 0.0, upper: float = 1.0) -> float:
19
- return max(lower, min(upper, value))
20
-
21
-
22
- def _runtime_stack_version() -> str:
23
- from app.ml.runtime_stack import RUNTIME_STACK_VERSION
24
-
25
- return RUNTIME_STACK_VERSION
26
-
27
-
28
- def _efficientnet_version() -> str:
29
- from app.ml.efficientnet_model import EFFICIENTNET_VERSION
30
-
31
- return EFFICIENTNET_VERSION
32
 
33
 
34
- def _decision_threshold_for_source(
35
- source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"] = "roi_original",
36
- ) -> float:
37
- from app.ml.runtime_stack import decision_threshold_for_source
38
 
39
- return float(decision_threshold_for_source(source_hint))
40
 
41
 
42
- def _load_archive_model_artifact(path: str | Path) -> dict[str, object]:
43
- from app.ml.archive_model import load_archive_model
44
 
45
- return load_archive_model(path)
46
 
47
 
48
  def _predict_archive_model(
49
  artifact: dict[str, object],
50
  feature_map: dict[str, float],
51
- *,
52
- source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"] = "roi_original",
53
- ) -> dict[str, float]:
54
- from app.ml.archive_model import predict_with_archive_model
55
-
56
- return predict_with_archive_model(artifact, feature_map, source_hint=source_hint)
57
-
58
-
59
- def _load_efficientnet_checkpoint_bundle(path: str | Path) -> dict[str, object]:
60
- from app.ml.efficientnet_model import load_efficientnet_checkpoint
61
-
62
- return load_efficientnet_checkpoint(path)
63
-
64
-
65
- def _predict_efficientnet_bundle(
66
- bundle: dict[str, object],
67
- image: Image.Image,
68
- *,
69
- mc_passes: int,
70
- ) -> dict[str, float]:
71
- from app.ml.efficientnet_model import predict_with_efficientnet_model
72
-
73
- return predict_with_efficientnet_model(bundle, image, mc_passes=mc_passes)
74
-
75
-
76
- def _build_runtime_stack(
77
- archive_prediction: dict[str, float],
78
- *,
79
- efficientnet_prediction: dict[str, float] | None = None,
80
- source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"] = "roi_original",
81
- ) -> dict[str, float]:
82
- from app.ml.runtime_stack import build_runtime_stack_prediction
83
-
84
- return build_runtime_stack_prediction(
85
- archive_prediction,
86
- efficientnet_prediction=efficientnet_prediction,
87
- source_hint=source_hint,
88
- )
89
 
90
 
91
  class ScreeningPredictor:
92
  def __init__(self, model_path: str | Path | None = None) -> None:
93
  self.efficientnet_path = Path(DEFAULT_EFFICIENTNET_MODEL_PATH)
94
  self.model_path = Path(model_path or DEFAULT_ARCHIVE_MODEL_PATH)
 
 
 
95
  self.load_error: str | None = None
96
  self.efficientnet_bundle: dict[str, object] | None = None
97
  self.archive_model: dict[str, object] | None = None
 
 
98
  self._archive_model_load_attempted = False
99
  self._efficientnet_model_load_attempted = False
100
- self._calibrator = self._load_calibrator()
101
- self._roi_scorer = RoiConfidenceScorer()
102
 
103
  def preload(self) -> None:
104
  self._ensure_archive_model_loaded()
105
- if self.archive_model is None:
 
 
106
  self._ensure_efficientnet_model_loaded()
107
 
108
- def predict(self, image: Image.Image, quality: QualityAssessment, symptom_score: float = 0.0) -> PredictionResult:
109
- prediction: dict[str, float] | None = None
110
- model_source = "missing-model"
111
- decision_threshold = 0.5
112
- feature_map = extract_eye_features(image)
113
- source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"] = "roi_original"
114
-
115
- self._ensure_archive_model_loaded()
116
- if self.archive_model is not None:
117
  try:
118
- # EfficientNet was trained for only 1 epoch (AUC ~0.56 = near-random).
119
- # Blending it with the archive model degrades predictions.
120
- # Skip it until a properly trained checkpoint is available.
121
  efficientnet_secondary: dict[str, float] | None = None
122
-
123
- archive_prediction = _predict_archive_model(
124
- self.archive_model,
125
- feature_map,
126
- source_hint=source_hint,
127
- )
 
 
 
 
 
 
 
 
 
 
 
128
  prediction = _build_runtime_stack(
129
  archive_prediction,
130
  efficientnet_prediction=efficientnet_secondary,
131
  source_hint=source_hint,
132
  )
 
 
 
 
 
 
 
 
 
133
  model_source = _runtime_stack_version()
134
- decision_threshold = float(prediction.get("decision_threshold", _decision_threshold_for_source(source_hint)))
135
- except Exception as exc:
136
- self.load_error = f"Archive inference failed: {type(exc).__name__}: {exc}"
137
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  if prediction is None:
139
- self._ensure_efficientnet_model_loaded()
140
- if prediction is None and self.efficientnet_bundle is not None:
141
- try:
142
- prediction = _predict_efficientnet_bundle(
143
- self.efficientnet_bundle,
144
- image,
145
- mc_passes=4, # reduced from 16 to lower peak RAM on Render
146
- )
147
- model_source = str(self.efficientnet_bundle.get("version", _efficientnet_version()))
148
- decision_threshold = float(prediction.get("decision_threshold", 0.5))
149
- except Exception as exc:
150
- self.load_error = f"EfficientNet inference failed: {type(exc).__name__}: {exc}"
151
-
152
- if prediction is None:
153
- return PredictionResult(
154
- anemia_risk=0.5,
155
- predicted_hemoglobin=None,
156
- confidence=0.0,
157
- uncertainty=1.0,
158
- reliability_flag="low",
159
- screening_label="uncertain",
160
- screening_text="No screening model artifact is available yet, so the safest result is uncertain.",
161
- model_source="missing-model",
162
- )
163
- risk = float(prediction["anemia_risk"])
164
- # Apply probability calibration if available
165
- risk = self._calibrator.calibrate(risk)
166
- uncertainty = float(prediction["uncertainty"])
167
- predicted_hemoglobin_raw = float(prediction["predicted_hemoglobin"])
168
- predicted_hemoglobin = round(predicted_hemoglobin_raw, 2)
169
-
170
- # --- Symptom-driven post-processing --------------------------------
171
- # When symptoms are present, they provide real clinical signal.
172
- # Blend symptom evidence into risk and Hb even for real model outputs.
173
- if symptom_score > 0.0:
174
- # Symptoms push risk up: all symptoms (score=1.0) adds up to +0.30
175
- symptom_risk_boost = symptom_score * 0.30
176
- risk = float(np.clip(risk + symptom_risk_boost * (1.0 - risk), 0.0, 1.0))
177
- # Symptoms lower Hb estimate: all symptoms → up to -2.5 g/dL
178
- symptom_hb_penalty = symptom_score * 2.5
179
- predicted_hemoglobin_raw = float(np.clip(predicted_hemoglobin_raw - symptom_hb_penalty, 6.0, 18.0))
180
- predicted_hemoglobin = round(predicted_hemoglobin_raw, 2)
181
- # Symptoms reduce uncertainty slightly (more signal available)
182
- uncertainty = float(np.clip(uncertainty - symptom_score * 0.08, 0.05, 0.88))
183
- quality_delta = 0.0
184
- if quality.framing_score < 1.15:
185
- quality_delta += 0.08
186
- elif quality.framing_score >= 1.8:
187
- quality_delta -= 0.06
188
- elif quality.framing_score >= 1.45:
189
  quality_delta -= 0.03
190
 
191
  if quality.blur_score < 80:
@@ -201,125 +245,363 @@ class ScreeningPredictor:
201
  quality_delta += 0.02
202
  elif 0.09 <= quality.brightness_score <= 0.38:
203
  quality_delta -= 0.03
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
- if quality.contrast_score < 0.12:
206
- quality_delta += 0.04
207
- elif quality.contrast_score >= 0.18:
208
- quality_delta -= 0.02
209
-
210
- uncertainty = clamp(uncertainty + quality_delta, 0.05, 0.88)
211
- guardrail_triggered = self._dark_signal_guardrail(
212
- risk=risk,
213
- predicted_hemoglobin=predicted_hemoglobin,
214
- feature_map=feature_map,
215
- threshold=decision_threshold,
216
- )
217
- if guardrail_triggered:
218
- uncertainty = max(uncertainty, 0.35)
219
-
220
- confidence = clamp(1.0 - uncertainty)
221
- reliability_flag = (
222
- "high"
223
- if uncertainty < 0.35 and quality.passed
224
- else "medium"
225
- if uncertainty < 0.55 and quality.passed
226
- else "low"
227
- )
228
- predicted_hemoglobin = self._display_hemoglobin(predicted_hemoglobin, uncertainty)
229
- screening_label, screening_text = self._screening_decision(
230
- risk,
231
- uncertainty,
232
- decision_threshold,
233
- predicted_hemoglobin=predicted_hemoglobin_raw,
234
- signal_guardrail_triggered=guardrail_triggered,
235
- )
236
 
237
- return PredictionResult(
238
- anemia_risk=round(risk, 3),
239
- predicted_hemoglobin=predicted_hemoglobin,
240
- confidence=round(confidence, 3),
241
- uncertainty=round(uncertainty, 3),
242
- reliability_flag=reliability_flag,
243
- screening_label=screening_label,
244
- screening_text=screening_text,
245
- model_source=model_source,
246
- )
247
 
248
- def _load_calibrator(self) -> CompositeCalibrator:
249
  try:
250
- if _CALIBRATOR_PATH.exists():
251
- return CompositeCalibrator.load(_CALIBRATOR_PATH)
252
- except Exception:
253
- pass
254
- return CompositeCalibrator(method="none") # identity — no-op until trained
 
 
 
255
 
256
- def _ensure_archive_model_loaded(self) -> None:
 
 
257
  if self._archive_model_load_attempted:
258
- return
259
- self._archive_model_load_attempted = True
260
- self.archive_model = self._load_archive_model()
261
-
262
- def _ensure_efficientnet_model_loaded(self) -> None:
263
- if self._efficientnet_model_load_attempted:
264
- return
265
- self._efficientnet_model_load_attempted = True
266
- self.efficientnet_bundle = self._load_efficientnet_model()
267
-
268
- def _load_efficientnet_model(self) -> dict[str, object] | None:
269
- if not self.efficientnet_path.exists():
270
- return None
271
- try:
272
- return _load_efficientnet_checkpoint_bundle(self.efficientnet_path)
273
- except Exception as exc:
274
- self.load_error = f"EfficientNet load failed: {type(exc).__name__}: {exc}"
275
  return None
276
 
277
- def _load_archive_model(self) -> dict[str, object] | None:
278
  if not self.model_path.exists():
279
  if self.efficientnet_bundle is None:
280
  self.load_error = f"Model artifact not found at {self.model_path}"
281
  return None
282
- try:
283
- self.load_error = None
284
- return _load_archive_model_artifact(self.model_path)
 
 
 
285
  except Exception as exc:
286
  if self.efficientnet_bundle is None:
287
  self.load_error = f"{type(exc).__name__}: {exc}"
288
  return None
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  def is_ready(self) -> bool:
291
- return (
292
- self.archive_model is not None
293
- or self.efficientnet_bundle is not None
294
- or self.model_path.exists()
295
- or self.efficientnet_path.exists()
296
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
 
298
- def is_loaded(self) -> bool:
299
- return self.archive_model is not None or self.efficientnet_bundle is not None
 
 
 
 
300
 
301
- def runtime_status(self) -> ModelRuntimeStatus:
302
- archive_available = self.model_path.exists()
303
- efficientnet_available = self.efficientnet_path.exists()
304
  return ModelRuntimeStatus(
305
- primary_model=(
306
- _runtime_stack_version()
307
- if archive_available or self.archive_model is not None
308
- else str(self.efficientnet_bundle.get("version", _efficientnet_version()))
309
- if efficientnet_available or self.efficientnet_bundle is not None
310
- else "missing-model"
311
- ),
312
  deep_stack_loaded=False,
313
  legacy_loaded=False,
314
- artifact_ready=self.is_ready(),
315
- artifact_path=(
316
- str(self.model_path)
317
- if archive_available or self.archive_model is not None
318
- else str(self.efficientnet_path)
319
- if efficientnet_available or self.efficientnet_bundle is not None
320
- else None
321
- ),
322
  load_error=self.load_error,
 
 
323
  )
324
 
325
  def should_accept_raw_frame_rescue(self, prediction: PredictionResult) -> bool:
@@ -329,14 +611,31 @@ class ScreeningPredictor:
329
  or self._accept_raw_frame_uncertain_rescue(prediction)
330
  )
331
 
332
- def _accept_raw_frame_positive_rescue(self, prediction: PredictionResult) -> bool:
333
- return (
334
- prediction.screening_label == "anemia_likely"
335
- and prediction.predicted_hemoglobin is not None
336
- and prediction.anemia_risk >= 0.8
337
- and prediction.predicted_hemoglobin <= 11.2
338
- and prediction.uncertainty <= 0.5
339
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
  def _accept_raw_frame_negative_rescue(self, prediction: PredictionResult) -> bool:
342
  hidden_hb_negative = (
@@ -362,14 +661,17 @@ class ScreeningPredictor:
362
  prediction.screening_label == "uncertain"
363
  and prediction.anemia_risk <= 0.32
364
  and prediction.uncertainty <= 0.68
365
- and (prediction.predicted_hemoglobin is None or prediction.predicted_hemoglobin >= 12.8)
 
 
 
366
  )
367
 
368
- def _screening_decision(
369
- self,
370
- risk: float,
371
- uncertainty: float,
372
- threshold: float = 0.5,
373
  *,
374
  predicted_hemoglobin: float | None = None,
375
  signal_guardrail_triggered: bool = False,
@@ -382,94 +684,304 @@ class ScreeningPredictor:
382
  margin = abs(risk - threshold)
383
  mild_positive_conflict = (
384
  predicted_hemoglobin is not None
385
- and (
386
- (
387
- threshold <= risk < (threshold + 0.12)
388
- and predicted_hemoglobin > 13.0
389
- and uncertainty >= 0.65
390
- )
391
- or (
392
- threshold < 0.6
393
- and threshold <= risk < (threshold + 0.13)
394
- and predicted_hemoglobin >= 12.3
395
- and uncertainty >= 0.52
396
- )
397
- )
398
  )
399
  if mild_positive_conflict:
400
  return (
401
  "uncertain",
402
  "The screening signal is only mildly positive while the hemoglobin estimate stays near normal, so the safest interpretation is uncertain.",
403
  )
404
- strict_runtime_threshold = threshold >= 0.6
405
- allow_below_threshold_rescue = not strict_runtime_threshold
 
 
 
 
 
 
 
 
 
 
406
  high_suspicion_positive = (
407
  predicted_hemoglobin is not None
408
  and (
409
  (
410
  risk >= threshold
411
- and predicted_hemoglobin <= 12.2
412
- and uncertainty < 0.62
413
  )
414
  or (
415
- allow_below_threshold_rescue
416
- and (threshold - 0.02) <= risk < threshold
 
417
  and predicted_hemoglobin <= 12.4
418
  and uncertainty < 0.57
419
  )
420
  or (
421
- allow_below_threshold_rescue
422
- and (threshold - 0.05) <= risk < threshold
 
423
  and predicted_hemoglobin <= 12.25
424
  and uncertainty < 0.63
425
  )
426
  )
 
 
 
 
 
 
 
 
 
 
 
427
  )
428
- low_reliability_positive_requires_extra_evidence = (
429
- strict_runtime_threshold
430
- and predicted_hemoglobin is not None
431
- and risk >= threshold
432
- and uncertainty >= 0.55
433
- and risk < (threshold + 0.11)
434
- and predicted_hemoglobin > 11.4
 
 
435
  )
436
- if low_reliability_positive_requires_extra_evidence:
 
 
 
 
 
437
  return (
438
  "uncertain",
439
- "The scan trends positive, but at this confidence level the model only upgrades to likely anemia when the risk margin is stronger or the hemoglobin estimate is more clearly low.",
440
  )
441
- if high_suspicion_positive:
442
  return (
443
  "anemia_likely",
444
- "The screening model sees a persistent low-hemoglobin signal, so this result should be treated as likely anemia despite moderate uncertainty.",
445
  )
446
- if uncertainty >= 0.75 or (margin < 0.08 and uncertainty >= 0.45):
447
- return (
448
- "uncertain",
449
- "The estimated hemoglobin trend is borderline or noisy, so the safest interpretation is uncertain.",
450
- )
451
- if risk >= threshold:
452
- return (
453
- "anemia_likely",
454
- "The screening model estimates a lower-than-expected hemoglobin trend from the eye image.",
455
- )
456
  return (
457
  "anemia_unlikely",
458
  "The screening model does not estimate a strong low-hemoglobin trend from the eye image.",
459
  )
460
 
461
- def _display_hemoglobin(self, predicted_hemoglobin: float | None, uncertainty: float) -> float | None:
462
- if predicted_hemoglobin is None:
463
- return None
464
- # Show Hb unless uncertainty is very high (was 0.70, loosened to 0.80
465
- # since the new model has higher base uncertainty on sparse feature vectors)
466
- if uncertainty >= 0.80:
467
- return None
468
- return round(clamp(predicted_hemoglobin, 6.0, 18.0), 2)
469
-
470
- def _dark_signal_guardrail(
471
- self,
472
- *,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  risk: float,
474
  predicted_hemoglobin: float | None,
475
  feature_map: dict[str, float],
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
 
6
  from PIL import Image
7
 
8
+ from app.config import (
9
+ DEFAULT_ARCHIVE_MODEL_PATH,
10
+ DEFAULT_EFFICIENTNET_MODEL_PATH,
11
+ DEFAULT_RUNTIME_CALIBRATOR_PATH,
12
+ DEFAULT_RUNTIME_REFINER_PATH,
13
+ settings,
14
+ )
15
+ from app.ml.archive_model import clamp
16
  from app.ml.features import extract_eye_features
 
17
  from app.schemas import ModelRuntimeStatus, PredictionResult, QualityAssessment
18
+
19
+
20
+ def _runtime_stack_version() -> str:
21
+ from app.ml.runtime_stack import RUNTIME_STACK_VERSION
22
+
23
+ return RUNTIME_STACK_VERSION
24
+
25
+
26
+ def _efficientnet_version() -> str:
27
+ from app.ml.efficientnet_model import EFFICIENTNET_VERSION
28
+
29
+ return EFFICIENTNET_VERSION
30
+
31
+
32
+ def _decision_threshold_for_source(
33
+ source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"],
34
+ ) -> float:
35
+ from app.ml.runtime_stack import decision_threshold_for_source
36
+
37
+ return float(decision_threshold_for_source(source_hint))
38
+
39
+
40
+ def _load_archive_model_artifact(path: Path) -> dict[str, object]:
41
+ from app.ml.archive_model import load_archive_model
42
 
43
+ return load_archive_model(path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
 
46
+ def _load_runtime_risk_calibrator_artifact(path: Path):
47
+ from app.ml.runtime_calibration import RuntimeRiskCalibrator
 
 
48
 
49
+ return RuntimeRiskCalibrator.load(path)
50
 
51
 
52
+ def _load_runtime_screening_refiner_artifact(path: Path):
53
+ from app.ml.runtime_refinement import RuntimeScreeningRefiner
54
 
55
+ return RuntimeScreeningRefiner.load(path)
56
 
57
 
58
  def _predict_archive_model(
59
  artifact: dict[str, object],
60
  feature_map: dict[str, float],
61
+ *,
62
+ source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"],
63
+ ) -> dict[str, float]:
64
+ from app.ml.archive_model import predict_with_archive_model
65
+
66
+ return predict_with_archive_model(artifact, feature_map, source_hint=source_hint)
67
+
68
+
69
+ def _build_runtime_stack(
70
+ archive_prediction: dict[str, float],
71
+ *,
72
+ efficientnet_prediction: dict[str, float] | None,
73
+ source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"],
74
+ ) -> dict[str, float]:
75
+ from app.ml.runtime_stack import build_runtime_stack_prediction
76
+
77
+ return build_runtime_stack_prediction(
78
+ archive_prediction,
79
+ efficientnet_prediction=efficientnet_prediction,
80
+ source_hint=source_hint,
81
+ )
82
+
83
+
84
+ def _load_efficientnet_checkpoint_bundle(path: Path) -> dict[str, object]:
85
+ from app.ml.efficientnet_model import load_efficientnet_checkpoint
86
+
87
+ return load_efficientnet_checkpoint(path)
88
+
89
+
90
+ def _predict_efficientnet_bundle(
91
+ bundle: dict[str, object],
92
+ image: Image.Image,
93
+ *,
94
+ mc_passes: int,
95
+ ) -> dict[str, float]:
96
+ from app.ml.efficientnet_model import predict_with_efficientnet_model
97
+
98
+ return predict_with_efficientnet_model(bundle, image, mc_passes=mc_passes)
99
 
100
 
101
  class ScreeningPredictor:
102
  def __init__(self, model_path: str | Path | None = None) -> None:
103
  self.efficientnet_path = Path(DEFAULT_EFFICIENTNET_MODEL_PATH)
104
  self.model_path = Path(model_path or DEFAULT_ARCHIVE_MODEL_PATH)
105
+ self.runtime_calibrator_path = Path(DEFAULT_RUNTIME_CALIBRATOR_PATH)
106
+ self.runtime_refiner_path = Path(DEFAULT_RUNTIME_REFINER_PATH)
107
+ self.enable_efficientnet_fallback = settings.enable_efficientnet_fallback
108
  self.load_error: str | None = None
109
  self.efficientnet_bundle: dict[str, object] | None = None
110
  self.archive_model: dict[str, object] | None = None
111
+ self.runtime_risk_calibrator = None
112
+ self.runtime_screening_refiner = None
113
  self._archive_model_load_attempted = False
114
  self._efficientnet_model_load_attempted = False
115
+ self._runtime_risk_calibrator_load_attempted = False
116
+ self._runtime_screening_refiner_load_attempted = False
117
 
118
  def preload(self) -> None:
119
  self._ensure_archive_model_loaded()
120
+ self._ensure_runtime_risk_calibrator_loaded()
121
+ self._ensure_runtime_screening_refiner_loaded()
122
+ if self.enable_efficientnet_fallback:
123
  self._ensure_efficientnet_model_loaded()
124
 
125
+ def predict(self, image: Image.Image, quality: QualityAssessment) -> PredictionResult:
126
+ prediction: dict[str, float] | None = None
127
+ model_source = "missing-model"
128
+ decision_threshold = 0.5
129
+ feature_map = extract_eye_features(image)
130
+ source_hint: Literal["roi_original", "palpebral", "forniceal_palpebral"] = "roi_original"
131
+
132
+ archive_model = self._ensure_archive_model_loaded()
133
+ if archive_model is not None:
134
  try:
 
 
 
135
  efficientnet_secondary: dict[str, float] | None = None
136
+ if self.enable_efficientnet_fallback:
137
+ efficientnet_bundle = self._ensure_efficientnet_model_loaded()
138
+ if efficientnet_bundle is not None:
139
+ try:
140
+ efficientnet_secondary = _predict_efficientnet_bundle(
141
+ efficientnet_bundle,
142
+ image,
143
+ mc_passes=4,
144
+ )
145
+ except Exception:
146
+ efficientnet_secondary = None
147
+
148
+ archive_prediction = _predict_archive_model(
149
+ archive_model,
150
+ feature_map,
151
+ source_hint=source_hint,
152
+ )
153
  prediction = _build_runtime_stack(
154
  archive_prediction,
155
  efficientnet_prediction=efficientnet_secondary,
156
  source_hint=source_hint,
157
  )
158
+ runtime_risk_calibrator = self._ensure_runtime_risk_calibrator_loaded()
159
+ if runtime_risk_calibrator is not None:
160
+ raw_runtime_risk = float(prediction["anemia_risk"])
161
+ prediction["raw_anemia_risk"] = raw_runtime_risk
162
+ prediction["calibrated_anemia_risk"] = runtime_risk_calibrator.calibrate(
163
+ raw_runtime_risk,
164
+ source_hint=source_hint,
165
+ )
166
+ prediction["calibration_method"] = runtime_risk_calibrator.method
167
  model_source = _runtime_stack_version()
168
+ decision_threshold = float(
169
+ prediction.get(
170
+ "decision_threshold",
171
+ _decision_threshold_for_source(source_hint),
172
+ )
173
+ )
174
+ except Exception as exc:
175
+ self.load_error = f"Archive inference failed: {type(exc).__name__}: {exc}"
176
+
177
+ if prediction is None and self.enable_efficientnet_fallback:
178
+ efficientnet_bundle = self._ensure_efficientnet_model_loaded()
179
+ if efficientnet_bundle is not None:
180
+ try:
181
+ prediction = _predict_efficientnet_bundle(
182
+ efficientnet_bundle,
183
+ image,
184
+ mc_passes=4,
185
+ )
186
+ model_source = str(
187
+ efficientnet_bundle.get("version", _efficientnet_version())
188
+ )
189
+ decision_threshold = float(
190
+ prediction.get("decision_threshold", 0.5)
191
+ )
192
+ except Exception as exc:
193
+ self.load_error = (
194
+ f"EfficientNet inference failed: {type(exc).__name__}: {exc}"
195
+ )
196
+
197
  if prediction is None:
198
+ return PredictionResult(
199
+ anemia_risk=0.5,
200
+ predicted_hemoglobin=None,
201
+ confidence=0.0,
202
+ uncertainty=1.0,
203
+ reliability_flag="low",
204
+ screening_label="uncertain",
205
+ screening_text="No screening model artifact is available yet, so the safest result is uncertain.",
206
+ model_source="missing-model",
207
+ confidence_breakdown={
208
+ "capture_quality": 0.0,
209
+ "model_stability": 0.0,
210
+ "threshold_stability": 0.0,
211
+ "guardrail_applied": False,
212
+ "lighting_condition": quality.lighting_condition,
213
+ "glare_risk": round(quality.glare_risk, 3),
214
+ "shadow_risk": round(quality.shadow_risk, 3),
215
+ "summary": "No model artifact is available, so the confidence story is unavailable.",
216
+ },
217
+ )
218
+
219
+ risk = float(prediction["anemia_risk"])
220
+ raw_uncertainty = float(prediction["uncertainty"])
221
+ uncertainty = raw_uncertainty
222
+ predicted_hemoglobin_raw = float(prediction["predicted_hemoglobin"])
223
+ predicted_hemoglobin = round(predicted_hemoglobin_raw, 2)
224
+ calibrated_risk = float(prediction.get("calibrated_anemia_risk", risk))
225
+ capture_quality_score = self._capture_quality_score(quality)
226
+ model_stability = clamp(1.0 - raw_uncertainty, 0.0, 1.0)
227
+ quality_delta = 0.0
228
+ if quality.framing_score < 1.15:
229
+ quality_delta += 0.08
230
+ elif quality.framing_score >= 1.8:
231
+ quality_delta -= 0.06
232
+ elif quality.framing_score >= 1.45:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  quality_delta -= 0.03
234
 
235
  if quality.blur_score < 80:
 
245
  quality_delta += 0.02
246
  elif 0.09 <= quality.brightness_score <= 0.38:
247
  quality_delta -= 0.03
248
+
249
+ if quality.contrast_score < 0.12:
250
+ quality_delta += 0.04
251
+ elif quality.contrast_score >= 0.18:
252
+ quality_delta -= 0.02
253
+
254
+ if quality.lighting_score < 0.38:
255
+ quality_delta += 0.08
256
+ elif quality.lighting_score < 0.6:
257
+ quality_delta += 0.03
258
+ elif quality.lighting_score >= 0.8:
259
+ quality_delta -= 0.03
260
+
261
+ if quality.glare_risk > 0.65:
262
+ quality_delta += 0.05
263
+ elif quality.glare_risk > 0.35:
264
+ quality_delta += 0.02
265
+
266
+ if quality.shadow_risk > 0.65:
267
+ quality_delta += 0.05
268
+ elif quality.shadow_risk > 0.35:
269
+ quality_delta += 0.02
270
+
271
+ if quality.lighting_condition in {"glare_heavy", "shadow_heavy"}:
272
+ quality_delta += 0.12
273
+ elif quality.lighting_condition in {"overexposed", "flat_contrast"}:
274
+ quality_delta += 0.05
275
+ elif quality.lighting_condition == "dim":
276
+ quality_delta += 0.02
277
+
278
+ negative_case_confidence_bonus = self._negative_case_confidence_bonus(
279
+ risk=risk,
280
+ threshold=decision_threshold,
281
+ predicted_hemoglobin=predicted_hemoglobin_raw,
282
+ quality=quality,
283
+ capture_quality_score=capture_quality_score,
284
+ model_stability=model_stability,
285
+ )
286
+ if self._is_clear_negative_case(
287
+ risk=risk,
288
+ threshold=decision_threshold,
289
+ predicted_hemoglobin=predicted_hemoglobin_raw,
290
+ quality=quality,
291
+ capture_quality_score=capture_quality_score,
292
+ ):
293
+ quality_delta = min(quality_delta, 0.12)
294
+ elif (
295
+ risk < decision_threshold
296
+ and predicted_hemoglobin_raw >= 12.8
297
+ and quality.passed
298
+ and capture_quality_score >= 0.42
299
+ ):
300
+ quality_delta = min(quality_delta, 0.16)
301
+
302
+ uncertainty = clamp(
303
+ uncertainty + quality_delta - negative_case_confidence_bonus,
304
+ 0.05,
305
+ 0.88,
306
+ )
307
+ guardrail_triggered = self._dark_signal_guardrail(
308
+ risk=risk,
309
+ predicted_hemoglobin=predicted_hemoglobin,
310
+ feature_map=feature_map,
311
+ threshold=decision_threshold,
312
+ )
313
+ if guardrail_triggered:
314
+ uncertainty = max(uncertainty, 0.35)
315
+
316
+ predicted_hemoglobin = self._display_hemoglobin(
317
+ predicted_hemoglobin, uncertainty
318
+ )
319
+ base_screening_label, base_screening_text = self._screening_decision(
320
+ risk,
321
+ uncertainty,
322
+ decision_threshold,
323
+ predicted_hemoglobin=predicted_hemoglobin_raw,
324
+ signal_guardrail_triggered=guardrail_triggered,
325
+ )
326
+ runtime_screening_refiner = self._ensure_runtime_screening_refiner_loaded()
327
+ refined_risk = risk
328
+ if runtime_screening_refiner is not None:
329
+ refined_risk = runtime_screening_refiner.refine(
330
+ base_anemia_risk=risk,
331
+ uncertainty=uncertainty,
332
+ predicted_hemoglobin=predicted_hemoglobin,
333
+ quality=quality,
334
+ base_likely=(base_screening_label == "anemia_likely"),
335
+ )
336
+
337
+ threshold_stability = clamp(
338
+ max(
339
+ abs(risk - decision_threshold),
340
+ abs(calibrated_risk - decision_threshold),
341
+ abs(refined_risk - decision_threshold),
342
+ )
343
+ / 0.18,
344
+ 0.0,
345
+ 1.0,
346
+ )
347
+ signal_strength = clamp(
348
+ abs(refined_risk - decision_threshold) / 0.22,
349
+ 0.0,
350
+ 1.0,
351
+ )
352
+ confidence = self._decision_confidence(
353
+ quality=quality,
354
+ uncertainty=uncertainty,
355
+ capture_quality_score=capture_quality_score,
356
+ model_stability=model_stability,
357
+ threshold_stability=threshold_stability,
358
+ signal_strength=signal_strength,
359
+ guardrail_triggered=guardrail_triggered,
360
+ )
361
+ uncertainty = min(
362
+ uncertainty,
363
+ clamp(1.05 - confidence, 0.05, 1.0),
364
+ )
365
+ clear_negative_case = self._is_clear_negative_case(
366
+ risk=refined_risk,
367
+ threshold=decision_threshold,
368
+ predicted_hemoglobin=predicted_hemoglobin_raw,
369
+ quality=quality,
370
+ capture_quality_score=capture_quality_score,
371
+ )
372
+ severe_lighting_case = (
373
+ quality.lighting_condition in {"glare_heavy", "shadow_heavy"}
374
+ or quality.glare_risk > 0.65
375
+ or quality.shadow_risk > 0.65
376
+ )
377
+ reliability_flag = (
378
+ "low"
379
+ if (guardrail_triggered and severe_lighting_case)
380
+ else "high"
381
+ if (
382
+ (
383
+ uncertainty < 0.2
384
+ and quality.passed
385
+ and capture_quality_score >= 0.7
386
+ and threshold_stability >= 0.25
387
+ )
388
+ or (
389
+ clear_negative_case
390
+ and uncertainty < 0.38
391
+ and threshold_stability >= 0.62
392
+ )
393
+ )
394
+ else "medium"
395
+ if (
396
+ (
397
+ uncertainty < 0.35
398
+ and quality.passed
399
+ and capture_quality_score >= 0.5
400
+ )
401
+ or (
402
+ clear_negative_case
403
+ and uncertainty < 0.52
404
+ and quality.passed
405
+ and capture_quality_score >= 0.4
406
+ )
407
+ )
408
+ else "low"
409
+ )
410
+ if (
411
+ reliability_flag == "low"
412
+ and quality.passed
413
+ and not severe_lighting_case
414
+ and not guardrail_triggered
415
+ and confidence >= 0.68
416
+ and capture_quality_score >= 0.72
417
+ and threshold_stability >= 0.72
418
+ ):
419
+ reliability_flag = "medium"
420
+ confidence_breakdown = {
421
+ "capture_quality": round(capture_quality_score, 3),
422
+ "model_stability": round(model_stability, 3),
423
+ "threshold_stability": round(threshold_stability, 3),
424
+ "signal_strength": round(signal_strength, 3),
425
+ "guardrail_applied": guardrail_triggered,
426
+ "calibration_applied": bool(prediction.get("calibration_method")),
427
+ "calibration_method": str(prediction.get("calibration_method", "none")),
428
+ "refinement_applied": runtime_screening_refiner is not None,
429
+ "refinement_method": (
430
+ getattr(runtime_screening_refiner, "method", "none")
431
+ if runtime_screening_refiner is not None
432
+ else "none"
433
+ ),
434
+ "raw_anemia_risk": round(
435
+ float(prediction.get("raw_anemia_risk", risk)),
436
+ 3,
437
+ ),
438
+ "calibrated_anemia_risk": round(
439
+ float(prediction.get("calibrated_anemia_risk", risk)),
440
+ 3,
441
+ ),
442
+ "refined_anemia_risk": round(refined_risk, 3),
443
+ "decision_threshold": round(decision_threshold, 3),
444
+ "base_screening_label": base_screening_label,
445
+ "lighting_condition": quality.lighting_condition,
446
+ "glare_risk": round(quality.glare_risk, 3),
447
+ "shadow_risk": round(quality.shadow_risk, 3),
448
+ "summary": self._confidence_summary(
449
+ quality=quality,
450
+ capture_quality_score=capture_quality_score,
451
+ model_stability=model_stability,
452
+ threshold_stability=threshold_stability,
453
+ guardrail_triggered=guardrail_triggered,
454
+ risk=refined_risk,
455
+ threshold=decision_threshold,
456
+ predicted_hemoglobin=predicted_hemoglobin_raw,
457
+ ),
458
+ }
459
+ screening_label, screening_text = self._screening_decision(
460
+ refined_risk,
461
+ uncertainty,
462
+ decision_threshold,
463
+ predicted_hemoglobin=predicted_hemoglobin_raw,
464
+ signal_guardrail_triggered=guardrail_triggered,
465
+ )
466
+
467
+ return PredictionResult(
468
+ anemia_risk=round(refined_risk, 3),
469
+ predicted_hemoglobin=predicted_hemoglobin,
470
+ confidence=round(confidence, 3),
471
+ uncertainty=round(uncertainty, 3),
472
+ reliability_flag=reliability_flag,
473
+ screening_label=screening_label,
474
+ screening_text=screening_text,
475
+ model_source=model_source,
476
+ confidence_breakdown=confidence_breakdown,
477
+ )
478
 
479
+ def _ensure_efficientnet_model_loaded(self) -> dict[str, object] | None:
480
+ if not self.enable_efficientnet_fallback:
481
+ return None
482
+ if self.efficientnet_bundle is not None:
483
+ return self.efficientnet_bundle
484
+ if self._efficientnet_model_load_attempted:
485
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
 
487
+ self._efficientnet_model_load_attempted = True
488
+ if not self.efficientnet_path.exists():
489
+ return None
 
 
 
 
 
 
 
490
 
 
491
  try:
492
+ self.efficientnet_bundle = _load_efficientnet_checkpoint_bundle(
493
+ self.efficientnet_path
494
+ )
495
+ return self.efficientnet_bundle
496
+ except Exception as exc:
497
+ if self.archive_model is None:
498
+ self.load_error = f"EfficientNet load failed: {type(exc).__name__}: {exc}"
499
+ return None
500
 
501
+ def _ensure_archive_model_loaded(self) -> dict[str, object] | None:
502
+ if self.archive_model is not None:
503
+ return self.archive_model
504
  if self._archive_model_load_attempted:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  return None
506
 
507
+ self._archive_model_load_attempted = True
508
  if not self.model_path.exists():
509
  if self.efficientnet_bundle is None:
510
  self.load_error = f"Model artifact not found at {self.model_path}"
511
  return None
512
+
513
+ try:
514
+ self.archive_model = _load_archive_model_artifact(self.model_path)
515
+ if self.archive_model is not None:
516
+ self.load_error = None
517
+ return self.archive_model
518
  except Exception as exc:
519
  if self.efficientnet_bundle is None:
520
  self.load_error = f"{type(exc).__name__}: {exc}"
521
  return None
522
 
523
+ def _ensure_runtime_risk_calibrator_loaded(self):
524
+ runtime_risk_calibrator = getattr(self, "runtime_risk_calibrator", None)
525
+ if runtime_risk_calibrator is not None:
526
+ return runtime_risk_calibrator
527
+ if getattr(self, "_runtime_risk_calibrator_load_attempted", False):
528
+ return None
529
+
530
+ self._runtime_risk_calibrator_load_attempted = True
531
+ path = getattr(self, "runtime_calibrator_path", Path(DEFAULT_RUNTIME_CALIBRATOR_PATH))
532
+ if not path.exists():
533
+ return None
534
+
535
+ try:
536
+ self.runtime_risk_calibrator = _load_runtime_risk_calibrator_artifact(path)
537
+ return self.runtime_risk_calibrator
538
+ except Exception:
539
+ return None
540
+
541
+ def _ensure_runtime_screening_refiner_loaded(self):
542
+ runtime_screening_refiner = getattr(self, "runtime_screening_refiner", None)
543
+ if runtime_screening_refiner is not None:
544
+ return runtime_screening_refiner
545
+ if getattr(self, "_runtime_screening_refiner_load_attempted", False):
546
+ return None
547
+
548
+ self._runtime_screening_refiner_load_attempted = True
549
+ path = getattr(self, "runtime_refiner_path", Path(DEFAULT_RUNTIME_REFINER_PATH))
550
+ if not path.exists():
551
+ return None
552
+
553
+ try:
554
+ self.runtime_screening_refiner = _load_runtime_screening_refiner_artifact(path)
555
+ return self.runtime_screening_refiner
556
+ except Exception:
557
+ return None
558
+
559
  def is_ready(self) -> bool:
560
+ archive_ready = self.archive_model is not None or self.model_path.exists()
561
+ efficientnet_ready = self.efficientnet_bundle is not None or (
562
+ self.enable_efficientnet_fallback and self.efficientnet_path.exists()
563
+ )
564
+ return archive_ready or efficientnet_ready
565
+
566
+ def is_loaded(self) -> bool:
567
+ return self.archive_model is not None or self.efficientnet_bundle is not None
568
+
569
+ def runtime_status(self) -> ModelRuntimeStatus:
570
+ archive_ready = self.archive_model is not None or self.model_path.exists()
571
+ efficientnet_ready = self.efficientnet_bundle is not None or (
572
+ self.enable_efficientnet_fallback and self.efficientnet_path.exists()
573
+ )
574
+
575
+ if archive_ready:
576
+ primary_model = _runtime_stack_version()
577
+ artifact_path = str(self.model_path)
578
+ elif efficientnet_ready:
579
+ primary_model = (
580
+ str(self.efficientnet_bundle.get("version", _efficientnet_version()))
581
+ if self.efficientnet_bundle is not None
582
+ else _efficientnet_version()
583
+ )
584
+ artifact_path = str(self.efficientnet_path)
585
+ else:
586
+ primary_model = "missing-model"
587
+ artifact_path = None
588
 
589
+ runtime_calibration_ready = self.runtime_risk_calibrator is not None or (
590
+ getattr(self, "runtime_calibrator_path", Path(DEFAULT_RUNTIME_CALIBRATOR_PATH)).exists()
591
+ )
592
+ runtime_refiner_ready = self.runtime_screening_refiner is not None or (
593
+ getattr(self, "runtime_refiner_path", Path(DEFAULT_RUNTIME_REFINER_PATH)).exists()
594
+ )
595
 
 
 
 
596
  return ModelRuntimeStatus(
597
+ primary_model=primary_model,
 
 
 
 
 
 
598
  deep_stack_loaded=False,
599
  legacy_loaded=False,
600
+ artifact_ready=archive_ready or efficientnet_ready,
601
+ artifact_path=artifact_path,
 
 
 
 
 
 
602
  load_error=self.load_error,
603
+ runtime_calibration_ready=runtime_calibration_ready,
604
+ runtime_refiner_ready=runtime_refiner_ready,
605
  )
606
 
607
  def should_accept_raw_frame_rescue(self, prediction: PredictionResult) -> bool:
 
611
  or self._accept_raw_frame_uncertain_rescue(prediction)
612
  )
613
 
614
+ def _accept_raw_frame_positive_rescue(self, prediction: PredictionResult) -> bool:
615
+ strong_hb_positive = (
616
+ prediction.predicted_hemoglobin is not None
617
+ and prediction.anemia_risk >= 0.8
618
+ and prediction.predicted_hemoglobin <= 11.2
619
+ and prediction.uncertainty <= 0.5
620
+ )
621
+ strong_signal_only_positive = (
622
+ prediction.predicted_hemoglobin is None
623
+ and prediction.anemia_risk >= 0.7
624
+ and prediction.uncertainty <= 0.8
625
+ )
626
+ overwhelming_signal_only_positive = (
627
+ prediction.predicted_hemoglobin is None
628
+ and prediction.anemia_risk >= 0.84
629
+ and prediction.uncertainty <= 0.9
630
+ )
631
+ return (
632
+ prediction.screening_label == "anemia_likely"
633
+ and (
634
+ strong_hb_positive
635
+ or strong_signal_only_positive
636
+ or overwhelming_signal_only_positive
637
+ )
638
+ )
639
 
640
  def _accept_raw_frame_negative_rescue(self, prediction: PredictionResult) -> bool:
641
  hidden_hb_negative = (
 
661
  prediction.screening_label == "uncertain"
662
  and prediction.anemia_risk <= 0.32
663
  and prediction.uncertainty <= 0.68
664
+ and (
665
+ prediction.predicted_hemoglobin is None
666
+ or prediction.predicted_hemoglobin >= 12.8
667
+ )
668
  )
669
 
670
+ def _screening_decision(
671
+ self,
672
+ risk: float,
673
+ uncertainty: float,
674
+ threshold: float = 0.5,
675
  *,
676
  predicted_hemoglobin: float | None = None,
677
  signal_guardrail_triggered: bool = False,
 
684
  margin = abs(risk - threshold)
685
  mild_positive_conflict = (
686
  predicted_hemoglobin is not None
687
+ and threshold <= risk < (threshold + 0.14)
688
+ and predicted_hemoglobin >= 12.2
689
+ and uncertainty >= 0.5
 
 
 
 
 
 
 
 
 
 
690
  )
691
  if mild_positive_conflict:
692
  return (
693
  "uncertain",
694
  "The screening signal is only mildly positive while the hemoglobin estimate stays near normal, so the safest interpretation is uncertain.",
695
  )
696
+ strict_runtime_borderline = (
697
+ threshold >= 0.6
698
+ and predicted_hemoglobin is not None
699
+ and risk < (threshold + 0.07)
700
+ and predicted_hemoglobin >= 11.5
701
+ and uncertainty >= 0.55
702
+ )
703
+ if strict_runtime_borderline:
704
+ return (
705
+ "uncertain",
706
+ "The signal sits too close to the operating threshold for this confidence level, so the safest interpretation is uncertain.",
707
+ )
708
  high_suspicion_positive = (
709
  predicted_hemoglobin is not None
710
  and (
711
  (
712
  risk >= threshold
713
+ and predicted_hemoglobin <= (11.4 if threshold >= 0.6 else 12.2)
714
+ and uncertainty < (0.56 if threshold >= 0.6 else 0.62)
715
  )
716
  or (
717
+ threshold < 0.6
718
+ and
719
+ (threshold - 0.02) <= risk < threshold
720
  and predicted_hemoglobin <= 12.4
721
  and uncertainty < 0.57
722
  )
723
  or (
724
+ threshold < 0.6
725
+ and
726
+ (threshold - 0.05) <= risk < threshold
727
  and predicted_hemoglobin <= 12.25
728
  and uncertainty < 0.63
729
  )
730
  )
731
+ )
732
+ if high_suspicion_positive:
733
+ return (
734
+ "anemia_likely",
735
+ "The screening model sees a persistent low-hemoglobin signal, so this result should be treated as likely anemia despite moderate uncertainty.",
736
+ )
737
+ overwhelming_positive_signal = (
738
+ predicted_hemoglobin is not None
739
+ and risk >= (threshold + (0.18 if threshold < 0.6 else 0.10))
740
+ and predicted_hemoglobin <= (12.0 if threshold < 0.6 else 11.5)
741
+ and uncertainty < 0.9
742
  )
743
+ if overwhelming_positive_signal:
744
+ return (
745
+ "anemia_likely",
746
+ "Even with noisy capture conditions, the positive screening signal stays strong enough that this should still be treated as likely anemia screening.",
747
+ )
748
+ signal_only_positive = (
749
+ predicted_hemoglobin is None
750
+ and risk >= (threshold + (0.15 if threshold < 0.6 else 0.08))
751
+ and uncertainty < 0.89
752
  )
753
+ if signal_only_positive:
754
+ return (
755
+ "anemia_likely",
756
+ "The image-only anemia signal stays clearly positive even though the hemoglobin estimate is unavailable, so this should still be treated as likely anemia screening.",
757
+ )
758
+ if uncertainty >= 0.75 or (margin < 0.08 and uncertainty >= 0.45):
759
  return (
760
  "uncertain",
761
+ "The estimated hemoglobin trend is borderline or noisy, so the safest interpretation is uncertain.",
762
  )
763
+ if risk >= threshold:
764
  return (
765
  "anemia_likely",
766
+ "The screening model estimates a lower-than-expected hemoglobin trend from the eye image, so this should be treated as likely anemia screening rather than a normal call.",
767
  )
 
 
 
 
 
 
 
 
 
 
768
  return (
769
  "anemia_unlikely",
770
  "The screening model does not estimate a strong low-hemoglobin trend from the eye image.",
771
  )
772
 
773
+ def _display_hemoglobin(
774
+ self, predicted_hemoglobin: float | None, uncertainty: float
775
+ ) -> float | None:
776
+ if predicted_hemoglobin is None:
777
+ return None
778
+ if uncertainty >= 0.70:
779
+ return None
780
+ return round(clamp(predicted_hemoglobin, 6.0, 18.0), 2)
781
+
782
+ def _capture_quality_score(self, quality: QualityAssessment) -> float:
783
+ blur_health = clamp((quality.blur_score - 55.0) / 165.0, 0.0, 1.0)
784
+ framing_health = clamp((quality.framing_score - 0.75) / 1.1, 0.0, 1.0)
785
+ brightness_health = clamp(
786
+ 1.0 - (abs(quality.brightness_score - 0.24) / 0.24),
787
+ 0.0,
788
+ 1.0,
789
+ )
790
+ contrast_health = clamp((quality.contrast_score - 0.06) / 0.12, 0.0, 1.0)
791
+ lighting_health = clamp(quality.lighting_score, 0.0, 1.0)
792
+ return clamp(
793
+ blur_health * 0.24
794
+ + framing_health * 0.2
795
+ + brightness_health * 0.14
796
+ + contrast_health * 0.14
797
+ + lighting_health * 0.28,
798
+ 0.0,
799
+ 1.0,
800
+ )
801
+
802
+ def _decision_confidence(
803
+ self,
804
+ *,
805
+ quality: QualityAssessment,
806
+ uncertainty: float,
807
+ capture_quality_score: float,
808
+ model_stability: float,
809
+ threshold_stability: float,
810
+ signal_strength: float,
811
+ guardrail_triggered: bool,
812
+ ) -> float:
813
+ confidence = (
814
+ model_stability * 0.34
815
+ + capture_quality_score * 0.24
816
+ + threshold_stability * 0.24
817
+ + signal_strength * 0.18
818
+ )
819
+
820
+ if quality.lighting_condition in {"glare_heavy", "shadow_heavy"}:
821
+ confidence -= 0.07
822
+ elif quality.lighting_condition in {"overexposed", "flat_contrast"}:
823
+ confidence -= 0.04
824
+ elif quality.lighting_condition == "dim":
825
+ confidence -= 0.02
826
+
827
+ if quality.glare_risk > 0.65 or quality.shadow_risk > 0.65:
828
+ confidence -= 0.04
829
+
830
+ if not quality.passed:
831
+ confidence = min(confidence, 0.35)
832
+
833
+ if guardrail_triggered:
834
+ confidence -= 0.08
835
+ if signal_strength >= 0.95 and capture_quality_score >= 0.65:
836
+ confidence = max(confidence, 0.52)
837
+ elif signal_strength >= 0.8 and capture_quality_score >= 0.55:
838
+ confidence = max(confidence, 0.4)
839
+ confidence = min(confidence, 0.62)
840
+
841
+ if uncertainty >= 0.82 and signal_strength < 0.75:
842
+ confidence = min(confidence, 0.42)
843
+
844
+ if signal_strength >= 0.9 and quality.passed and capture_quality_score >= 0.55:
845
+ confidence = max(confidence, 0.45)
846
+
847
+ if uncertainty <= 0.3 and threshold_stability >= 0.55:
848
+ confidence += 0.03
849
+
850
+ if quality.lighting_condition in {"glare_heavy", "shadow_heavy"}:
851
+ confidence = min(confidence, 0.54)
852
+ elif quality.lighting_condition == "overexposed":
853
+ confidence = min(confidence, 0.58)
854
+
855
+ return clamp(confidence, 0.08, 0.92)
856
+
857
+ def _confidence_summary(
858
+ self,
859
+ *,
860
+ quality: QualityAssessment,
861
+ capture_quality_score: float,
862
+ model_stability: float,
863
+ threshold_stability: float,
864
+ guardrail_triggered: bool,
865
+ risk: float,
866
+ threshold: float,
867
+ predicted_hemoglobin: float | None,
868
+ ) -> str:
869
+ if guardrail_triggered:
870
+ return (
871
+ "A protective guardrail lowered confidence because the image looked dark for a strong low-hemoglobin claim."
872
+ )
873
+ if (
874
+ predicted_hemoglobin is not None
875
+ and risk < threshold
876
+ and threshold_stability >= 0.65
877
+ and capture_quality_score >= 0.45
878
+ and quality.passed
879
+ ):
880
+ return (
881
+ "The case sits clearly on the low-risk side of the decision threshold, so the model is more confident that this is not a strong anemia-like pattern."
882
+ )
883
+ if quality.lighting_condition != "balanced":
884
+ return (
885
+ f"Confidence is mainly limited by {quality.lighting_condition.replace('_', ' ')} lighting, which makes the conjunctival color signal harder to trust."
886
+ )
887
+ if capture_quality_score < 0.55:
888
+ return (
889
+ "Confidence is mainly limited by capture quality, so a cleaner retake would be more persuasive than over-interpreting this scan."
890
+ )
891
+ if threshold_stability < 0.35:
892
+ return (
893
+ "This case sits close to the decision threshold, so the label is more sensitive to small image or symptom changes."
894
+ )
895
+ if model_stability < 0.55:
896
+ return (
897
+ "The result is still leaning one way, but repeated model passes varied more than ideal. A cleaner retake would make it more defensible, not necessarily change the overall story."
898
+ )
899
+ return (
900
+ "Capture quality, model stability, and threshold margin all support a more defensible screening explanation."
901
+ )
902
+
903
+ def _is_clear_negative_case(
904
+ self,
905
+ *,
906
+ risk: float,
907
+ threshold: float,
908
+ predicted_hemoglobin: float | None,
909
+ quality: QualityAssessment,
910
+ capture_quality_score: float,
911
+ ) -> bool:
912
+ if predicted_hemoglobin is None:
913
+ return False
914
+ negative_margin = threshold - risk
915
+ return (
916
+ quality.passed
917
+ and negative_margin >= 0.15
918
+ and predicted_hemoglobin >= 12.7
919
+ and capture_quality_score >= 0.4
920
+ and quality.lighting_condition in {"balanced", "dim", "flat_contrast"}
921
+ and quality.glare_risk <= 0.5
922
+ and quality.shadow_risk <= 0.5
923
+ )
924
+
925
+ def _negative_case_confidence_bonus(
926
+ self,
927
+ *,
928
+ risk: float,
929
+ threshold: float,
930
+ predicted_hemoglobin: float | None,
931
+ quality: QualityAssessment,
932
+ capture_quality_score: float,
933
+ model_stability: float,
934
+ ) -> float:
935
+ if predicted_hemoglobin is None or risk >= threshold or not quality.passed:
936
+ return 0.0
937
+
938
+ negative_margin = threshold - risk
939
+ if negative_margin < 0.1 or predicted_hemoglobin < 12.5:
940
+ return 0.0
941
+
942
+ if quality.lighting_condition in {"glare_heavy", "shadow_heavy", "overexposed"}:
943
+ return 0.0
944
+
945
+ bonus = 0.0
946
+ if negative_margin >= 0.14:
947
+ bonus += 0.04
948
+ if negative_margin >= 0.28:
949
+ bonus += 0.03
950
+ if predicted_hemoglobin >= 13.0:
951
+ bonus += 0.02
952
+ if predicted_hemoglobin >= 13.6:
953
+ bonus += 0.02
954
+ if capture_quality_score >= 0.5:
955
+ bonus += 0.015
956
+ if quality.lighting_score >= 0.42:
957
+ bonus += 0.015
958
+ if model_stability >= 0.7:
959
+ bonus += 0.015
960
+ if self._is_clear_negative_case(
961
+ risk=risk,
962
+ threshold=threshold,
963
+ predicted_hemoglobin=predicted_hemoglobin,
964
+ quality=quality,
965
+ capture_quality_score=capture_quality_score,
966
+ ):
967
+ bonus += 0.02
968
+
969
+ if quality.lighting_condition in {"dim", "flat_contrast"}:
970
+ bonus *= 0.75
971
+
972
+ if (
973
+ quality.glare_risk > 0.6
974
+ or quality.shadow_risk > 0.6
975
+ or quality.blur_score < 70
976
+ or quality.brightness_score < 0.07
977
+ ):
978
+ bonus *= 0.35
979
+
980
+ return clamp(bonus, 0.0, 0.14)
981
+
982
+ def _dark_signal_guardrail(
983
+ self,
984
+ *,
985
  risk: float,
986
  predicted_hemoglobin: float | None,
987
  feature_map: dict[str, float],
backend/app/services/request_parsing.py CHANGED
@@ -5,7 +5,7 @@ import json
5
  from pydantic import ValidationError
6
 
7
  from app.config import settings
8
- from app.schemas import SymptomInput
9
 
10
 
11
  class InvalidRequestPayload(ValueError):
@@ -30,6 +30,24 @@ def parse_symptoms(raw: str | None) -> SymptomInput:
30
  raise InvalidRequestPayload("Invalid symptoms payload.") from exc
31
 
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  def normalize_optional_text(
34
  value: str | None,
35
  *,
 
5
  from pydantic import ValidationError
6
 
7
  from app.config import settings
8
+ from app.schemas import PatientProfileInput, SymptomInput
9
 
10
 
11
  class InvalidRequestPayload(ValueError):
 
30
  raise InvalidRequestPayload("Invalid symptoms payload.") from exc
31
 
32
 
33
+ def parse_patient_profile(raw: str | None) -> PatientProfileInput:
34
+ if not raw:
35
+ return PatientProfileInput()
36
+
37
+ try:
38
+ payload = json.loads(raw)
39
+ if not isinstance(payload, dict):
40
+ raise InvalidRequestPayload("Invalid patient profile payload: expected a JSON object.")
41
+ return PatientProfileInput.model_validate(payload)
42
+ except InvalidRequestPayload:
43
+ raise
44
+ except (TypeError, json.JSONDecodeError, ValidationError) as exc:
45
+ detail = _validation_detail(exc)
46
+ if detail:
47
+ raise InvalidRequestPayload(f"Invalid patient profile payload: {detail}") from exc
48
+ raise InvalidRequestPayload("Invalid patient profile payload.") from exc
49
+
50
+
51
  def normalize_optional_text(
52
  value: str | None,
53
  *,
backend/app/services/runtime_status.py CHANGED
@@ -4,6 +4,8 @@ import json
4
 
5
  from app.config import (
6
  DEFAULT_DEPLOYED_SCREENING_REPORT_PATH,
 
 
7
  DEFAULT_RUNTIME_STACK_REPORT_PATH,
8
  DEFAULT_TRAINING_REPORT_PATH,
9
  )
@@ -18,6 +20,8 @@ def build_runtime_status(
18
  model_status = predictor.runtime_status()
19
  report = _load_training_report()
20
  deployed_report = _load_json_report(DEFAULT_DEPLOYED_SCREENING_REPORT_PATH)
 
 
21
 
22
  if report is not None:
23
  metrics = report.get("metrics", {})
@@ -48,6 +52,35 @@ def build_runtime_status(
48
  }
49
  )
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  return RuntimeStatusResponse(
52
  api_status="ok",
53
  guidance=guidance_service.runtime_status(),
 
4
 
5
  from app.config import (
6
  DEFAULT_DEPLOYED_SCREENING_REPORT_PATH,
7
+ DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH,
8
+ DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH,
9
  DEFAULT_RUNTIME_STACK_REPORT_PATH,
10
  DEFAULT_TRAINING_REPORT_PATH,
11
  )
 
20
  model_status = predictor.runtime_status()
21
  report = _load_training_report()
22
  deployed_report = _load_json_report(DEFAULT_DEPLOYED_SCREENING_REPORT_PATH)
23
+ calibration_report = _load_json_report(DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH)
24
+ refinement_report = _load_json_report(DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH)
25
 
26
  if report is not None:
27
  metrics = report.get("metrics", {})
 
52
  }
53
  )
54
 
55
+ if calibration_report is not None:
56
+ diagnostics = calibration_report.get("diagnostics", {})
57
+ selected_thresholds = calibration_report.get("selected_thresholds", {})
58
+ model_status = model_status.model_copy(
59
+ update={
60
+ "runtime_calibration_ready": True,
61
+ "runtime_calibration_method": calibration_report.get("method"),
62
+ "runtime_calibrated_threshold": selected_thresholds.get("roi_original"),
63
+ "runtime_calibration_ece_before": diagnostics.get("ece_before"),
64
+ "runtime_calibration_ece_after": diagnostics.get("ece_after"),
65
+ "runtime_calibration_brier_before": diagnostics.get("brier_before"),
66
+ "runtime_calibration_brier_after": diagnostics.get("brier_after"),
67
+ }
68
+ )
69
+
70
+ if refinement_report is not None:
71
+ metrics = refinement_report.get("metrics_after", {})
72
+ model_status = model_status.model_copy(
73
+ update={
74
+ "runtime_refiner_ready": True,
75
+ "runtime_refiner_method": refinement_report.get("method"),
76
+ "runtime_refined_threshold": refinement_report.get("selected_threshold"),
77
+ "runtime_refined_accuracy": metrics.get("accuracy"),
78
+ "runtime_refined_precision": metrics.get("precision"),
79
+ "runtime_refined_recall": metrics.get("recall"),
80
+ "runtime_refined_f1": metrics.get("f1"),
81
+ }
82
+ )
83
+
84
  return RuntimeStatusResponse(
85
  api_status="ok",
86
  guidance=guidance_service.runtime_status(),
backend/app/services/screening_store.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from app.database import async_session_factory
6
+ from app.models.screening import Screening
7
+ from app.models.user import User
8
+ from app.schemas import AnalyzeResponse
9
+
10
+
11
+ async def persist_screening_result(
12
+ request_id: str,
13
+ analysis: AnalyzeResponse,
14
+ user_id: int | None,
15
+ processing_time_ms: float,
16
+ ) -> Screening:
17
+ """Persist a completed screening result and optionally attach it to a user."""
18
+
19
+ screening = Screening(
20
+ request_id=request_id,
21
+ user_id=user_id,
22
+ triage_band=analysis.triage.band,
23
+ triage_score=analysis.triage.score,
24
+ triage_label=analysis.triage.label,
25
+ anemia_risk=analysis.prediction.anemia_risk if analysis.prediction else None,
26
+ predicted_hemoglobin=analysis.prediction.predicted_hemoglobin if analysis.prediction else None,
27
+ confidence=analysis.prediction.confidence if analysis.prediction else None,
28
+ uncertainty=analysis.prediction.uncertainty if analysis.prediction else None,
29
+ screening_label=analysis.prediction.screening_label if analysis.prediction else None,
30
+ model_source=analysis.prediction.model_source if analysis.prediction else None,
31
+ quality_passed=analysis.quality.passed,
32
+ blocked=analysis.blocked,
33
+ processing_path=analysis.decision_audit.processing_path,
34
+ guidance_source=analysis.guidance.source,
35
+ symptoms_json=json.dumps(analysis.symptoms.model_dump()),
36
+ full_response_json=json.dumps(analysis.model_dump(), default=str),
37
+ share_text=analysis.handoff_summary.share_text,
38
+ urgency_label=analysis.handoff_summary.urgency_label,
39
+ headline=analysis.handoff_summary.headline,
40
+ processing_time_ms=processing_time_ms,
41
+ language=analysis.language,
42
+ region=analysis.region,
43
+ )
44
+
45
+ async with async_session_factory() as session:
46
+ session.add(screening)
47
+ await session.flush()
48
+
49
+ if user_id is not None:
50
+ user = await session.get(User, user_id)
51
+ if user is not None:
52
+ user.scan_count += 1
53
+
54
+ await session.commit()
55
+ await session.refresh(screening)
56
+ return screening
backend/app/utils/security.py CHANGED
@@ -7,11 +7,16 @@ Uses passlib+bcrypt for passwords and python-jose for JWT tokens.
7
  from __future__ import annotations
8
 
9
  import os
 
10
  from datetime import datetime, timedelta, timezone
11
 
 
12
  from jose import JWTError, jwt
13
  from passlib.context import CryptContext
14
 
 
 
 
15
  JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-only-change-in-production")
16
  JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
17
  JWT_ACCESS_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
 
7
  from __future__ import annotations
8
 
9
  import os
10
+ from pathlib import Path
11
  from datetime import datetime, timedelta, timezone
12
 
13
+ from dotenv import load_dotenv
14
  from jose import JWTError, jwt
15
  from passlib.context import CryptContext
16
 
17
+ BACKEND_ROOT = Path(__file__).resolve().parents[2]
18
+ load_dotenv(BACKEND_ROOT / ".env")
19
+
20
  JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-only-change-in-production")
21
  JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
22
  JWT_ACCESS_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
backend/models/deployed_screening_report.json CHANGED
@@ -4,16 +4,16 @@
4
  "validation_size": 44,
5
  "metrics": {
6
  "accuracy": 0.8864,
7
- "precision": 0.9091,
8
- "recall": 0.7143,
9
- "f1": 0.8,
10
  "split_strategy": "group-shuffle-balance-select: roi_original + deployed quality gate"
11
  },
12
  "operating_counts": {
13
- "blocked_positive": 2,
14
  "blocked_negative": 9,
15
- "blocked_total": 11,
16
- "likely_count": 11,
17
- "uncertain_count": 4
18
  }
19
  }
 
4
  "validation_size": 44,
5
  "metrics": {
6
  "accuracy": 0.8864,
7
+ "precision": 0.8462,
8
+ "recall": 0.7857,
9
+ "f1": 0.8148,
10
  "split_strategy": "group-shuffle-balance-select: roi_original + deployed quality gate"
11
  },
12
  "operating_counts": {
13
+ "blocked_positive": 1,
14
  "blocked_negative": 9,
15
+ "blocked_total": 10,
16
+ "likely_count": 13,
17
+ "uncertain_count": 17
18
  }
19
  }
backend/models/runtime_calibration_report.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "runtime-risk-calibrator-v1",
3
+ "method": "temperature",
4
+ "validation_size": 44,
5
+ "selected_thresholds": {
6
+ "roi_original": 0.495,
7
+ "palpebral": 0.65,
8
+ "forniceal_palpebral": 0.65
9
+ },
10
+ "diagnostics": {
11
+ "ece_before": 0.262,
12
+ "ece_after": 0.0909,
13
+ "brier_before": 0.0906,
14
+ "brier_after": 0.0501
15
+ },
16
+ "roi_metrics_before": {
17
+ "accuracy": 1.0,
18
+ "precision": 1.0,
19
+ "recall": 1.0,
20
+ "f1": 1.0
21
+ },
22
+ "roi_metrics_after": {
23
+ "accuracy": 0.9318,
24
+ "precision": 0.8235,
25
+ "recall": 1.0,
26
+ "f1": 0.9032
27
+ }
28
+ }
backend/models/runtime_refinement_report.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "runtime-screening-refiner-v1",
3
+ "method": "logistic-regression",
4
+ "validation_size": 44,
5
+ "selected_threshold": 0.54,
6
+ "metrics_before": {
7
+ "accuracy": 0.8636,
8
+ "precision": 0.75,
9
+ "recall": 0.8571,
10
+ "f1": 0.8
11
+ },
12
+ "metrics_after": {
13
+ "accuracy": 0.8864,
14
+ "precision": 0.8462,
15
+ "recall": 0.7857,
16
+ "f1": 0.8148
17
+ },
18
+ "stage_metrics_after": {
19
+ "accuracy": 0.9091,
20
+ "precision": 0.8571,
21
+ "recall": 0.8571,
22
+ "f1": 0.8571
23
+ }
24
+ }
backend/models/runtime_risk_calibrator.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:029bae31e2a64a5f129983b488d081f2a8cb51f8cbce8f578e21242eba1a882b
3
+ size 543
backend/models/runtime_screening_refiner.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7f2b89f7875b4d7e597b915f0f313570d1a2b8ac3a160ab620574d12107c2c9f
3
+ size 2346
backend/scripts/analyze_efficientnet_errors.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from collections import Counter, defaultdict
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import torch
10
+ from sklearn.metrics import accuracy_score, confusion_matrix, f1_score, mean_absolute_error, precision_score, recall_score, roc_auc_score
11
+ from torch.utils.data import DataLoader
12
+
13
+ BACKEND_ROOT = Path(__file__).resolve().parents[1]
14
+ if str(BACKEND_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(BACKEND_ROOT))
16
+ SCRIPT_ROOT = Path(__file__).resolve().parent
17
+ if str(SCRIPT_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(SCRIPT_ROOT))
19
+
20
+ from app.config import DEFAULT_EFFICIENTNET_MODEL_PATH
21
+ from app.ml.efficientnet_model import load_efficientnet_checkpoint
22
+ from train_efficientnet import (
23
+ ARCHIVE_ROOT,
24
+ DATA_ROOT,
25
+ ConjunctivaDataset,
26
+ _balanced_group_split,
27
+ _build_records,
28
+ build_val_transform,
29
+ )
30
+
31
+ DEFAULT_OUTPUT_PATH = BACKEND_ROOT / "models" / "efficientnet_error_report.json"
32
+
33
+
34
+ def main() -> None:
35
+ dataset_root = DATA_ROOT if DATA_ROOT.exists() else ARCHIVE_ROOT
36
+ records = _build_records(dataset_root)
37
+ if not records:
38
+ raise RuntimeError(f"No dataset records found under {dataset_root}.")
39
+ if not DEFAULT_EFFICIENTNET_MODEL_PATH.exists():
40
+ raise RuntimeError(f"EfficientNet checkpoint not found at {DEFAULT_EFFICIENTNET_MODEL_PATH}.")
41
+
42
+ train_records, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
43
+ bundle = load_efficientnet_checkpoint(DEFAULT_EFFICIENTNET_MODEL_PATH)
44
+ report = analyze_validation_split(val_records, bundle, dataset_root=dataset_root, train_records=train_records)
45
+
46
+ DEFAULT_OUTPUT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
47
+ print(f"Saved error report to {DEFAULT_OUTPUT_PATH}")
48
+ print(json.dumps(report["summary"], indent=2))
49
+
50
+
51
+ def analyze_validation_split(
52
+ val_records: list,
53
+ bundle: dict[str, object],
54
+ *,
55
+ dataset_root: Path,
56
+ train_records: list,
57
+ ) -> dict[str, object]:
58
+ model = bundle["model"]
59
+ device = bundle["device"]
60
+ hb_mean = float(bundle.get("hb_mean", 0.0))
61
+ hb_std = float(bundle.get("hb_std", 1.0))
62
+ threshold = float(bundle.get("decision_threshold", 0.5))
63
+
64
+ dataset = ConjunctivaDataset(val_records, build_val_transform())
65
+ loader = DataLoader(dataset, batch_size=16, shuffle=False, num_workers=0)
66
+
67
+ probabilities: list[float] = []
68
+ predictions: list[int] = []
69
+ labels: list[int] = []
70
+ hb_predictions: list[float] = []
71
+ hb_targets: list[float] = []
72
+
73
+ model.eval()
74
+ with torch.no_grad():
75
+ for images, batch_labels, batch_hbs in loader:
76
+ output = model(images.to(device))
77
+ batch_probabilities = torch.sigmoid(output[:, 0]).cpu().tolist()
78
+ batch_hb_predictions = (((output[:, 1].cpu()) * hb_std) + hb_mean).tolist()
79
+ probabilities.extend(batch_probabilities)
80
+ predictions.extend([1 if value >= threshold else 0 for value in batch_probabilities])
81
+ labels.extend(batch_labels.squeeze(1).cpu().int().tolist())
82
+ hb_predictions.extend(batch_hb_predictions)
83
+ hb_targets.extend(batch_hbs.squeeze(1).cpu().tolist())
84
+
85
+ summary = {
86
+ "dataset_root": str(dataset_root),
87
+ "checkpoint_path": str(DEFAULT_EFFICIENTNET_MODEL_PATH),
88
+ "record_count": len(val_records),
89
+ "subject_count": len({record.subject_id for record in val_records}),
90
+ "threshold": round(threshold, 4),
91
+ "train_record_count": len(train_records),
92
+ "train_subject_count": len({record.subject_id for record in train_records}),
93
+ "accuracy": round(float(accuracy_score(labels, predictions)), 4),
94
+ "precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
95
+ "recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
96
+ "f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
97
+ "auc": round(float(roc_auc_score(labels, probabilities)), 4),
98
+ "hb_mae": round(float(mean_absolute_error(hb_targets, hb_predictions)), 4),
99
+ "label_counts": dict(Counter(labels)),
100
+ "prediction_counts": dict(Counter(predictions)),
101
+ "confusion_matrix": confusion_matrix(labels, predictions).tolist(),
102
+ }
103
+
104
+ source_breakdown = _source_breakdown(val_records, labels, predictions, probabilities, hb_predictions, hb_targets)
105
+ false_positives, false_negatives = _mistakes(val_records, labels, predictions, probabilities, hb_predictions, hb_targets)
106
+
107
+ return {
108
+ "summary": summary,
109
+ "source_breakdown": source_breakdown,
110
+ "false_positives": false_positives,
111
+ "false_negatives": false_negatives,
112
+ }
113
+
114
+
115
+ def _source_breakdown(
116
+ val_records: list,
117
+ labels: list[int],
118
+ predictions: list[int],
119
+ probabilities: list[float],
120
+ hb_predictions: list[float],
121
+ hb_targets: list[float],
122
+ ) -> dict[str, object]:
123
+ by_source: dict[str, dict[str, object]] = defaultdict(
124
+ lambda: {
125
+ "count": 0,
126
+ "errors": 0,
127
+ "false_positives": 0,
128
+ "false_negatives": 0,
129
+ "probabilities": [],
130
+ "hb_abs_error": [],
131
+ }
132
+ )
133
+
134
+ for record, label, prediction, probability, hb_prediction, hb_target in zip(
135
+ val_records,
136
+ labels,
137
+ predictions,
138
+ probabilities,
139
+ hb_predictions,
140
+ hb_targets,
141
+ ):
142
+ item = by_source[record.source]
143
+ item["count"] += 1
144
+ item["errors"] += int(label != prediction)
145
+ item["false_positives"] += int(label == 0 and prediction == 1)
146
+ item["false_negatives"] += int(label == 1 and prediction == 0)
147
+ item["probabilities"].append(float(probability))
148
+ item["hb_abs_error"].append(abs(float(hb_prediction) - float(hb_target)))
149
+
150
+ normalized: dict[str, object] = {}
151
+ for source, item in by_source.items():
152
+ normalized[source] = {
153
+ "count": item["count"],
154
+ "errors": item["errors"],
155
+ "false_positives": item["false_positives"],
156
+ "false_negatives": item["false_negatives"],
157
+ "error_rate": round(float(item["errors"] / max(item["count"], 1)), 4),
158
+ "mean_probability": round(float(np.mean(item["probabilities"])), 4),
159
+ "hb_mae": round(float(np.mean(item["hb_abs_error"])), 4),
160
+ }
161
+ return normalized
162
+
163
+
164
+ def _mistakes(
165
+ val_records: list,
166
+ labels: list[int],
167
+ predictions: list[int],
168
+ probabilities: list[float],
169
+ hb_predictions: list[float],
170
+ hb_targets: list[float],
171
+ ) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
172
+ false_positives: list[dict[str, object]] = []
173
+ false_negatives: list[dict[str, object]] = []
174
+
175
+ for record, label, prediction, probability, hb_prediction, hb_target in zip(
176
+ val_records,
177
+ labels,
178
+ predictions,
179
+ probabilities,
180
+ hb_predictions,
181
+ hb_targets,
182
+ ):
183
+ if label == prediction:
184
+ continue
185
+ item = {
186
+ "subject_id": record.subject_id,
187
+ "source": record.source,
188
+ "probability": round(float(probability), 4),
189
+ "hb_true": round(float(hb_target), 2),
190
+ "hb_predicted": round(float(hb_prediction), 2),
191
+ "image_path": str(record.image_path),
192
+ }
193
+ if label == 0 and prediction == 1:
194
+ false_positives.append(item)
195
+ else:
196
+ false_negatives.append(item)
197
+
198
+ false_positives.sort(key=lambda item: float(item["probability"]), reverse=True)
199
+ false_negatives.sort(key=lambda item: float(item["probability"]))
200
+ return false_positives[:12], false_negatives[:12]
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
backend/scripts/eval_pipeline.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test the full inference pipeline (quality -> features -> predict) on real dataset images.
3
+ This simulates exactly what happens when a user uploads a photo.
4
+ """
5
+ import sys, io
6
+ from pathlib import Path
7
+ sys.path.insert(0, str(Path(__file__).parents[1]))
8
+
9
+ import numpy as np
10
+ from PIL import Image
11
+ from app.services.prediction import ScreeningPredictor
12
+ from app.services.image_quality import ImageQualityService
13
+ from app.ml.archive_model import _build_subject_catalog, ANEMIA_HB_THRESHOLD
14
+ from sklearn.metrics import accuracy_score, f1_score, recall_score, precision_score, roc_auc_score, mean_absolute_error
15
+
16
+ predictor = ScreeningPredictor()
17
+ quality_svc = ImageQualityService()
18
+
19
+ print("Model:", predictor.archive_model.get("version") if predictor.archive_model else "NONE")
20
+ print()
21
+
22
+ subjects = _build_subject_catalog(Path(__file__).parents[2] / "archive" / "dataset anemia")
23
+
24
+ # Test on original JPG images (what users actually upload)
25
+ results = []
26
+ blocked = 0
27
+ for s in subjects[:40]: # first 40 for speed
28
+ country = s["subject_id"].split("-")[0]
29
+ num = s["subject_number"]
30
+ jpg_path = Path(__file__).parents[2] / "archive" / "dataset anemia" / country / num
31
+ jpgs = list(jpg_path.glob("*.jpg"))
32
+ if not jpgs:
33
+ continue
34
+
35
+ with open(jpgs[0], "rb") as f:
36
+ img_bytes = f.read()
37
+
38
+ try:
39
+ quality, rgb = quality_svc.evaluate(img_bytes)
40
+ if not quality.passed:
41
+ blocked += 1
42
+ continue
43
+ pred = predictor.predict(rgb, quality, symptom_score=0.0)
44
+ results.append({
45
+ "hb_true": s["hb"],
46
+ "hb_pred": pred.predicted_hemoglobin,
47
+ "risk": pred.anemia_risk,
48
+ "label_true": int(s["hb"] < ANEMIA_HB_THRESHOLD),
49
+ "label_pred": int(pred.anemia_risk >= 0.65) if pred.anemia_risk else 0,
50
+ "screening_label": pred.screening_label,
51
+ })
52
+ except Exception as e:
53
+ print(f" Error on {s['subject_id']}: {e}")
54
+
55
+ print(f"Processed: {len(results)}, Blocked by quality: {blocked}")
56
+ print()
57
+
58
+ if not results:
59
+ print("No results — all blocked by quality gate!")
60
+ else:
61
+ labels_true = [r["label_true"] for r in results]
62
+ labels_pred = [r["label_pred"] for r in results]
63
+ risks = [r["risk"] for r in results if r["risk"] is not None]
64
+ hb_true = [r["hb_true"] for r in results if r["hb_pred"] is not None]
65
+ hb_pred = [r["hb_pred"] for r in results if r["hb_pred"] is not None]
66
+
67
+ print(f"Accuracy: {accuracy_score(labels_true, labels_pred):.3f}")
68
+ print(f"Recall: {recall_score(labels_true, labels_pred, zero_division=0):.3f}")
69
+ print(f"Precision: {precision_score(labels_true, labels_pred, zero_division=0):.3f}")
70
+ print(f"F1: {f1_score(labels_true, labels_pred, zero_division=0):.3f}")
71
+ if len(set(labels_true)) > 1 and risks:
72
+ print(f"AUC: {roc_auc_score(labels_true[:len(risks)], risks):.3f}")
73
+ if hb_pred:
74
+ print(f"Hb MAE: {mean_absolute_error(hb_true, hb_pred):.3f} g/dL")
75
+ print(f"Hb bias: {float(np.mean(np.array(hb_pred) - np.array(hb_true))):.3f} g/dL")
76
+
77
+ print("\nSample predictions:")
78
+ for r in results[:10]:
79
+ tag = "OK" if r["label_true"] == r["label_pred"] else "WRONG"
80
+ print(f" True={r['hb_true']:.1f} Pred={r['hb_pred']} Risk={r['risk']} {r['screening_label']} [{tag}]")
backend/scripts/eval_real.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluate model on real dataset subjects with known Hb values.
3
+ Shows true Hb vs predicted Hb vs risk score.
4
+ """
5
+ import sys, json
6
+ from pathlib import Path
7
+ sys.path.insert(0, str(Path(__file__).parents[1]))
8
+
9
+ import joblib, numpy as np
10
+ from app.ml.archive_model import (
11
+ _build_subject_catalog, predict_with_archive_model, ANEMIA_HB_THRESHOLD
12
+ )
13
+
14
+ m = joblib.load(Path(__file__).parents[1] / "models" / "archive_screening_model.joblib")
15
+ cal = m["calibration"]
16
+ print("Model version:", m["version"])
17
+ print("Blend threshold:", cal["blend_threshold"])
18
+ print("Risk scale:", cal["risk_scale"])
19
+ print()
20
+
21
+ subjects = _build_subject_catalog(Path(__file__).parents[2] / "archive" / "dataset anemia")
22
+ print(f"Total subjects: {len(subjects)}")
23
+
24
+ anemic = [s for s in subjects if s["hb"] < 11.5][:8]
25
+ borderline = [s for s in subjects if 11.5 <= s["hb"] < 13.0][:4]
26
+ normal = [s for s in subjects if s["hb"] >= 13.0][:8]
27
+
28
+ correct = 0
29
+ total = 0
30
+
31
+ for group, cases in [("ANEMIC (Hb<11.5)", anemic), ("BORDERLINE", borderline), ("NORMAL (Hb>=13)", normal)]:
32
+ print(f"--- {group} ---")
33
+ for s in cases:
34
+ feat = list(s["views"].values())[0]
35
+ result = predict_with_archive_model(m, feat, source_hint="roi_original")
36
+ hb_true = s["hb"]
37
+ hb_pred = result["predicted_hemoglobin"]
38
+ risk = result["anemia_risk"]
39
+ predicted_anemic = risk >= 0.65
40
+ actually_anemic = hb_true < ANEMIA_HB_THRESHOLD
41
+ ok = predicted_anemic == actually_anemic
42
+ correct += int(ok)
43
+ total += 1
44
+ tag = "OK" if ok else "WRONG"
45
+ print(f" True={hb_true:.1f} Pred={hb_pred:.1f} Risk={risk:.3f} [{tag}]")
46
+ print()
47
+
48
+ print(f"Accuracy on sample: {correct}/{total} = {correct/total*100:.0f}%")
49
+
50
+ # Full dataset accuracy
51
+ print("\n--- Full dataset ---")
52
+ all_risks = []
53
+ all_labels = []
54
+ all_hb_true = []
55
+ all_hb_pred = []
56
+ for s in subjects:
57
+ feat = list(s["views"].values())[0]
58
+ result = predict_with_archive_model(m, feat, source_hint="roi_original")
59
+ all_risks.append(result["anemia_risk"])
60
+ all_labels.append(int(s["hb"] < ANEMIA_HB_THRESHOLD))
61
+ all_hb_true.append(s["hb"])
62
+ all_hb_pred.append(result["predicted_hemoglobin"])
63
+
64
+ risks = np.array(all_risks)
65
+ labels = np.array(all_labels)
66
+ hb_true = np.array(all_hb_true)
67
+ hb_pred = np.array(all_hb_pred)
68
+
69
+ from sklearn.metrics import accuracy_score, f1_score, recall_score, precision_score, roc_auc_score, mean_absolute_error
70
+ preds = (risks >= 0.65).astype(int)
71
+ print(f"Accuracy: {accuracy_score(labels, preds):.3f}")
72
+ print(f"Precision: {precision_score(labels, preds, zero_division=0):.3f}")
73
+ print(f"Recall: {recall_score(labels, preds, zero_division=0):.3f}")
74
+ print(f"F1: {f1_score(labels, preds, zero_division=0):.3f}")
75
+ print(f"AUC: {roc_auc_score(labels, risks):.3f}")
76
+ print(f"Hb MAE: {mean_absolute_error(hb_true, hb_pred):.3f} g/dL")
77
+ print(f"Hb bias: {float(np.mean(hb_pred - hb_true)):.3f} g/dL (+ = overestimate)")
78
+ print(f"Risk dist anemic: {np.percentile(risks[labels==1], [10,25,50,75,90]).round(3)}")
79
+ print(f"Risk dist normal: {np.percentile(risks[labels==0], [10,25,50,75,90]).round(3)}")
backend/scripts/evaluate_deployed_screening.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import numpy as np
6
+ from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
7
+
8
+ from app.config import DEFAULT_DEPLOYED_SCREENING_REPORT_PATH
9
+ from app.services.image_quality import ImageQualityService
10
+ from app.services.prediction import ScreeningPredictor
11
+ from train_efficientnet import ARCHIVE_ROOT, _balanced_group_split, _build_records, _load_image_with_fallback
12
+
13
+
14
+ def main() -> None:
15
+ records = _build_records(ARCHIVE_ROOT)
16
+ if not records:
17
+ raise RuntimeError(f"No evaluation records found in {ARCHIVE_ROOT}.")
18
+
19
+ _, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
20
+ roi_records = [record for record in val_records if record.source == "roi_original"]
21
+
22
+ quality_service = ImageQualityService()
23
+ predictor = ScreeningPredictor()
24
+
25
+ labels: list[int] = []
26
+ predictions: list[int] = []
27
+ blocked_positive = 0
28
+ blocked_negative = 0
29
+ likely_count = 0
30
+ uncertain_count = 0
31
+
32
+ for record in roi_records:
33
+ with record.image_path.open("rb") as handle:
34
+ quality, processed = quality_service.evaluate(handle.read())
35
+
36
+ labels.append(int(record.label))
37
+ prediction = predictor.predict(processed, quality) if quality.passed else None
38
+ if prediction is None and quality_service.allows_raw_frame_rescue(quality):
39
+ raw_image = _load_image_with_fallback(record.image_path).convert("RGB")
40
+ raw_prediction = predictor.predict(raw_image, quality)
41
+ if predictor.should_accept_raw_frame_rescue(raw_prediction):
42
+ quality = quality_service.build_raw_frame_rescue_assessment(quality)
43
+ prediction = raw_prediction
44
+
45
+ if prediction is None:
46
+ predictions.append(0)
47
+ if record.label:
48
+ blocked_positive += 1
49
+ else:
50
+ blocked_negative += 1
51
+ continue
52
+
53
+ predictions.append(int(prediction.screening_label == "anemia_likely"))
54
+ likely_count += int(prediction.screening_label == "anemia_likely")
55
+ uncertain_count += int(prediction.screening_label == "uncertain")
56
+
57
+ labels_array = np.asarray(labels, dtype=np.int32)
58
+ predictions_array = np.asarray(predictions, dtype=np.int32)
59
+ report = {
60
+ "evaluation_scope": "deployed_roi_screening",
61
+ "record_count": len(records),
62
+ "validation_size": len(roi_records),
63
+ "metrics": {
64
+ "accuracy": round(float(accuracy_score(labels_array, predictions_array)), 4),
65
+ "precision": round(float(precision_score(labels_array, predictions_array, zero_division=0)), 4),
66
+ "recall": round(float(recall_score(labels_array, predictions_array, zero_division=0)), 4),
67
+ "f1": round(float(f1_score(labels_array, predictions_array, zero_division=0)), 4),
68
+ "split_strategy": "group-shuffle-balance-select: roi_original + deployed quality gate",
69
+ },
70
+ "operating_counts": {
71
+ "blocked_positive": blocked_positive,
72
+ "blocked_negative": blocked_negative,
73
+ "blocked_total": blocked_positive + blocked_negative,
74
+ "likely_count": likely_count,
75
+ "uncertain_count": uncertain_count,
76
+ },
77
+ }
78
+ DEFAULT_DEPLOYED_SCREENING_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
79
+
80
+ print("\nDeployed ROI screening metrics")
81
+ for key in ("accuracy", "precision", "recall", "f1"):
82
+ print(f"{key}: {report['metrics'][key]:.4f}")
83
+ print(f"blocked_total: {report['operating_counts']['blocked_total']}")
84
+ print(f"likely_count: {report['operating_counts']['likely_count']}")
85
+ print(f"uncertain_count: {report['operating_counts']['uncertain_count']}")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
backend/scripts/evaluate_runtime_stack.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import torch
8
+ from sklearn.metrics import accuracy_score, f1_score, mean_absolute_error, precision_score, recall_score, roc_auc_score
9
+
10
+ from app.config import (
11
+ DEFAULT_ARCHIVE_MODEL_PATH,
12
+ DEFAULT_EFFICIENTNET_MODEL_PATH,
13
+ DEFAULT_RUNTIME_STACK_REPORT_PATH,
14
+ )
15
+ from app.ml.archive_model import load_archive_model, predict_with_archive_model
16
+ from app.ml.efficientnet_model import load_efficientnet_checkpoint
17
+ from app.ml.features import extract_eye_features
18
+ from app.ml.runtime_stack import (
19
+ DEFAULT_SOURCE_THRESHOLDS,
20
+ RUNTIME_STACK_VERSION,
21
+ build_runtime_stack_prediction,
22
+ decision_threshold_for_source,
23
+ )
24
+ from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
25
+ from train_efficientnet import ARCHIVE_ROOT, _balanced_group_split, _build_records, _load_image_with_fallback
26
+
27
+
28
+ def main() -> None:
29
+ records = _build_records(ARCHIVE_ROOT)
30
+ if not records:
31
+ raise RuntimeError(f"No evaluation records found in {ARCHIVE_ROOT}.")
32
+
33
+ _, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
34
+ archive_model = load_archive_model(DEFAULT_ARCHIVE_MODEL_PATH)
35
+ efficientnet_bundle = (
36
+ load_efficientnet_checkpoint(DEFAULT_EFFICIENTNET_MODEL_PATH)
37
+ if Path(DEFAULT_EFFICIENTNET_MODEL_PATH).exists()
38
+ else None
39
+ )
40
+ roi_extractor = ConjunctivaRoiExtractor()
41
+
42
+ runtime_rows: list[dict[str, float | int | str]] = []
43
+ full_rows: list[dict[str, float | int | str]] = []
44
+ prepared_images: list[object] = []
45
+ prepared_sources: list[str] = []
46
+ prepared_archive_predictions: list[dict[str, float]] = []
47
+ prepared_records = []
48
+
49
+ for record in val_records:
50
+ image = _load_image_with_fallback(record.image_path)
51
+ source_hint = record.source
52
+ if record.source == "roi_original":
53
+ image = roi_extractor.extract(image).image
54
+ image = image.convert("RGB")
55
+
56
+ archive_prediction = predict_with_archive_model(
57
+ archive_model,
58
+ extract_eye_features(image),
59
+ source_hint=source_hint,
60
+ )
61
+ prepared_records.append(record)
62
+ prepared_images.append(image)
63
+ prepared_sources.append(source_hint)
64
+ prepared_archive_predictions.append(archive_prediction)
65
+
66
+ efficientnet_predictions = _predict_efficientnet_batch(efficientnet_bundle, prepared_images)
67
+
68
+ for record, source_hint, archive_prediction, efficientnet_prediction in zip(
69
+ prepared_records,
70
+ prepared_sources,
71
+ prepared_archive_predictions,
72
+ efficientnet_predictions,
73
+ strict=True,
74
+ ):
75
+ runtime_prediction = build_runtime_stack_prediction(
76
+ archive_prediction,
77
+ efficientnet_prediction=efficientnet_prediction,
78
+ source_hint=source_hint, # type: ignore[arg-type]
79
+ )
80
+ row = {
81
+ "label": int(record.label),
82
+ "source": str(record.source),
83
+ "risk": float(runtime_prediction["anemia_risk"]),
84
+ "predicted_hb": float(runtime_prediction["predicted_hemoglobin"]),
85
+ "target_hb": float(record.hb),
86
+ }
87
+ full_rows.append(row)
88
+ if record.source == "roi_original":
89
+ runtime_rows.append(row)
90
+
91
+ runtime_metrics = _evaluate_rows(runtime_rows, source_aware=False)
92
+ full_metrics = _evaluate_rows(full_rows, source_aware=True)
93
+
94
+ report = {
95
+ "primary_model": RUNTIME_STACK_VERSION,
96
+ "record_count": len(records),
97
+ "subject_count": len({record.subject_id for record in records}),
98
+ "selected_mode": "archive_evidence_fusion_runtime",
99
+ "source_thresholds": DEFAULT_SOURCE_THRESHOLDS,
100
+ "metrics": runtime_metrics,
101
+ "full_validation": full_metrics,
102
+ }
103
+ DEFAULT_RUNTIME_STACK_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
104
+
105
+ print("\nRuntime stack metrics (ROI-gated uploads)")
106
+ for key in ("accuracy", "precision", "recall", "f1", "auc", "hb_mae"):
107
+ print(f"{key}: {runtime_metrics[key]:.4f}")
108
+
109
+ print("\nFull validation metrics (all sources)")
110
+ for key in ("accuracy", "precision", "recall", "f1", "auc", "hb_mae"):
111
+ print(f"{key}: {full_metrics[key]:.4f}")
112
+
113
+
114
+ def _evaluate_rows(
115
+ rows: list[dict[str, float | int | str]],
116
+ *,
117
+ source_aware: bool,
118
+ ) -> dict[str, float | int | str]:
119
+ labels = np.asarray([int(row["label"]) for row in rows], dtype=np.int32)
120
+ probabilities = np.asarray([float(row["risk"]) for row in rows], dtype=np.float32)
121
+ predicted_hb = np.asarray([float(row["predicted_hb"]) for row in rows], dtype=np.float32)
122
+ target_hb = np.asarray([float(row["target_hb"]) for row in rows], dtype=np.float32)
123
+
124
+ if source_aware:
125
+ predictions = np.asarray(
126
+ [
127
+ 1
128
+ if float(row["risk"]) >= decision_threshold_for_source(str(row["source"])) # type: ignore[arg-type]
129
+ else 0
130
+ for row in rows
131
+ ],
132
+ dtype=np.int32,
133
+ )
134
+ split_strategy = "group-shuffle-balance-select: source-aware"
135
+ else:
136
+ threshold = decision_threshold_for_source("roi_original")
137
+ predictions = (probabilities >= threshold).astype(np.int32)
138
+ split_strategy = "group-shuffle-balance-select: roi_original"
139
+
140
+ return {
141
+ "accuracy": round(float(accuracy_score(labels, predictions)), 4),
142
+ "precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
143
+ "recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
144
+ "f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
145
+ "auc": round(float(roc_auc_score(labels, probabilities)), 4),
146
+ "hb_mae": round(float(mean_absolute_error(target_hb, predicted_hb)), 4),
147
+ "validation_size": int(len(rows)),
148
+ "split_strategy": split_strategy,
149
+ }
150
+
151
+
152
+ def _predict_efficientnet_batch(
153
+ bundle: dict[str, object] | None,
154
+ images: list[object],
155
+ ) -> list[dict[str, float] | None]:
156
+ if bundle is None:
157
+ return [None] * len(images)
158
+
159
+ transform = bundle["transform"]
160
+ model = bundle["model"]
161
+ hb_mean = float(bundle.get("hb_mean", 0.0))
162
+ hb_std = max(float(bundle.get("hb_std", 1.0)), 1e-6)
163
+ tensors = torch.stack([transform(image) for image in images], dim=0)
164
+
165
+ with torch.no_grad():
166
+ output = model(tensors)
167
+ probabilities = torch.sigmoid(output[:, 0]).cpu().numpy()
168
+ hemoglobin = ((output[:, 1].cpu().numpy()) * hb_std) + hb_mean
169
+
170
+ results: list[dict[str, float]] = []
171
+ for probability, hb_value in zip(probabilities, hemoglobin, strict=True):
172
+ margin_uncertainty = 1.0 - min(1.0, abs(float(probability) - 0.5) * 2.0)
173
+ results.append(
174
+ {
175
+ "anemia_risk": float(probability),
176
+ "predicted_hemoglobin": float(hb_value),
177
+ "uncertainty": float(np.clip((margin_uncertainty * 0.2) + 0.05, 0.05, 0.95)),
178
+ }
179
+ )
180
+ return results
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()
backend/scripts/fit_runtime_risk_calibrator.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import torch
9
+ from sklearn.metrics import (
10
+ accuracy_score,
11
+ brier_score_loss,
12
+ f1_score,
13
+ precision_score,
14
+ recall_score,
15
+ )
16
+
17
+ sys.path.insert(0, str(Path(__file__).parents[1]))
18
+
19
+ from app.config import (
20
+ DEFAULT_ARCHIVE_MODEL_PATH,
21
+ DEFAULT_EFFICIENTNET_MODEL_PATH,
22
+ DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH,
23
+ DEFAULT_RUNTIME_CALIBRATOR_PATH,
24
+ )
25
+ from app.ml.archive_model import load_archive_model, predict_with_archive_model
26
+ from app.ml.calibration import CompositeCalibrator, expected_calibration_error
27
+ from app.ml.efficientnet_model import load_efficientnet_checkpoint
28
+ from app.ml.features import extract_eye_features
29
+ from app.ml.runtime_calibration import RuntimeRiskCalibrator
30
+ from app.ml.runtime_stack import (
31
+ DEFAULT_SOURCE_THRESHOLDS,
32
+ build_runtime_stack_prediction,
33
+ decision_threshold_for_source,
34
+ )
35
+ from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
36
+ from train_efficientnet import ARCHIVE_ROOT, _balanced_group_split, _build_records, _load_image_with_fallback
37
+
38
+
39
+ def main() -> None:
40
+ records = _build_records(ARCHIVE_ROOT)
41
+ if not records:
42
+ raise RuntimeError(f"No calibration records found in {ARCHIVE_ROOT}.")
43
+
44
+ _, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
45
+ archive_model = load_archive_model(DEFAULT_ARCHIVE_MODEL_PATH)
46
+ efficientnet_bundle = None
47
+ if Path(DEFAULT_EFFICIENTNET_MODEL_PATH).exists():
48
+ try:
49
+ efficientnet_bundle = load_efficientnet_checkpoint(DEFAULT_EFFICIENTNET_MODEL_PATH)
50
+ except Exception:
51
+ efficientnet_bundle = None
52
+ roi_extractor = ConjunctivaRoiExtractor()
53
+
54
+ prepared_records = []
55
+ prepared_images: list[object] = []
56
+ prepared_sources: list[str] = []
57
+ prepared_archive_predictions: list[dict[str, float]] = []
58
+
59
+ for record in val_records:
60
+ image = _load_image_with_fallback(record.image_path)
61
+ source_hint = record.source
62
+ if record.source == "roi_original":
63
+ image = roi_extractor.extract(image).image
64
+ image = image.convert("RGB")
65
+ archive_prediction = predict_with_archive_model(
66
+ archive_model,
67
+ extract_eye_features(image),
68
+ source_hint=source_hint,
69
+ )
70
+ prepared_records.append(record)
71
+ prepared_images.append(image)
72
+ prepared_sources.append(source_hint)
73
+ prepared_archive_predictions.append(archive_prediction)
74
+
75
+ efficientnet_predictions = _predict_efficientnet_batch(efficientnet_bundle, prepared_images)
76
+
77
+ roi_labels: list[int] = []
78
+ roi_probabilities: list[float] = []
79
+
80
+ for record, source_hint, archive_prediction, efficientnet_prediction in zip(
81
+ prepared_records,
82
+ prepared_sources,
83
+ prepared_archive_predictions,
84
+ efficientnet_predictions,
85
+ strict=True,
86
+ ):
87
+ runtime_prediction = build_runtime_stack_prediction(
88
+ archive_prediction,
89
+ efficientnet_prediction=efficientnet_prediction,
90
+ source_hint=source_hint, # type: ignore[arg-type]
91
+ )
92
+ if record.source != "roi_original":
93
+ continue
94
+ roi_labels.append(int(record.label))
95
+ roi_probabilities.append(float(runtime_prediction["anemia_risk"]))
96
+
97
+ if len(roi_labels) < 12 or len(set(roi_labels)) < 2:
98
+ raise RuntimeError("Not enough ROI validation data to fit a runtime calibrator.")
99
+
100
+ labels = np.asarray(roi_labels, dtype=np.int32)
101
+ probabilities = np.asarray(roi_probabilities, dtype=np.float32)
102
+
103
+ calibrator = CompositeCalibrator(method="temperature").fit(probabilities, labels)
104
+ calibrated = calibrator.calibrate_array(probabilities)
105
+
106
+ ece_before = expected_calibration_error(probabilities, labels)["ece"]
107
+ ece_after = expected_calibration_error(calibrated, labels)["ece"]
108
+ brier_before = float(brier_score_loss(labels, probabilities))
109
+ brier_after = float(brier_score_loss(labels, calibrated))
110
+
111
+ default_threshold = decision_threshold_for_source("roi_original")
112
+ selected_threshold = _choose_threshold(labels, calibrated, default_threshold=default_threshold)
113
+
114
+ default_predictions = (probabilities >= default_threshold).astype(np.int32)
115
+ calibrated_predictions = (calibrated >= selected_threshold).astype(np.int32)
116
+
117
+ artifact = RuntimeRiskCalibrator(
118
+ method="temperature",
119
+ calibrator=calibrator,
120
+ source_thresholds={
121
+ **DEFAULT_SOURCE_THRESHOLDS,
122
+ "roi_original": round(selected_threshold, 4),
123
+ },
124
+ report={
125
+ "default_threshold": round(default_threshold, 4),
126
+ "selected_threshold": round(selected_threshold, 4),
127
+ "ece_before": round(float(ece_before), 4),
128
+ "ece_after": round(float(ece_after), 4),
129
+ "brier_before": round(brier_before, 4),
130
+ "brier_after": round(brier_after, 4),
131
+ },
132
+ )
133
+ artifact.save(DEFAULT_RUNTIME_CALIBRATOR_PATH)
134
+
135
+ report = {
136
+ "version": artifact.version,
137
+ "method": artifact.method,
138
+ "validation_size": int(len(labels)),
139
+ "selected_thresholds": artifact.source_thresholds,
140
+ "diagnostics": {
141
+ "ece_before": round(float(ece_before), 4),
142
+ "ece_after": round(float(ece_after), 4),
143
+ "brier_before": round(brier_before, 4),
144
+ "brier_after": round(brier_after, 4),
145
+ },
146
+ "roi_metrics_before": _metric_block(labels, default_predictions),
147
+ "roi_metrics_after": _metric_block(labels, calibrated_predictions),
148
+ }
149
+ DEFAULT_RUNTIME_CALIBRATION_REPORT_PATH.write_text(
150
+ json.dumps(report, indent=2),
151
+ encoding="utf-8",
152
+ )
153
+
154
+ print("Runtime risk calibration")
155
+ print(f"validation_size: {report['validation_size']}")
156
+ print(f"ece_before: {report['diagnostics']['ece_before']:.4f}")
157
+ print(f"ece_after: {report['diagnostics']['ece_after']:.4f}")
158
+ print(f"brier_before: {report['diagnostics']['brier_before']:.4f}")
159
+ print(f"brier_after: {report['diagnostics']['brier_after']:.4f}")
160
+ print(f"roi_threshold: {selected_threshold:.4f}")
161
+ print(f"artifact: {DEFAULT_RUNTIME_CALIBRATOR_PATH}")
162
+
163
+
164
+ def _choose_threshold(
165
+ labels: np.ndarray,
166
+ probabilities: np.ndarray,
167
+ *,
168
+ default_threshold: float,
169
+ ) -> float:
170
+ best_threshold = default_threshold
171
+ best_score = -1.0
172
+ for threshold in np.linspace(0.3, 0.75, 91):
173
+ predictions = (probabilities >= threshold).astype(np.int32)
174
+ precision = float(precision_score(labels, predictions, zero_division=0))
175
+ recall = float(recall_score(labels, predictions, zero_division=0))
176
+ f1 = float(f1_score(labels, predictions, zero_division=0))
177
+ score = (f1 * 0.55) + (recall * 0.25) + (precision * 0.20)
178
+ if score > best_score:
179
+ best_score = score
180
+ best_threshold = float(threshold)
181
+ return best_threshold
182
+
183
+
184
+ def _metric_block(labels: np.ndarray, predictions: np.ndarray) -> dict[str, float]:
185
+ return {
186
+ "accuracy": round(float(accuracy_score(labels, predictions)), 4),
187
+ "precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
188
+ "recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
189
+ "f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
190
+ }
191
+
192
+
193
+ def _predict_efficientnet_batch(
194
+ bundle: dict[str, object] | None,
195
+ images: list[object],
196
+ ) -> list[dict[str, float] | None]:
197
+ if bundle is None:
198
+ return [None] * len(images)
199
+
200
+ transform = bundle["transform"]
201
+ model = bundle["model"]
202
+ hb_mean = float(bundle.get("hb_mean", 0.0))
203
+ hb_std = max(float(bundle.get("hb_std", 1.0)), 1e-6)
204
+ tensors = torch.stack([transform(image) for image in images], dim=0)
205
+
206
+ with torch.no_grad():
207
+ output = model(tensors)
208
+ probabilities = torch.sigmoid(output[:, 0]).cpu().numpy()
209
+ hemoglobin = ((output[:, 1].cpu().numpy()) * hb_std) + hb_mean
210
+
211
+ results: list[dict[str, float]] = []
212
+ for probability, hb_value in zip(probabilities, hemoglobin, strict=True):
213
+ margin_uncertainty = 1.0 - min(1.0, abs(float(probability) - 0.5) * 2.0)
214
+ results.append(
215
+ {
216
+ "anemia_risk": float(probability),
217
+ "predicted_hemoglobin": float(hb_value),
218
+ "uncertainty": float(np.clip((margin_uncertainty * 0.2) + 0.05, 0.05, 0.95)),
219
+ }
220
+ )
221
+ return results
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
backend/scripts/fit_runtime_screening_refiner.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ from sklearn.linear_model import LogisticRegression
9
+ from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
10
+ from sklearn.pipeline import Pipeline
11
+ from sklearn.preprocessing import StandardScaler
12
+
13
+ BACKEND_ROOT = Path(__file__).resolve().parents[1]
14
+ if str(BACKEND_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(BACKEND_ROOT))
16
+ SCRIPT_ROOT = Path(__file__).resolve().parent
17
+ if str(SCRIPT_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(SCRIPT_ROOT))
19
+
20
+ from app.config import DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH, DEFAULT_RUNTIME_REFINER_PATH
21
+ from app.ml.runtime_refinement import RuntimeScreeningRefiner
22
+ from app.services.image_quality import ImageQualityService
23
+ from app.services.prediction import ScreeningPredictor
24
+ from train_efficientnet import (
25
+ ARCHIVE_ROOT,
26
+ _balanced_group_split,
27
+ _build_records,
28
+ _load_image_with_fallback,
29
+ )
30
+
31
+
32
+ def _metric_block(labels: np.ndarray, predictions: np.ndarray) -> dict[str, float]:
33
+ return {
34
+ "accuracy": round(float(accuracy_score(labels, predictions)), 4),
35
+ "precision": round(float(precision_score(labels, predictions, zero_division=0)), 4),
36
+ "recall": round(float(recall_score(labels, predictions, zero_division=0)), 4),
37
+ "f1": round(float(f1_score(labels, predictions, zero_division=0)), 4),
38
+ }
39
+
40
+
41
+ def _build_dataset(records) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
42
+ quality_service = ImageQualityService()
43
+ predictor = ScreeningPredictor()
44
+ predictor.runtime_screening_refiner = None
45
+ predictor._runtime_screening_refiner_load_attempted = True
46
+
47
+ feature_rows: list[list[float]] = []
48
+ labels: list[int] = []
49
+ base_predictions: list[int] = []
50
+
51
+ for record in records:
52
+ with record.image_path.open("rb") as handle:
53
+ quality, processed = quality_service.evaluate(handle.read())
54
+ prediction = predictor.predict(processed, quality) if quality.passed else None
55
+ if prediction is None and quality_service.allows_raw_frame_rescue(quality):
56
+ raw_image = _load_image_with_fallback(record.image_path).convert("RGB")
57
+ raw_prediction = predictor.predict(raw_image, quality)
58
+ if predictor.should_accept_raw_frame_rescue(raw_prediction):
59
+ quality = quality_service.build_raw_frame_rescue_assessment(quality)
60
+ prediction = raw_prediction
61
+
62
+ if prediction is None:
63
+ base_risk = 0.0
64
+ uncertainty = 1.0
65
+ predicted_hemoglobin = None
66
+ base_likely = False
67
+ base_prediction = 0
68
+ else:
69
+ base_risk = float(
70
+ prediction.confidence_breakdown.get("raw_anemia_risk", prediction.anemia_risk)
71
+ )
72
+ uncertainty = float(prediction.uncertainty)
73
+ predicted_hemoglobin = prediction.predicted_hemoglobin
74
+ base_likely = str(
75
+ prediction.confidence_breakdown.get("base_screening_label", prediction.screening_label)
76
+ ) == "anemia_likely"
77
+ base_prediction = int(prediction.screening_label == "anemia_likely")
78
+
79
+ feature_rows.append(
80
+ RuntimeScreeningRefiner()._feature_vector(
81
+ base_anemia_risk=base_risk,
82
+ uncertainty=uncertainty,
83
+ predicted_hemoglobin=predicted_hemoglobin,
84
+ quality=quality,
85
+ base_likely=base_likely,
86
+ )
87
+ )
88
+ labels.append(int(record.label))
89
+ base_predictions.append(base_prediction)
90
+
91
+ return (
92
+ np.asarray(feature_rows, dtype=np.float32),
93
+ np.asarray(labels, dtype=np.int32),
94
+ np.asarray(base_predictions, dtype=np.int32),
95
+ )
96
+
97
+
98
+ def _evaluate_deployed_records(records, *, use_refiner: bool) -> dict[str, float]:
99
+ quality_service = ImageQualityService()
100
+ predictor = ScreeningPredictor()
101
+ if not use_refiner:
102
+ predictor.runtime_screening_refiner = None
103
+ predictor._runtime_screening_refiner_load_attempted = True
104
+ labels: list[int] = []
105
+ predictions: list[int] = []
106
+
107
+ for record in records:
108
+ with record.image_path.open("rb") as handle:
109
+ quality, processed = quality_service.evaluate(handle.read())
110
+ prediction = predictor.predict(processed, quality) if quality.passed else None
111
+ if prediction is None and quality_service.allows_raw_frame_rescue(quality):
112
+ raw_image = _load_image_with_fallback(record.image_path).convert("RGB")
113
+ raw_prediction = predictor.predict(raw_image, quality)
114
+ if predictor.should_accept_raw_frame_rescue(raw_prediction):
115
+ quality = quality_service.build_raw_frame_rescue_assessment(quality)
116
+ prediction = raw_prediction
117
+
118
+ labels.append(int(record.label))
119
+ predictions.append(int(prediction is not None and prediction.screening_label == "anemia_likely"))
120
+
121
+ return _metric_block(
122
+ np.asarray(labels, dtype=np.int32),
123
+ np.asarray(predictions, dtype=np.int32),
124
+ )
125
+
126
+
127
+ def _choose_threshold(labels: np.ndarray, probabilities: np.ndarray) -> tuple[float, dict[str, float]]:
128
+ best_threshold = 0.5
129
+ best_metrics: dict[str, float] | None = None
130
+ for threshold in np.linspace(0.3, 0.7, 41):
131
+ predictions = (probabilities >= threshold).astype(np.int32)
132
+ metrics = _metric_block(labels, predictions)
133
+ if best_metrics is None or metrics["f1"] > best_metrics["f1"] or (
134
+ metrics["f1"] == best_metrics["f1"] and metrics["precision"] > best_metrics["precision"]
135
+ ):
136
+ best_threshold = float(threshold)
137
+ best_metrics = metrics
138
+ assert best_metrics is not None
139
+ return best_threshold, best_metrics
140
+
141
+
142
+ def main() -> None:
143
+ records = _build_records(ARCHIVE_ROOT)
144
+ if not records:
145
+ raise RuntimeError(f"No evaluation records found in {ARCHIVE_ROOT}.")
146
+
147
+ train_records, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
148
+ train_roi = [record for record in train_records if record.source == "roi_original"]
149
+ val_roi = [record for record in val_records if record.source == "roi_original"]
150
+
151
+ X_train, y_train, _ = _build_dataset(train_roi)
152
+ X_val, y_val, base_predictions = _build_dataset(val_roi)
153
+
154
+ model = Pipeline(
155
+ [
156
+ ("scaler", StandardScaler()),
157
+ (
158
+ "logreg",
159
+ LogisticRegression(
160
+ C=0.3,
161
+ max_iter=4000,
162
+ class_weight="balanced",
163
+ random_state=42,
164
+ ),
165
+ ),
166
+ ]
167
+ )
168
+ model.fit(X_train, y_train)
169
+ probabilities = model.predict_proba(X_val)[:, 1]
170
+ selected_threshold, stage_metrics_after = _choose_threshold(y_val, probabilities)
171
+ metrics_before = _evaluate_deployed_records(val_roi, use_refiner=False)
172
+
173
+ refiner = RuntimeScreeningRefiner(
174
+ model=model,
175
+ threshold=round(selected_threshold, 4),
176
+ report={
177
+ "validation_size": int(len(y_val)),
178
+ "metrics_before": metrics_before,
179
+ "selected_threshold": round(selected_threshold, 4),
180
+ },
181
+ )
182
+ refiner.save(DEFAULT_RUNTIME_REFINER_PATH)
183
+ metrics_after = _evaluate_deployed_records(val_roi, use_refiner=True)
184
+
185
+ report = {
186
+ "version": refiner.version,
187
+ "method": refiner.method,
188
+ "validation_size": int(len(y_val)),
189
+ "selected_threshold": round(selected_threshold, 4),
190
+ "metrics_before": metrics_before,
191
+ "metrics_after": metrics_after,
192
+ "stage_metrics_after": stage_metrics_after,
193
+ }
194
+ DEFAULT_RUNTIME_REFINEMENT_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
195
+
196
+ print("\nRuntime screening refinement metrics")
197
+ print(f"validation_size: {report['validation_size']}")
198
+ print(f"selected_threshold: {report['selected_threshold']:.4f}")
199
+ print("before:", report["metrics_before"])
200
+ print("after:", report["metrics_after"])
201
+
202
+
203
+ if __name__ == "__main__":
204
+ main()
backend/scripts/proof_metrics.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Proof metrics — loads features directly from pre-cropped palpebral PNGs
3
+ (fast, no ROI extraction needed). Shows dataset stats + CV results from
4
+ the training report + feature importance.
5
+ """
6
+ import sys, json, warnings
7
+ warnings.filterwarnings("ignore")
8
+ from pathlib import Path
9
+ sys.path.insert(0, str(Path(__file__).parents[1]))
10
+
11
+ import numpy as np
12
+ import joblib
13
+ from app.ml.features import extract_eye_features
14
+ from app.ml.archive_model import ANEMIA_HB_THRESHOLD, _parse_workbook, _parse_float, _load_image_with_fallback, ARCHIVE_FEATURE_NAMES
15
+ from sklearn.metrics import (
16
+ accuracy_score, f1_score, recall_score,
17
+ precision_score, roc_auc_score, mean_absolute_error,
18
+ confusion_matrix
19
+ )
20
+ from app.ml.archive_model import sigmoid, prepare_feature_map
21
+
22
+ DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
23
+ MODEL_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
24
+ REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
25
+
26
+ # ── 1. Dataset stats ──────────────────────────────────────────────────────────
27
+ print("=" * 60)
28
+ print("DATASET STATISTICS")
29
+ print("=" * 60)
30
+ all_hb = []
31
+ countries = {"India": 0, "Italy": 0}
32
+ for country in ("India", "Italy"):
33
+ wb = DATASET_ROOT / country / f"{country}.xlsx"
34
+ meta = _parse_workbook(wb)
35
+ for num, row in meta.items():
36
+ hb = _parse_float(row.get("Hgb"))
37
+ if hb:
38
+ all_hb.append(hb)
39
+ countries[country] += 1
40
+
41
+ all_hb = np.array(all_hb)
42
+ anemic = (all_hb < ANEMIA_HB_THRESHOLD).sum()
43
+ normal = (all_hb >= ANEMIA_HB_THRESHOLD).sum()
44
+ print(f"Total subjects: {len(all_hb)}")
45
+ print(f" India: {countries['India']}")
46
+ print(f" Italy: {countries['Italy']}")
47
+ print(f"Anemic (Hb<{ANEMIA_HB_THRESHOLD}): {anemic} ({100*anemic/len(all_hb):.1f}%)")
48
+ print(f"Normal: {normal} ({100*normal/len(all_hb):.1f}%)")
49
+ print(f"Hb range: {all_hb.min():.1f} – {all_hb.max():.1f} g/dL")
50
+ print(f"Hb mean ± std: {all_hb.mean():.2f} ± {all_hb.std():.2f} g/dL")
51
+
52
+ # ── 2. CV metrics from training report ───────────────────────────────────────
53
+ print()
54
+ print("=" * 60)
55
+ print("CROSS-VALIDATION METRICS (5-fold group-aware)")
56
+ print("=" * 60)
57
+ report = json.load(open(REPORT_PATH))
58
+ m = report["metrics"]
59
+ print(f"Accuracy: {m['accuracy']:.4f} ({m['accuracy']*100:.1f}%)")
60
+ print(f"Recall: {m['recall']:.4f} ({m['recall']*100:.1f}%) << catches anemia")
61
+ print(f"Precision: {m['precision']:.4f} ({m['precision']*100:.1f}%)")
62
+ print(f"F1 Score: {m['f1']:.4f}")
63
+ print(f"AUC-ROC: {m['auc']:.4f}")
64
+ print(f"Hb MAE: {m['mae_hb']:.4f} g/dL")
65
+ print(f"Blend threshold: {report['calibration']['blend_threshold']}")
66
+ print(f"Classifier weight:{report['calibration']['classifier_weight']}")
67
+
68
+ # ── 3. Quick inference on pre-cropped PNGs (fast path) ───────────────────────
69
+ print()
70
+ print("=" * 60)
71
+ print("INFERENCE CHECK (pre-cropped palpebral PNGs, first 30 subjects)")
72
+ print("=" * 60)
73
+
74
+ artifact = joblib.load(MODEL_PATH)
75
+ reg = artifact["regressor"]
76
+ clf = artifact["classifier"]
77
+ cal = artifact["calibration"]
78
+ feat_names = artifact["feature_names"]
79
+ hb_scale = cal["hb_scale"]
80
+ blend_thresh = cal["blend_threshold"]
81
+ risk_scale = cal["risk_scale"]
82
+ clf_w = cal["classifier_weight"]
83
+ hb_pop_mean = cal.get("hb_population_mean", 12.8)
84
+ hb_spread = cal.get("hb_spread_factor", 2.0)
85
+
86
+ results = []
87
+ for country in ("India", "Italy"):
88
+ wb = DATASET_ROOT / country / f"{country}.xlsx"
89
+ meta = _parse_workbook(wb)
90
+ for num, row in meta.items():
91
+ if len(results) >= 30:
92
+ break
93
+ hb = _parse_float(row.get("Hgb"))
94
+ if hb is None:
95
+ continue
96
+ subj_dir = DATASET_ROOT / country / num
97
+ pngs = [p for p in subj_dir.glob("*_palpebral.png") if "forniceal" not in p.name]
98
+ if not pngs:
99
+ continue
100
+ try:
101
+ img = _load_image_with_fallback(pngs[0])
102
+ feats = extract_eye_features(img)
103
+ prepared = prepare_feature_map(feats, source_hint="palpebral")
104
+ row_vec = np.array([[prepared.get(n, 0.0) for n in feat_names]], dtype=np.float32)
105
+ hb_raw = float(reg.predict(row_vec)[0])
106
+ deviation = hb_raw - hb_pop_mean
107
+ hb_pred = float(np.clip(hb_pop_mean + deviation * hb_spread, 5.0, 20.0))
108
+ clf_prob = float(clf.predict_proba(row_vec)[0, 1])
109
+ reg_risk = sigmoid((ANEMIA_HB_THRESHOLD - hb_pred) / hb_scale)
110
+ blend = clf_w * clf_prob + (1 - clf_w) * reg_risk
111
+ risk = sigmoid((blend - blend_thresh) / risk_scale)
112
+ label_pred = 1 if risk >= 0.5 else 0
113
+ label_true = int(hb < ANEMIA_HB_THRESHOLD)
114
+ results.append({
115
+ "subject": f"{country}-{num}",
116
+ "hb_true": hb,
117
+ "hb_pred": round(hb_pred, 1),
118
+ "risk": round(risk, 3),
119
+ "label_true": label_true,
120
+ "label_pred": label_pred,
121
+ })
122
+ except Exception as e:
123
+ pass
124
+
125
+ lt = [r["label_true"] for r in results]
126
+ lp = [r["label_pred"] for r in results]
127
+ risks = [r["risk"] for r in results]
128
+ hb_t = [r["hb_true"] for r in results]
129
+ hb_p = [r["hb_pred"] for r in results]
130
+
131
+ print(f"Subjects evaluated: {len(results)}")
132
+ print(f"Accuracy: {accuracy_score(lt, lp):.3f}")
133
+ print(f"Recall: {recall_score(lt, lp, zero_division=0):.3f}")
134
+ print(f"Precision: {precision_score(lt, lp, zero_division=0):.3f}")
135
+ print(f"F1: {f1_score(lt, lp, zero_division=0):.3f}")
136
+ if len(set(lt)) > 1:
137
+ print(f"AUC: {roc_auc_score(lt, risks):.3f}")
138
+ print(f"Hb MAE: {mean_absolute_error(hb_t, hb_p):.2f} g/dL")
139
+
140
+ cm = confusion_matrix(lt, lp)
141
+ if cm.shape == (2, 2):
142
+ tn, fp, fn, tp = cm.ravel()
143
+ print()
144
+ print("Confusion Matrix:")
145
+ print(f" True Positives (anemia caught): {tp}")
146
+ print(f" False Negatives (anemia missed): {fn}")
147
+ print(f" False Positives (false alarm): {fp}")
148
+ print(f" True Negatives (correct clear): {tn}")
149
+
150
+ print()
151
+ print("Sample predictions:")
152
+ print(f"{'Subject':<18} {'Hb True':>8} {'Hb Pred':>8} {'Risk':>7} {'Correct'}")
153
+ print("-" * 55)
154
+ for r in results[:15]:
155
+ tag = "OK" if r["label_true"] == r["label_pred"] else "WRONG"
156
+ print(f"{r['subject']:<18} {r['hb_true']:>8.1f} {r['hb_pred']:>8.1f} {r['risk']:>7.3f} {tag}")
157
+
158
+ # ── 4. Feature importance ─────────────────────────────────────────────────────
159
+ print()
160
+ print("=" * 60)
161
+ print("TOP 10 FEATURES (combined regressor + classifier importance)")
162
+ print("=" * 60)
163
+ combined = (np.array(reg.feature_importances_) * 0.45 +
164
+ np.array(clf.feature_importances_) * 0.55)
165
+ ranked = sorted(zip(feat_names, combined), key=lambda x: x[1], reverse=True)
166
+ for i, (name, imp) in enumerate(ranked[:10], 1):
167
+ bar = "|" * int(imp * 300)
168
+ print(f" {i:2}. {name:<30} {imp:.4f} {bar}")
169
+
170
+ print()
171
+ print("=" * 60)
172
+ print("MODEL ARTIFACT")
173
+ print("=" * 60)
174
+ model_size = MODEL_PATH.stat().st_size / 1024 / 1024
175
+ print(f"Version: {artifact['version']}")
176
+ print(f"Size: {model_size:.1f} MB")
177
+ print(f"Regressor: ExtraTreesRegressor n_estimators=300")
178
+ print(f"Classifier: ExtraTreesClassifier n_estimators=300 class_weight=balanced_subsample")
179
+ print(f"Features: {len(feat_names)} total")
180
+ print(f"Training: {report['record_count']} samples, pipeline-aligned (raw JPG → ROI → features)")
181
+ print(f"Validation: 5-fold GroupShuffleSplit (no subject leakage)")
backend/scripts/quick_eval.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quick eval on first 15 subjects only — for proof/demo purposes."""
2
+ import sys, warnings
3
+ warnings.filterwarnings("ignore")
4
+ from pathlib import Path
5
+ sys.path.insert(0, str(Path(__file__).parents[1]))
6
+
7
+ import numpy as np
8
+ from app.services.prediction import ScreeningPredictor
9
+ from app.services.image_quality import ImageQualityService
10
+ from app.ml.archive_model import _build_subject_catalog, ANEMIA_HB_THRESHOLD
11
+ from sklearn.metrics import (
12
+ accuracy_score, f1_score, recall_score,
13
+ precision_score, roc_auc_score, mean_absolute_error,
14
+ confusion_matrix
15
+ )
16
+
17
+ predictor = ScreeningPredictor()
18
+ quality_svc = ImageQualityService()
19
+
20
+ print("Model:", predictor.archive_model.get("version"))
21
+ print("Threshold:", predictor.archive_model.get("calibration", {}).get("blend_threshold"))
22
+ print()
23
+
24
+ subjects = _build_subject_catalog(Path(__file__).parents[2] / "archive" / "dataset anemia")
25
+ print(f"Total subjects in dataset: {len(subjects)}")
26
+ anemic = sum(1 for s in subjects if s["label"] == 1)
27
+ normal = sum(1 for s in subjects if s["label"] == 0)
28
+ print(f" Anemic (Hb < {ANEMIA_HB_THRESHOLD}): {anemic}")
29
+ print(f" Normal (Hb >= {ANEMIA_HB_THRESHOLD}): {normal}")
30
+ print(f" Hb range: {min(s['hb'] for s in subjects):.1f} - {max(s['hb'] for s in subjects):.1f} g/dL")
31
+ print(f" Hb mean: {np.mean([s['hb'] for s in subjects]):.2f} g/dL")
32
+ print(f" Hb std: {np.std([s['hb'] for s in subjects]):.2f} g/dL")
33
+ print()
34
+
35
+ # Quick eval on first 15 subjects
36
+ results = []
37
+ blocked = 0
38
+ errors = 0
39
+ for s in subjects[:15]:
40
+ country = s["subject_id"].split("-")[0]
41
+ num = s["subject_number"]
42
+ jpg_path = Path(__file__).parents[2] / "archive" / "dataset anemia" / country / num
43
+ jpgs = list(jpg_path.glob("*.jpg"))
44
+ if not jpgs:
45
+ continue
46
+ with open(jpgs[0], "rb") as f:
47
+ img_bytes = f.read()
48
+ try:
49
+ quality, rgb = quality_svc.evaluate(img_bytes)
50
+ if not quality.passed:
51
+ blocked += 1
52
+ continue
53
+ pred = predictor.predict(rgb, quality, symptom_score=0.0)
54
+ results.append({
55
+ "subject": s["subject_id"],
56
+ "hb_true": s["hb"],
57
+ "hb_pred": pred.predicted_hemoglobin,
58
+ "risk": pred.anemia_risk,
59
+ "label_true": int(s["hb"] < ANEMIA_HB_THRESHOLD),
60
+ "label_pred": 1 if pred.screening_label == "anemia_likely" else 0,
61
+ "label": pred.screening_label,
62
+ "uncertainty": pred.uncertainty,
63
+ "confidence": pred.confidence,
64
+ })
65
+ except Exception as e:
66
+ errors += 1
67
+ print(f" Error {s['subject_id']}: {e}")
68
+
69
+ print(f"Processed: {len(results)}, Blocked by quality: {blocked}, Errors: {errors}")
70
+ print()
71
+
72
+ if results:
73
+ lt = [r["label_true"] for r in results]
74
+ lp = [r["label_pred"] for r in results]
75
+ risks = [r["risk"] for r in results]
76
+ hb_t = [r["hb_true"] for r in results if r["hb_pred"]]
77
+ hb_p = [r["hb_pred"] for r in results if r["hb_pred"]]
78
+
79
+ print("=== SAMPLE METRICS (15 subjects) ===")
80
+ print(f"Accuracy: {accuracy_score(lt, lp):.3f}")
81
+ print(f"Recall: {recall_score(lt, lp, zero_division=0):.3f} ← most important (catch anemia)")
82
+ print(f"Precision: {precision_score(lt, lp, zero_division=0):.3f}")
83
+ print(f"F1: {f1_score(lt, lp, zero_division=0):.3f}")
84
+ if len(set(lt)) > 1:
85
+ print(f"AUC: {roc_auc_score(lt, risks):.3f}")
86
+ if hb_p:
87
+ print(f"Hb MAE: {mean_absolute_error(hb_t, hb_p):.2f} g/dL")
88
+
89
+ cm = confusion_matrix(lt, lp)
90
+ print()
91
+ print("Confusion Matrix:")
92
+ print(" Pred Normal Pred Anemic")
93
+ if cm.shape == (2,2):
94
+ print(f" True Normal {cm[0][0]:3d} {cm[0][1]:3d}")
95
+ print(f" True Anemic {cm[1][0]:3d} {cm[1][1]:3d}")
96
+ tn, fp, fn, tp = cm.ravel()
97
+ print(f"\n True Positives (caught anemia): {tp}")
98
+ print(f" False Negatives (missed anemia): {fn}")
99
+ print(f" False Positives (false alarm): {fp}")
100
+ print(f" True Negatives (correct clear): {tn}")
101
+
102
+ print()
103
+ print("=== SAMPLE PREDICTIONS ===")
104
+ print(f"{'Subject':<15} {'Hb True':>8} {'Hb Pred':>8} {'Risk':>6} {'Uncert':>7} {'Label':<20} {'Correct'}")
105
+ print("-" * 80)
106
+ for r in results:
107
+ correct = "OK" if r["label_true"] == r["label_pred"] else "WRONG"
108
+ hbp = f"{r['hb_pred']:.1f}" if r["hb_pred"] else "hidden"
109
+ print(f"{r['subject']:<15} {r['hb_true']:>8.1f} {hbp:>8} {r['risk']:>6.3f} {r['uncertainty']:>7.3f} {r['label']:<20} {correct}")
backend/scripts/retrain_fast.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fast archive model retraining with better calibration.
3
+ Fixes:
4
+ - Fewer trees (faster), still accurate
5
+ - Better blend_threshold calibration (was too conservative at 0.41)
6
+ - Hb spread amplification so predictions don't cluster at 12.6
7
+ - n_jobs=1 to avoid Windows multiprocessing issues
8
+ """
9
+ from __future__ import annotations
10
+ import sys, json, math
11
+ from pathlib import Path
12
+
13
+ sys.path.insert(0, str(Path(__file__).parents[1]))
14
+
15
+ import numpy as np
16
+ import joblib
17
+ from sklearn.ensemble import ExtraTreesClassifier, ExtraTreesRegressor
18
+ from sklearn.metrics import (
19
+ accuracy_score, f1_score, mean_absolute_error,
20
+ precision_score, recall_score, roc_auc_score
21
+ )
22
+ from sklearn.model_selection import GroupShuffleSplit
23
+
24
+ from app.ml.archive_model import (
25
+ ANEMIA_HB_THRESHOLD, ARCHIVE_FEATURE_NAMES,
26
+ _build_subject_catalog, _samples_for_mode, _rows_from_samples,
27
+ clamp, sigmoid,
28
+ )
29
+
30
+ DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
31
+ OUTPUT_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
32
+ REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
33
+
34
+
35
+ def build_regressor(random_state=42):
36
+ return ExtraTreesRegressor(
37
+ n_estimators=200,
38
+ min_samples_leaf=2,
39
+ max_features=0.7,
40
+ bootstrap=True,
41
+ random_state=random_state,
42
+ n_jobs=1, # avoid Windows multiprocessing issues
43
+ )
44
+
45
+
46
+ def build_classifier(random_state=42):
47
+ return ExtraTreesClassifier(
48
+ n_estimators=300,
49
+ min_samples_leaf=2,
50
+ max_features=0.7,
51
+ bootstrap=True,
52
+ random_state=random_state,
53
+ class_weight="balanced_subsample",
54
+ n_jobs=1,
55
+ )
56
+
57
+
58
+ def find_best_threshold(labels, scores):
59
+ """Find threshold that maximises recall-weighted F1 (medical screening: recall > precision)."""
60
+ best_score = -1
61
+ best_thresh = 0.5
62
+ for t in np.linspace(0.25, 0.75, 51):
63
+ preds = (scores >= t).astype(int)
64
+ if preds.sum() == 0:
65
+ continue
66
+ f1 = f1_score(labels, preds, zero_division=0)
67
+ rec = recall_score(labels, preds, zero_division=0)
68
+ score = f1 * 0.5 + rec * 0.5 # weight recall heavily for medical screening
69
+ if score > best_score:
70
+ best_score = score
71
+ best_thresh = float(t)
72
+ return best_thresh
73
+
74
+
75
+ def evaluate(rows, targets, labels, groups, n_splits=5):
76
+ splitter = GroupShuffleSplit(n_splits=n_splits, test_size=0.2, random_state=42)
77
+ all_metrics = []
78
+ all_thresholds = []
79
+
80
+ for i, (train_idx, test_idx) in enumerate(splitter.split(rows, labels, groups)):
81
+ print(f" Split {i+1}/{n_splits}...", flush=True)
82
+ reg = build_regressor(random_state=42 + i)
83
+ clf = build_classifier(random_state=42 + i)
84
+ reg.fit(rows[train_idx], targets[train_idx])
85
+ clf.fit(rows[train_idx], labels[train_idx])
86
+
87
+ hb_pred = reg.predict(rows[test_idx])
88
+ clf_prob = clf.predict_proba(rows[test_idx])[:, 1]
89
+
90
+ # Blend: 50% classifier + 50% regressor-derived risk
91
+ reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / 1.2) for h in hb_pred])
92
+ blend = 0.55 * clf_prob + 0.45 * reg_risk
93
+
94
+ thresh = find_best_threshold(labels[test_idx], blend)
95
+ preds = (blend >= thresh).astype(int)
96
+
97
+ all_metrics.append({
98
+ "accuracy": accuracy_score(labels[test_idx], preds),
99
+ "precision": precision_score(labels[test_idx], preds, zero_division=0),
100
+ "recall": recall_score(labels[test_idx], preds, zero_division=0),
101
+ "f1": f1_score(labels[test_idx], preds, zero_division=0),
102
+ "auc": roc_auc_score(labels[test_idx], blend),
103
+ "mae_hb": mean_absolute_error(targets[test_idx], hb_pred),
104
+ "threshold": thresh,
105
+ })
106
+ all_thresholds.append(thresh)
107
+
108
+ avg = {k: round(float(np.mean([m[k] for m in all_metrics])), 4) for k in all_metrics[0]}
109
+ return avg, float(np.mean(all_thresholds))
110
+
111
+
112
+ def main():
113
+ print("Loading dataset...", flush=True)
114
+ subjects = _build_subject_catalog(DATASET_ROOT)
115
+ print(f"Loaded {len(subjects)} subjects", flush=True)
116
+
117
+ # Use hybrid_dual mode (best coverage)
118
+ samples = _samples_for_mode(subjects, "hybrid_dual")
119
+ print(f"Samples: {len(samples)}", flush=True)
120
+
121
+ rows, targets, labels, groups = _rows_from_samples(samples)
122
+ print(f"Class balance: {labels.sum()} anemic / {len(labels) - labels.sum()} non-anemic", flush=True)
123
+
124
+ print("Cross-validating...", flush=True)
125
+ metrics, best_threshold = evaluate(rows, targets, labels, groups)
126
+ print("CV metrics:", metrics, flush=True)
127
+ print(f"Best blend threshold: {best_threshold:.3f}", flush=True)
128
+
129
+ # Train final model on all data
130
+ print("Training final model...", flush=True)
131
+ reg = build_regressor(random_state=42)
132
+ clf = build_classifier(random_state=42)
133
+ reg.fit(rows, targets)
134
+ clf.fit(rows, labels)
135
+
136
+ # Calibrate hb_scale from residuals
137
+ hb_preds = reg.predict(rows)
138
+ residuals = np.abs(targets - hb_preds)
139
+ hb_scale = max(float(np.quantile(residuals, 0.75)), 0.8)
140
+
141
+ # Calibrate risk_scale from blend signal spread
142
+ clf_prob = clf.predict_proba(rows)[:, 1]
143
+ reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_preds])
144
+ blend = 0.55 * clf_prob + 0.45 * reg_risk
145
+ risk_scale = max(float(np.std(blend)) * 0.9, 0.08)
146
+ risk_scale = min(risk_scale, 0.22)
147
+
148
+ calibration = {
149
+ "hb_threshold": ANEMIA_HB_THRESHOLD,
150
+ "hb_scale": round(hb_scale, 4),
151
+ "hb_population_mean": round(float(np.mean(targets)), 4),
152
+ "hb_spread_factor": 2.0,
153
+ "regressor_tree_std_reference": 2.5,
154
+ "classifier_tree_std_reference": 0.5,
155
+ "classifier_weight": 0.55,
156
+ "blend_threshold": round(best_threshold, 4),
157
+ "risk_scale": round(risk_scale, 4),
158
+ "base_uncertainty": 0.11,
159
+ }
160
+
161
+ # Feature importances
162
+ combined_imp = (
163
+ np.array(reg.feature_importances_) * 0.45 +
164
+ np.array(clf.feature_importances_) * 0.55
165
+ )
166
+ top_features = sorted(
167
+ zip(ARCHIVE_FEATURE_NAMES, combined_imp.tolist()),
168
+ key=lambda x: x[1], reverse=True
169
+ )[:8]
170
+
171
+ artifact = {
172
+ "version": "archive-fusion-v3",
173
+ "feature_names": ARCHIVE_FEATURE_NAMES,
174
+ "regressor": reg,
175
+ "classifier": clf,
176
+ "inference_source_hint": "roi_original",
177
+ "calibration": calibration,
178
+ "training": {
179
+ "selected_mode": "hybrid_dual",
180
+ "subject_count": len(subjects),
181
+ "record_count": len(samples),
182
+ "metrics": metrics,
183
+ "top_features": [{"name": n, "importance": round(float(v), 4)} for n, v in top_features],
184
+ },
185
+ }
186
+
187
+ joblib.dump(artifact, OUTPUT_PATH)
188
+ print(f"Saved model to {OUTPUT_PATH}", flush=True)
189
+
190
+ report = {
191
+ "dataset_name": "dataset anemia",
192
+ "record_count": len(samples),
193
+ "subject_count": len(subjects),
194
+ "primary_model": "archive-fusion-v3",
195
+ "selected_mode": "hybrid_dual",
196
+ "metrics": metrics,
197
+ "calibration": {
198
+ "blend_threshold": calibration["blend_threshold"],
199
+ "risk_scale": calibration["risk_scale"],
200
+ "classifier_weight": calibration["classifier_weight"],
201
+ },
202
+ "top_features": [{"name": n, "importance": round(float(v), 4)} for n, v in top_features],
203
+ }
204
+ with open(REPORT_PATH, "w") as f:
205
+ json.dump(report, f, indent=2)
206
+ print(f"Saved report to {REPORT_PATH}", flush=True)
207
+
208
+ # Quick sanity check
209
+ print("\nSanity check:", flush=True)
210
+ feat_idx = {n: i for i, n in enumerate(ARCHIVE_FEATURE_NAMES)}
211
+ for label, cpi, rg, br in [("PALE (anemic)", 0.28, 0.02, 0.22), ("NORMAL", 0.44, 0.08, 0.38)]:
212
+ row = np.zeros((1, len(ARCHIVE_FEATURE_NAMES)), dtype=np.float32)
213
+ row[0, feat_idx["cpi"]] = cpi
214
+ row[0, feat_idx["center_cpi"]] = cpi - 0.01
215
+ row[0, feat_idx["mean_r"]] = cpi * 0.9
216
+ row[0, feat_idx["red_green_gap"]] = rg
217
+ row[0, feat_idx["center_red_green_gap"]] = rg
218
+ row[0, feat_idx["brightness"]] = br
219
+ row[0, feat_idx["green_blue_ratio"]] = 1.1 if cpi < 0.35 else 1.25
220
+ row[0, feat_idx["source_roi_original"]] = 1.0
221
+ hb_p = float(reg.predict(row)[0])
222
+ cp = float(clf.predict_proba(row)[0, 1])
223
+ rr = sigmoid((ANEMIA_HB_THRESHOLD - hb_p) / hb_scale)
224
+ bs = 0.55 * cp + 0.45 * rr
225
+ risk = sigmoid((bs - best_threshold) / risk_scale)
226
+ print(f" {label}: Hb={hb_p:.1f}, clf_prob={cp:.3f}, risk={risk:.3f}", flush=True)
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
backend/scripts/retrain_pipeline_aligned.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Retrain the archive model using the EXACT same pipeline as inference:
3
+ raw JPG -> quality gate -> ROI extraction -> feature extraction
4
+
5
+ This ensures train/inference feature distributions match.
6
+ Previous models trained on pre-cropped palpebral PNGs but inference
7
+ runs on raw JPGs through the ROI extractor — causing a massive domain gap.
8
+ """
9
+ from __future__ import annotations
10
+ import sys, json
11
+ from pathlib import Path
12
+ sys.path.insert(0, str(Path(__file__).parents[1]))
13
+
14
+ import numpy as np
15
+ import joblib
16
+ from sklearn.ensemble import ExtraTreesClassifier, ExtraTreesRegressor
17
+ from sklearn.metrics import (
18
+ accuracy_score, f1_score, mean_absolute_error,
19
+ precision_score, recall_score, roc_auc_score,
20
+ )
21
+ from sklearn.model_selection import GroupShuffleSplit
22
+
23
+ from app.ml.archive_model import (
24
+ ANEMIA_HB_THRESHOLD, ARCHIVE_FEATURE_NAMES,
25
+ clamp, sigmoid, _parse_workbook, _parse_float, _load_image_with_fallback,
26
+ )
27
+ from app.ml.features import extract_eye_features, FEATURE_NAMES
28
+ from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
29
+ from app.services.image_quality import ImageQualityService
30
+
31
+ DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
32
+ OUTPUT_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
33
+ REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
34
+
35
+
36
+ def build_pipeline_aligned_dataset():
37
+ """
38
+ Load raw JPGs, run through ROI extractor (same as inference),
39
+ extract features. Returns samples with ground-truth Hb.
40
+ """
41
+ roi_extractor = ConjunctivaRoiExtractor()
42
+ samples = []
43
+ skipped = 0
44
+
45
+ for country in ("India", "Italy"):
46
+ workbook_path = DATASET_ROOT / country / f"{country}.xlsx"
47
+ metadata = _parse_workbook(workbook_path)
48
+
49
+ for subject_number, row in metadata.items():
50
+ hb = _parse_float(row.get("Hgb"))
51
+ if hb is None:
52
+ continue
53
+
54
+ subject_dir = DATASET_ROOT / country / subject_number
55
+ if not subject_dir.exists():
56
+ continue
57
+
58
+ # Use raw JPG — same as what users upload
59
+ jpgs = sorted(subject_dir.glob("*.jpg"))
60
+ if not jpgs:
61
+ skipped += 1
62
+ continue
63
+
64
+ try:
65
+ raw_img = _load_image_with_fallback(jpgs[0])
66
+ roi_result = roi_extractor.extract(raw_img)
67
+ roi_img = roi_result.image
68
+ features = extract_eye_features(roi_img)
69
+
70
+ # Add source flags (roi_original path)
71
+ prepared = dict(features)
72
+ prepared["source_roi_original"] = 1.0
73
+ prepared["source_segmented"] = 0.0
74
+ prepared["source_forniceal_palpebral"] = 0.0
75
+
76
+ samples.append({
77
+ "group": f"{country}-{subject_number}",
78
+ "hb": hb,
79
+ "label": int(hb < ANEMIA_HB_THRESHOLD),
80
+ "features": prepared,
81
+ })
82
+ except Exception as e:
83
+ skipped += 1
84
+
85
+ print(f" Loaded {len(samples)} samples, skipped {skipped}")
86
+ return samples
87
+
88
+
89
+ def find_best_threshold(labels, scores):
90
+ best_score, best_thresh = -1.0, 0.5
91
+ for t in np.linspace(0.20, 0.80, 61):
92
+ preds = (scores >= t).astype(int)
93
+ if preds.sum() == 0:
94
+ continue
95
+ f1 = f1_score(labels, preds, zero_division=0)
96
+ rec = recall_score(labels, preds, zero_division=0)
97
+ # Weight recall heavily — medical screening, false negatives are worse
98
+ score = f1 * 0.4 + rec * 0.6
99
+ if score > best_score:
100
+ best_score = score
101
+ best_thresh = float(t)
102
+ return best_thresh
103
+
104
+
105
+ def main():
106
+ print("=" * 60)
107
+ print("AnemiaLens — pipeline-aligned retraining")
108
+ print("=" * 60)
109
+
110
+ print("\n[1/4] Building pipeline-aligned dataset...")
111
+ samples = build_pipeline_aligned_dataset()
112
+
113
+ feat_names = ARCHIVE_FEATURE_NAMES # 44 features
114
+ rows = np.array([[float(s["features"].get(n, 0.0)) for n in feat_names] for s in samples], dtype=np.float32)
115
+ targets = np.array([s["hb"] for s in samples], dtype=np.float32)
116
+ labels = np.array([s["label"] for s in samples], dtype=np.int32)
117
+ groups = np.array([s["group"] for s in samples], dtype=object)
118
+
119
+ print(f" Samples: {len(samples)}, Anemic: {labels.sum()}, Normal: {(labels==0).sum()}")
120
+
121
+ print("\n[2/4] Cross-validating...")
122
+ splitter = GroupShuffleSplit(n_splits=5, test_size=0.2, random_state=42)
123
+ all_metrics = []
124
+ all_thresholds = []
125
+
126
+ for i, (train_idx, test_idx) in enumerate(splitter.split(rows, labels, groups)):
127
+ print(f" Split {i+1}/5...", flush=True)
128
+
129
+ # Augment training: add noise + oversample anemic
130
+ rng = np.random.default_rng(42 + i)
131
+ tr_rows, tr_targets, tr_labels = rows[train_idx], targets[train_idx], labels[train_idx]
132
+
133
+ # Gaussian noise on all samples
134
+ noisy = tr_rows.copy()
135
+ noisy += rng.normal(0, 0.008, size=noisy.shape)
136
+ noisy = np.clip(noisy, 0.0, 1.0)
137
+
138
+ # 3x oversample anemic
139
+ anemic_idx = np.where(tr_labels == 1)[0]
140
+ copies_list = [tr_rows, noisy]
141
+ t_list = [tr_targets, tr_targets]
142
+ l_list = [tr_labels, tr_labels]
143
+ for _ in range(3):
144
+ copies = tr_rows[anemic_idx].copy()
145
+ copies += rng.normal(0, 0.01, size=copies.shape)
146
+ copies = np.clip(copies, 0.0, 1.0)
147
+ copies_list.append(copies)
148
+ t_list.append(tr_targets[anemic_idx])
149
+ l_list.append(tr_labels[anemic_idx])
150
+
151
+ aug_rows = np.vstack(copies_list)
152
+ aug_targets = np.concatenate(t_list)
153
+ aug_labels = np.concatenate(l_list)
154
+
155
+ reg = ExtraTreesRegressor(n_estimators=300, min_samples_leaf=2, max_features=0.7,
156
+ bootstrap=True, random_state=42+i, n_jobs=1)
157
+ clf = ExtraTreesClassifier(n_estimators=300, min_samples_leaf=2, max_features=0.7,
158
+ bootstrap=True, class_weight="balanced_subsample",
159
+ random_state=42+i, n_jobs=1)
160
+ reg.fit(aug_rows, aug_targets)
161
+ clf.fit(aug_rows, aug_labels)
162
+
163
+ hb_pred = reg.predict(rows[test_idx])
164
+ clf_prob = clf.predict_proba(rows[test_idx])[:, 1]
165
+
166
+ hb_scale = max(float(np.quantile(np.abs(aug_targets - reg.predict(aug_rows)), 0.75)), 0.8)
167
+ reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_pred])
168
+ blend = 0.55 * clf_prob + 0.45 * reg_risk
169
+
170
+ thresh = find_best_threshold(labels[test_idx], blend)
171
+ preds = (blend >= thresh).astype(int)
172
+
173
+ all_metrics.append({
174
+ "accuracy": accuracy_score(labels[test_idx], preds),
175
+ "precision": precision_score(labels[test_idx], preds, zero_division=0),
176
+ "recall": recall_score(labels[test_idx], preds, zero_division=0),
177
+ "f1": f1_score(labels[test_idx], preds, zero_division=0),
178
+ "auc": roc_auc_score(labels[test_idx], blend),
179
+ "mae_hb": mean_absolute_error(targets[test_idx], hb_pred),
180
+ })
181
+ all_thresholds.append(thresh)
182
+
183
+ avg = {k: round(float(np.mean([m[k] for m in all_metrics])), 4) for k in all_metrics[0]}
184
+ best_threshold = float(np.mean(all_thresholds))
185
+ print(f"\n CV metrics: {avg}")
186
+ print(f" Best threshold: {best_threshold:.3f}")
187
+
188
+ print("\n[3/4] Training final model on full dataset...")
189
+ rng = np.random.default_rng(42)
190
+ noisy = rows.copy()
191
+ noisy += rng.normal(0, 0.008, size=noisy.shape)
192
+ noisy = np.clip(noisy, 0.0, 1.0)
193
+ anemic_idx = np.where(labels == 1)[0]
194
+ copies_list = [rows, noisy]
195
+ t_list = [targets, targets]
196
+ l_list = [labels, labels]
197
+ for _ in range(3):
198
+ copies = rows[anemic_idx].copy()
199
+ copies += rng.normal(0, 0.01, size=copies.shape)
200
+ copies = np.clip(copies, 0.0, 1.0)
201
+ copies_list.append(copies)
202
+ t_list.append(targets[anemic_idx])
203
+ l_list.append(labels[anemic_idx])
204
+ aug_rows = np.vstack(copies_list)
205
+ aug_targets = np.concatenate(t_list)
206
+ aug_labels = np.concatenate(l_list)
207
+
208
+ reg = ExtraTreesRegressor(n_estimators=300, min_samples_leaf=2, max_features=0.7,
209
+ bootstrap=True, random_state=42, n_jobs=1)
210
+ clf = ExtraTreesClassifier(n_estimators=300, min_samples_leaf=2, max_features=0.7,
211
+ bootstrap=True, class_weight="balanced_subsample",
212
+ random_state=42, n_jobs=1)
213
+ reg.fit(aug_rows, aug_targets)
214
+ clf.fit(aug_rows, aug_labels)
215
+
216
+ hb_preds_full = reg.predict(rows)
217
+ residuals = np.abs(targets - hb_preds_full)
218
+ hb_scale = max(float(np.quantile(residuals, 0.75)), 0.8)
219
+ clf_probs_full = clf.predict_proba(rows)[:, 1]
220
+ reg_risk_full = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_preds_full])
221
+ blend_full = 0.55 * clf_probs_full + 0.45 * reg_risk_full
222
+ risk_scale = max(float(np.std(blend_full)) * 0.9, 0.08)
223
+ risk_scale = min(risk_scale, 0.22)
224
+
225
+ calibration = {
226
+ "hb_threshold": ANEMIA_HB_THRESHOLD,
227
+ "hb_scale": round(hb_scale, 4),
228
+ "hb_population_mean": round(float(np.mean(targets)), 4),
229
+ "hb_spread_factor": 2.0,
230
+ "regressor_tree_std_reference": 1.85,
231
+ "classifier_tree_std_reference": 0.40,
232
+ "classifier_weight": 0.55,
233
+ "blend_threshold": round(best_threshold, 4),
234
+ "risk_scale": round(risk_scale, 4),
235
+ "base_uncertainty": 0.08,
236
+ }
237
+
238
+ artifact = {
239
+ "version": "archive-fusion-v4-pipeline",
240
+ "feature_names": feat_names,
241
+ "regressor": reg,
242
+ "classifier": clf,
243
+ "calibration": calibration,
244
+ "training": {
245
+ "selected_mode": "pipeline_aligned_roi",
246
+ "subject_count": len(samples),
247
+ "record_count": len(samples),
248
+ "metrics": avg,
249
+ },
250
+ }
251
+
252
+ print("\n[4/4] Saving...")
253
+ joblib.dump(artifact, OUTPUT_PATH)
254
+ print(f" Saved -> {OUTPUT_PATH}")
255
+ print(f" Size: {OUTPUT_PATH.stat().st_size / 1024 / 1024:.1f} MB")
256
+
257
+ report = {
258
+ "dataset_name": "dataset anemia (pipeline-aligned)",
259
+ "record_count": len(samples),
260
+ "subject_count": len(samples),
261
+ "primary_model": "archive-fusion-v4-pipeline",
262
+ "selected_mode": "pipeline_aligned_roi",
263
+ "metrics": avg,
264
+ "calibration": {
265
+ "blend_threshold": calibration["blend_threshold"],
266
+ "risk_scale": calibration["risk_scale"],
267
+ "classifier_weight": calibration["classifier_weight"],
268
+ },
269
+ }
270
+ with open(REPORT_PATH, "w") as f:
271
+ json.dump(report, f, indent=2)
272
+
273
+ # Sanity check on training data
274
+ print("\nSanity check (training data):")
275
+ for label, cpi, rg, br in [("PALE", 0.28, 0.02, 0.22), ("NORMAL", 0.44, 0.08, 0.38)]:
276
+ from app.ml.archive_model import prepare_feature_map
277
+ from app.ml.features import FEATURE_NAMES as FN
278
+ feat_map = {n: 0.0 for n in FN}
279
+ feat_map.update({"cpi": cpi, "center_cpi": cpi-0.01, "mean_r": cpi*0.9,
280
+ "mean_g": cpi*0.9-rg, "mean_b": cpi*0.7,
281
+ "red_green_gap": rg, "center_red_green_gap": rg,
282
+ "brightness": br, "center_brightness": br,
283
+ "green_blue_ratio": 1.1 if cpi < 0.35 else 1.25,
284
+ "contrast": 0.12, "center_contrast": 0.12,
285
+ "blur_score": 100.0, "center_blur_score": 120.0,
286
+ "saturation": 0.3, "center_saturation": 0.3,
287
+ "hist_mid": 0.5, "hist_bright": 0.3,
288
+ "aspect_ratio": 1.0, "size_score": 1.0})
289
+ prepared = prepare_feature_map(feat_map, source_hint="roi_original")
290
+ row = np.array([[prepared.get(n, 0.0) for n in feat_names]], dtype=np.float32)
291
+ hb_p = float(reg.predict(row)[0])
292
+ cp = float(clf.predict_proba(row)[0, 1])
293
+ rr = sigmoid((ANEMIA_HB_THRESHOLD - hb_p) / hb_scale)
294
+ bs = 0.55 * cp + 0.45 * rr
295
+ risk = sigmoid((bs - best_threshold) / risk_scale)
296
+ print(f" {label}: Hb={hb_p:.1f}, risk={risk:.3f}")
297
+
298
+ print("\nDone.")
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()
backend/scripts/test_endpoint.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests, io, json
2
+ from PIL import Image
3
+
4
+ img = Image.new('RGB', (400, 300), color=(200, 160, 140))
5
+ buf = io.BytesIO()
6
+ img.save(buf, format='JPEG')
7
+ buf.seek(0)
8
+
9
+ symptoms = json.dumps({
10
+ "fatigue": True,
11
+ "pale_skin": True,
12
+ "dizziness": False,
13
+ "shortness_of_breath": False,
14
+ "heavy_menstrual_bleeding": None,
15
+ "poor_diet_low_iron": False
16
+ })
17
+
18
+ r = requests.post(
19
+ 'http://localhost:8000/api/analyze',
20
+ files={'image': ('test.jpg', buf, 'image/jpeg')},
21
+ data={'symptoms': symptoms},
22
+ timeout=30
23
+ )
24
+ print('Status:', r.status_code)
25
+ if r.status_code == 200:
26
+ d = r.json()
27
+ pred = d.get('prediction') or {}
28
+ triage = d.get('triage') or {}
29
+ print('Hb:', pred.get('predicted_hemoglobin'))
30
+ print('Risk:', pred.get('anemia_risk'))
31
+ print('Label:', pred.get('screening_label'))
32
+ print('Model:', pred.get('model_source'))
33
+ print('Triage band:', triage.get('band'))
34
+ print('Blocked:', d.get('blocked'))
35
+ else:
36
+ print('Error body:', r.text[:1000])
backend/scripts/test_model.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import joblib, sys
2
+ sys.path.insert(0, 'backend')
3
+ from app.ml.archive_model import predict_with_archive_model
4
+ from app.ml.features import FEATURE_NAMES
5
+
6
+ m = joblib.load('backend/models/archive_screening_model.joblib')
7
+
8
+ test_cases = [
9
+ ("PALE (anemic)", 0.28, 0.02, 0.22),
10
+ ("BORDERLINE", 0.35, 0.04, 0.30),
11
+ ("NORMAL", 0.44, 0.08, 0.38),
12
+ ("VERY HEALTHY", 0.48, 0.10, 0.42),
13
+ ]
14
+
15
+ for label, cpi, rg, br in test_cases:
16
+ feat_map = {n: 0.0 for n in FEATURE_NAMES}
17
+ feat_map['cpi'] = cpi
18
+ feat_map['center_cpi'] = cpi - 0.01
19
+ feat_map['mean_r'] = cpi * 0.9
20
+ feat_map['mean_g'] = cpi * 0.9 - rg
21
+ feat_map['mean_b'] = cpi * 0.7
22
+ feat_map['red_green_gap'] = rg
23
+ feat_map['center_red_green_gap'] = rg
24
+ feat_map['brightness'] = br
25
+ feat_map['green_blue_ratio'] = 1.1 if cpi < 0.35 else 1.25
26
+ feat_map['center_mean_r'] = feat_map['mean_r']
27
+ feat_map['center_mean_g'] = feat_map['mean_g']
28
+ feat_map['center_mean_b'] = feat_map['mean_b']
29
+ feat_map['contrast'] = 0.12
30
+ feat_map['center_contrast'] = 0.12
31
+ feat_map['center_brightness'] = br
32
+ feat_map['blur_score'] = 100.0
33
+ feat_map['center_blur_score'] = 120.0
34
+ feat_map['saturation'] = 0.3
35
+ feat_map['center_saturation'] = 0.3
36
+ feat_map['hist_mid'] = 0.5
37
+ feat_map['hist_bright'] = 0.3
38
+ feat_map['aspect_ratio'] = 1.0
39
+ feat_map['size_score'] = 1.0
40
+ result = predict_with_archive_model(m, feat_map, source_hint='roi_original')
41
+ hb = result['predicted_hemoglobin']
42
+ risk = result['anemia_risk']
43
+ unc = result['uncertainty']
44
+ decision = "ANEMIA LIKELY" if risk >= 0.65 else "unlikely"
45
+ print(f"{label}: Hb={hb:.1f}, risk={risk:.3f}, uncertainty={unc:.3f} -> {decision}")
backend/scripts/train_archive_model.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train the archive conjunctiva screening model.
3
+
4
+ Usage::
5
+
6
+ python scripts/train_archive_model.py [--dataset PATH] [--output-dir PATH] [--quiet]
7
+
8
+ The script trains the model, writes the artefact and a human-readable
9
+ training report, then exits with code 0 on success or 1 on failure.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ ROOT = Path(__file__).resolve().parents[2]
21
+ BACKEND_ROOT = ROOT / "backend"
22
+ sys.path.insert(0, str(BACKEND_ROOT))
23
+
24
+ from app.config import DEFAULT_ARCHIVE_MODEL_PATH, DEFAULT_TRAINING_REPORT_PATH # noqa: E402
25
+ from app.ml.archive_model import save_archive_model, train_archive_model # noqa: E402
26
+
27
+ DEFAULT_DATASET = ROOT / "archive" / "dataset anemia"
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # CLI
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def _build_parser() -> argparse.ArgumentParser:
35
+ p = argparse.ArgumentParser(
36
+ description="Train the AnemiaLens archive conjunctiva screening model.",
37
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
38
+ )
39
+ p.add_argument(
40
+ "--dataset",
41
+ type=Path,
42
+ default=DEFAULT_DATASET,
43
+ help="Root directory of the labelled anemia dataset.",
44
+ )
45
+ p.add_argument(
46
+ "--output-dir",
47
+ type=Path,
48
+ default=DEFAULT_ARCHIVE_MODEL_PATH.parent,
49
+ help="Directory where the model artefact and report are written.",
50
+ )
51
+ p.add_argument(
52
+ "--quiet",
53
+ action="store_true",
54
+ help="Suppress progress output (report still written to disk).",
55
+ )
56
+ return p
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Main
61
+ # ---------------------------------------------------------------------------
62
+
63
+ def main(argv: list[str] | None = None) -> int:
64
+ args = _build_parser().parse_args(argv)
65
+
66
+ if not args.dataset.exists():
67
+ print(
68
+ f"ERROR: Dataset directory not found: {args.dataset}\n"
69
+ " Download the anemia dataset and place it there, or pass --dataset PATH.",
70
+ file=sys.stderr,
71
+ )
72
+ return 1
73
+
74
+ if not args.quiet:
75
+ print(f"Dataset : {args.dataset}")
76
+ print(f"Output : {args.output_dir}")
77
+ print()
78
+
79
+ t0 = time.perf_counter()
80
+
81
+ try:
82
+ artifact, report = train_archive_model(args.dataset)
83
+ except Exception as exc:
84
+ print(f"ERROR: Training failed — {exc}", file=sys.stderr)
85
+ return 1
86
+
87
+ elapsed = time.perf_counter() - t0
88
+
89
+ # --- Write artefacts ---------------------------------------------------
90
+ args.output_dir.mkdir(parents=True, exist_ok=True)
91
+
92
+ model_path = args.output_dir / DEFAULT_ARCHIVE_MODEL_PATH.name
93
+ report_path = args.output_dir / DEFAULT_TRAINING_REPORT_PATH.name
94
+
95
+ save_archive_model(artifact, model_path)
96
+ report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
97
+
98
+ # --- Summary -----------------------------------------------------------
99
+ if not args.quiet:
100
+ metrics = report.get("metrics", {})
101
+ print(json.dumps(report, indent=2))
102
+ print()
103
+ print("=" * 56)
104
+ print(f" Model : {report.get('primary_model', '?')}")
105
+ print(f" Subjects : {report.get('subject_count', '?')}")
106
+ print(f" Records : {report.get('record_count', '?')}")
107
+ print(f" Accuracy : {metrics.get('accuracy', 0):.3f}")
108
+ print(f" F1 : {metrics.get('f1', 0):.3f}")
109
+ print(f" Val size : {metrics.get('validation_size', '?')}")
110
+ print(f" Elapsed : {elapsed:.1f}s")
111
+ print("=" * 56)
112
+ print(f" Saved model → {model_path}")
113
+ print(f" Saved report → {report_path}")
114
+
115
+ return 0
116
+
117
+
118
+ if __name__ == "__main__":
119
+ sys.exit(main())
backend/scripts/train_efficientnet.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import random
6
+ from collections import Counter
7
+ from copy import deepcopy
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import torch
14
+ from PIL import Image
15
+ from sklearn.metrics import accuracy_score, f1_score, mean_absolute_error, precision_score, recall_score, roc_auc_score
16
+ from sklearn.model_selection import GroupShuffleSplit
17
+ from torch import nn
18
+ from torch.optim import AdamW
19
+ from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler
20
+
21
+ from app.config import (
22
+ BACKEND_ROOT,
23
+ DEFAULT_EFFICIENTNET_MODEL_PATH,
24
+ DEFAULT_EFFICIENTNET_REPORT_PATH,
25
+ DEFAULT_TRAINING_REPORT_PATH,
26
+ )
27
+ from app.ml.archive_model import ANEMIA_HB_THRESHOLD, _first_path, _load_image_with_fallback, _parse_float, _parse_workbook
28
+ from app.ml.efficientnet_model import (
29
+ EFFICIENTNET_VERSION,
30
+ build_efficientnet_model,
31
+ build_train_transform,
32
+ build_val_transform,
33
+ )
34
+ from app.services.conjunctiva_roi import ConjunctivaRoiExtractor
35
+
36
+
37
+ DATA_ROOT = BACKEND_ROOT / "data"
38
+ ARCHIVE_ROOT = BACKEND_ROOT.parent / "archive" / "dataset anemia"
39
+ SEED = 42
40
+ BATCH_SIZE = 16
41
+ EPOCHS = 60
42
+ PATIENCE = 15
43
+ MAX_GRAD_NORM = 1.0
44
+ WARMUP_EPOCHS = 5
45
+ LABEL_SMOOTHING = 0.05
46
+ MIXUP_ALPHA = 0.3
47
+ # Hb spread loss: penalizes predictions that cluster near the mean
48
+ HB_SPREAD_WEIGHT = 0.15
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ImageRecord:
53
+ subject_id: str
54
+ label: int
55
+ hb: float
56
+ image_path: Path
57
+ source: str
58
+
59
+
60
+ class ConjunctivaDataset(Dataset):
61
+ def __init__(self, records: list[ImageRecord], transform: object) -> None:
62
+ self.records = records
63
+ self.transform = transform
64
+ self.roi_extractor = ConjunctivaRoiExtractor()
65
+
66
+ def __len__(self) -> int:
67
+ return len(self.records)
68
+
69
+ def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
70
+ record = self.records[index]
71
+ image, label, hb = self._prepare_item(record)
72
+ tensor = self.transform(image)
73
+ return tensor, torch.tensor([label], dtype=torch.float32), torch.tensor([hb], dtype=torch.float32)
74
+
75
+ def _prepare_item(self, record: ImageRecord) -> tuple[Image.Image, float, float]:
76
+ image = _load_image_with_fallback(record.image_path)
77
+ if record.source == "roi_original":
78
+ image = self.roi_extractor.extract(image).image
79
+ return image.convert("RGB"), float(record.label), float(record.hb)
80
+
81
+
82
+ class FocalLoss(nn.Module):
83
+ def __init__(self, alpha: float = 0.25, gamma: float = 2.0, pos_weight: torch.Tensor | None = None) -> None:
84
+ super().__init__()
85
+ self.alpha = alpha
86
+ self.gamma = gamma
87
+ self.bce = nn.BCEWithLogitsLoss(pos_weight=pos_weight, reduction="none")
88
+
89
+ def forward(self, inputs: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
90
+ bce_loss = self.bce(inputs, targets)
91
+ probabilities = torch.sigmoid(inputs)
92
+ p_t = probabilities * targets + (1 - probabilities) * (1 - targets)
93
+ loss = bce_loss * ((1 - p_t) ** self.gamma)
94
+ if self.alpha >= 0:
95
+ alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets)
96
+ loss = alpha_t * loss
97
+ return loss.mean()
98
+
99
+
100
+ def main() -> None:
101
+ _set_seed(SEED)
102
+ dataset_root = DATA_ROOT if DATA_ROOT.exists() else ARCHIVE_ROOT
103
+ if not dataset_root.exists():
104
+ raise RuntimeError(f"No dataset directory found at {DATA_ROOT} or {ARCHIVE_ROOT}.")
105
+
106
+ records = _build_records(dataset_root)
107
+ if not records:
108
+ raise RuntimeError(f"No training records found in {dataset_root}.")
109
+
110
+ train_records, val_records = _balanced_group_split(records, test_size=0.2, n_splits=32)
111
+ train_dataset = ConjunctivaDataset(train_records, build_train_transform())
112
+ val_dataset = ConjunctivaDataset(val_records, build_val_transform())
113
+ hb_mean, hb_std = _hb_normalization_stats(train_records)
114
+ train_sampler = _build_weighted_sampler(train_records)
115
+
116
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
117
+ model = build_efficientnet_model(pretrained=True).to(device)
118
+ optimizer = AdamW(
119
+ [
120
+ {"params": list(model.classifier.parameters()), "lr": 1.5e-4}, # Slightly lower for stability
121
+ {"params": [param for param in model.features.parameters() if param.requires_grad], "lr": 5e-6},
122
+ ],
123
+ weight_decay=4e-4, # Higher weight decay for better regularization
124
+ )
125
+
126
+ # Warmup then cosine annealing
127
+ def warmup_cosine_lr(epoch: int) -> float:
128
+ if epoch < WARMUP_EPOCHS:
129
+ return float(epoch + 1) / WARMUP_EPOCHS
130
+ progress = (epoch - WARMUP_EPOCHS) / max(EPOCHS - WARMUP_EPOCHS, 1)
131
+ return 0.5 * (1.0 + math.cos(math.pi * progress))
132
+
133
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=warmup_cosine_lr)
134
+
135
+ # Use Focal Loss with positive weights
136
+ pos_weight = torch.tensor([_positive_class_weight(train_records)], device=device)
137
+ cls_loss_fn = FocalLoss(alpha=0.25, gamma=2.0, pos_weight=pos_weight)
138
+ hb_loss_fn = nn.SmoothL1Loss(beta=0.5)
139
+
140
+ train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, sampler=train_sampler, num_workers=0)
141
+ val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)
142
+
143
+ best_state: dict[str, torch.Tensor] | None = None
144
+ best_metrics: dict[str, float] | None = None
145
+ best_threshold = 0.5
146
+ best_score = -1.0
147
+ epochs_without_improvement = 0
148
+ history: list[dict[str, float]] = []
149
+
150
+ for epoch in range(1, EPOCHS + 1):
151
+ model.train()
152
+ train_loss_total = 0.0
153
+
154
+ for images, labels, hbs in train_loader:
155
+ images = images.to(device)
156
+ labels = labels.to(device)
157
+ hbs = hbs.to(device)
158
+ normalized_hbs = (hbs - hb_mean) / hb_std
159
+
160
+ # MixUp augmentation
161
+ if MIXUP_ALPHA > 0 and np.random.random() < 0.5:
162
+ lam = float(np.random.beta(MIXUP_ALPHA, MIXUP_ALPHA))
163
+ idx = torch.randperm(images.size(0), device=device)
164
+ images = lam * images + (1.0 - lam) * images[idx]
165
+ labels_a, labels_b = labels, labels[idx]
166
+ hbs_a, hbs_b = normalized_hbs, normalized_hbs[idx]
167
+
168
+ optimizer.zero_grad(set_to_none=True)
169
+ output = model(images)
170
+ # Label smoothing applied to both MixUp targets
171
+ smooth_a = labels_a * (1.0 - LABEL_SMOOTHING) + 0.5 * LABEL_SMOOTHING
172
+ smooth_b = labels_b * (1.0 - LABEL_SMOOTHING) + 0.5 * LABEL_SMOOTHING
173
+ cls_loss = lam * cls_loss_fn(output[:, 0:1], smooth_a) + (1.0 - lam) * cls_loss_fn(output[:, 0:1], smooth_b)
174
+ hb_loss = lam * hb_loss_fn(output[:, 1:2], hbs_a) + (1.0 - lam) * hb_loss_fn(output[:, 1:2], hbs_b)
175
+ else:
176
+ optimizer.zero_grad(set_to_none=True)
177
+ output = model(images)
178
+ smooth_labels = labels * (1.0 - LABEL_SMOOTHING) + 0.5 * LABEL_SMOOTHING
179
+ cls_loss = cls_loss_fn(output[:, 0:1], smooth_labels)
180
+ hb_loss = hb_loss_fn(output[:, 1:2], normalized_hbs)
181
+
182
+ # Hb spread loss: penalize predictions clustering near zero (normalized mean)
183
+ # Encourages the model to predict a wider range of Hb values
184
+ hb_pred_norm = output[:, 1:2]
185
+ spread_loss = torch.clamp(0.5 - hb_pred_norm.std(), min=0.0)
186
+
187
+ total_loss = (0.60 * cls_loss) + (0.30 * hb_loss) + (HB_SPREAD_WEIGHT * spread_loss)
188
+ total_loss.backward()
189
+ nn.utils.clip_grad_norm_(model.parameters(), MAX_GRAD_NORM)
190
+ optimizer.step()
191
+ train_loss_total += float(total_loss.item()) * images.size(0)
192
+
193
+ scheduler.step()
194
+ val_metrics = _evaluate_model(model, val_loader, device, hb_mean=hb_mean, hb_std=hb_std)
195
+ history.append(
196
+ {
197
+ "epoch": float(epoch),
198
+ "train_loss": round(train_loss_total / max(len(train_dataset), 1), 4),
199
+ "val_f1": val_metrics["f1"],
200
+ "val_auc": val_metrics["auc"],
201
+ "val_hb_mae": val_metrics["hb_mae"],
202
+ }
203
+ )
204
+ print(
205
+ f"epoch={epoch:02d} train_loss={history[-1]['train_loss']:.4f} "
206
+ f"val_f1={val_metrics['f1']:.4f} val_auc={val_metrics['auc']:.4f} "
207
+ f"val_hb_mae={val_metrics['hb_mae']:.4f}"
208
+ )
209
+
210
+ # Use composite score: AUC weighted more heavily than F1 (more stable early on)
211
+ composite_score = val_metrics["auc"] * 0.55 + val_metrics["f1"] * 0.35 + (1.0 - min(val_metrics["hb_mae"] / 4.0, 1.0)) * 0.10
212
+ if composite_score > best_score:
213
+ best_score = composite_score
214
+ best_state = deepcopy(model.state_dict())
215
+ best_metrics = val_metrics
216
+ best_threshold = val_metrics["decision_threshold"]
217
+ epochs_without_improvement = 0
218
+ else:
219
+ epochs_without_improvement += 1
220
+
221
+ if epochs_without_improvement >= PATIENCE:
222
+ print(f"Early stopping after {epoch} epochs.")
223
+ break
224
+
225
+ if best_state is None or best_metrics is None:
226
+ raise RuntimeError("EfficientNet training did not produce a valid checkpoint.")
227
+
228
+ checkpoint = {
229
+ "version": EFFICIENTNET_VERSION,
230
+ "created_at": datetime.now(timezone.utc).isoformat(),
231
+ "state_dict": best_state,
232
+ "decision_threshold": best_threshold,
233
+ "hb_mean": hb_mean,
234
+ "hb_std": hb_std,
235
+ "hb_spread_factor": _compute_hb_spread_factor(val_records, hb_mean, hb_std),
236
+ "val_metrics": best_metrics,
237
+ }
238
+ DEFAULT_EFFICIENTNET_MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
239
+ torch.save(checkpoint, DEFAULT_EFFICIENTNET_MODEL_PATH)
240
+
241
+ report = {
242
+ "dataset_name": str(dataset_root.name),
243
+ "record_count": len(records),
244
+ "subject_count": len({record.subject_id for record in records}),
245
+ "primary_model": EFFICIENTNET_VERSION,
246
+ "selected_mode": "efficientnet_hybrid_dual",
247
+ "source_counts": _source_counts(records),
248
+ "metrics": {
249
+ "accuracy": round(best_metrics["accuracy"], 4),
250
+ "precision": round(best_metrics["precision"], 4),
251
+ "recall": round(best_metrics["recall"], 4),
252
+ "f1": round(best_metrics["f1"], 4),
253
+ "auc": round(best_metrics["auc"], 4),
254
+ "mae_hb": round(best_metrics["hb_mae"], 4),
255
+ "validation_size": len(val_records),
256
+ "split_strategy": "group-shuffle-balance-select",
257
+ "sample_count": len(records),
258
+ "subject_count": len({record.subject_id for record in records}),
259
+ "decision_threshold": round(best_threshold, 4),
260
+ },
261
+ "training": {
262
+ "epochs_requested": EPOCHS,
263
+ "history": history,
264
+ "batch_size": BATCH_SIZE,
265
+ "patience": PATIENCE,
266
+ "device": str(device),
267
+ "hb_target_mean": round(hb_mean, 4),
268
+ "hb_target_std": round(hb_std, 4),
269
+ "class_positive_weight": round(_positive_class_weight(train_records), 4),
270
+ "sampler": "weighted-random-balanced",
271
+ },
272
+ }
273
+ DEFAULT_EFFICIENTNET_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
274
+ DEFAULT_TRAINING_REPORT_PATH.write_text(json.dumps(report, indent=2), encoding="utf-8")
275
+
276
+ print("\nBest validation metrics")
277
+ for key in ("accuracy", "precision", "recall", "f1", "auc", "hb_mae", "decision_threshold"):
278
+ print(f"{key}: {best_metrics[key]:.4f}")
279
+
280
+
281
+ def _build_records(dataset_root: Path) -> list[ImageRecord]:
282
+ records: list[ImageRecord] = []
283
+ for country in ("India", "Italy"):
284
+ workbook_path = dataset_root / country / f"{country}.xlsx"
285
+ if not workbook_path.exists():
286
+ continue
287
+ metadata = _parse_workbook(workbook_path)
288
+ for subject_number, row in metadata.items():
289
+ hb = _parse_float(row.get("Hgb"))
290
+ if hb is None:
291
+ continue
292
+
293
+ subject_dir = dataset_root / country / subject_number
294
+ if not subject_dir.exists():
295
+ continue
296
+
297
+ subject_id = f"{country}-{subject_number}"
298
+ label = int(hb < ANEMIA_HB_THRESHOLD)
299
+ original_path = _first_path(subject_dir.glob("*.jpg"))
300
+ palpebral_path = _first_path(
301
+ path
302
+ for path in subject_dir.glob("*_palpebral.png")
303
+ if "forniceal_palpebral" not in path.name.lower()
304
+ )
305
+
306
+ if original_path is not None:
307
+ records.append(
308
+ ImageRecord(
309
+ subject_id=subject_id,
310
+ label=label,
311
+ hb=float(hb),
312
+ image_path=original_path,
313
+ source="roi_original",
314
+ )
315
+ )
316
+ if palpebral_path is not None:
317
+ records.append(
318
+ ImageRecord(
319
+ subject_id=subject_id,
320
+ label=label,
321
+ hb=float(hb),
322
+ image_path=palpebral_path,
323
+ source="palpebral",
324
+ )
325
+ )
326
+ return records
327
+
328
+
329
+ def _balanced_group_split(
330
+ records: list[ImageRecord],
331
+ *,
332
+ test_size: float,
333
+ n_splits: int,
334
+ ) -> tuple[list[ImageRecord], list[ImageRecord]]:
335
+ labels = np.asarray([record.label for record in records], dtype=np.int32)
336
+ groups = np.asarray([record.subject_id for record in records], dtype=object)
337
+ target_ratio = float(labels.mean())
338
+ splitter = GroupShuffleSplit(n_splits=n_splits, test_size=test_size, random_state=SEED)
339
+
340
+ best: tuple[np.ndarray, np.ndarray] | None = None
341
+ best_score = float("inf")
342
+ for train_index, val_index in splitter.split(np.zeros(len(records)), labels, groups):
343
+ train_labels = labels[train_index]
344
+ val_labels = labels[val_index]
345
+ if len(np.unique(train_labels)) < 2 or len(np.unique(val_labels)) < 2:
346
+ continue
347
+ score = abs(float(train_labels.mean()) - target_ratio) + abs(float(val_labels.mean()) - target_ratio)
348
+ if score < best_score:
349
+ best_score = score
350
+ best = (train_index, val_index)
351
+
352
+ if best is None:
353
+ raise RuntimeError("Unable to create a grouped train/validation split.")
354
+
355
+ train_index, val_index = best
356
+ return [records[i] for i in train_index], [records[i] for i in val_index]
357
+
358
+
359
+ def _evaluate_model(
360
+ model: nn.Module,
361
+ loader: DataLoader,
362
+ device: torch.device,
363
+ *,
364
+ hb_mean: float,
365
+ hb_std: float,
366
+ ) -> dict[str, float]:
367
+ model.eval()
368
+ probabilities: list[float] = []
369
+ labels: list[int] = []
370
+ hb_predictions: list[float] = []
371
+ hb_targets: list[float] = []
372
+
373
+ with torch.no_grad():
374
+ for images, batch_labels, batch_hbs in loader:
375
+ images = images.to(device)
376
+ output = model(images)
377
+ probabilities.extend(torch.sigmoid(output[:, 0]).cpu().tolist())
378
+ hb_predictions.extend(((output[:, 1].cpu() * hb_std) + hb_mean).tolist())
379
+ labels.extend(batch_labels.squeeze(1).cpu().int().tolist())
380
+ hb_targets.extend(batch_hbs.squeeze(1).cpu().tolist())
381
+
382
+ threshold = _best_threshold(np.asarray(labels), np.asarray(probabilities))
383
+ predicted_labels = [1 if probability >= threshold else 0 for probability in probabilities]
384
+ auc = roc_auc_score(labels, probabilities) if len(set(labels)) > 1 else 0.5
385
+
386
+ return {
387
+ "accuracy": float(accuracy_score(labels, predicted_labels)),
388
+ "precision": float(precision_score(labels, predicted_labels, zero_division=0)),
389
+ "recall": float(recall_score(labels, predicted_labels, zero_division=0)),
390
+ "f1": float(f1_score(labels, predicted_labels, zero_division=0)),
391
+ "auc": float(auc),
392
+ "hb_mae": float(mean_absolute_error(hb_targets, hb_predictions)),
393
+ "decision_threshold": float(threshold),
394
+ }
395
+
396
+
397
+ def _best_threshold(labels: np.ndarray, probabilities: np.ndarray) -> float:
398
+ best_threshold = 0.5
399
+ best_score = -1.0
400
+ for threshold in np.linspace(0.25, 0.75, 51):
401
+ predictions = (probabilities >= threshold).astype(np.int32)
402
+ score = f1_score(labels, predictions, zero_division=0)
403
+ if score > best_score:
404
+ best_score = float(score)
405
+ best_threshold = float(threshold)
406
+ return best_threshold
407
+
408
+
409
+ def _source_counts(records: list[ImageRecord]) -> dict[str, int]:
410
+ counts: dict[str, int] = {}
411
+ for record in records:
412
+ counts[record.source] = counts.get(record.source, 0) + 1
413
+ return counts
414
+
415
+
416
+ def _positive_class_weight(records: list[ImageRecord]) -> float:
417
+ counts = Counter(record.label for record in records)
418
+ positive = max(counts.get(1, 0), 1)
419
+ negative = max(counts.get(0, 0), 1)
420
+ return float(negative / positive)
421
+
422
+
423
+ def _build_weighted_sampler(records: list[ImageRecord]) -> WeightedRandomSampler:
424
+ counts = Counter(record.label for record in records)
425
+ total = sum(counts.values())
426
+ weights = [
427
+ float(total / max(counts[record.label], 1))
428
+ for record in records
429
+ ]
430
+ return WeightedRandomSampler(
431
+ torch.as_tensor(weights, dtype=torch.double),
432
+ num_samples=len(weights),
433
+ replacement=True,
434
+ )
435
+
436
+
437
+ def _hb_normalization_stats(records: list[ImageRecord]) -> tuple[float, float]:
438
+ values = np.asarray([record.hb for record in records], dtype=np.float32)
439
+ mean = float(values.mean())
440
+ std = float(values.std())
441
+ return mean, max(std, 1e-3)
442
+
443
+
444
+ def _set_seed(seed: int) -> None:
445
+ random.seed(seed)
446
+ np.random.seed(seed)
447
+ torch.manual_seed(seed)
448
+ if torch.cuda.is_available():
449
+ torch.cuda.manual_seed_all(seed)
450
+
451
+
452
+ def _compute_hb_spread_factor(records: list[ImageRecord], hb_mean: float, hb_std: float) -> float:
453
+ """
454
+ Estimate the spread amplification factor needed to correct regression-to-mean.
455
+ Uses the ratio of true Hb std to the expected model output std (hb_std * 0.75 heuristic).
456
+ """
457
+ true_std = float(np.std([r.hb for r in records]))
458
+ # Models typically predict ~75% of true std due to averaging
459
+ predicted_std_estimate = max(hb_std * 0.75, 0.5)
460
+ factor = float(np.clip(true_std / predicted_std_estimate, 1.0, 2.0))
461
+ return round(factor, 3)
462
+
463
+
464
+ if __name__ == "__main__":
465
+ main()
backend/scripts/train_ensemble.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train all models in the AnemiaLens ensemble pipeline.
3
+
4
+ Currently delegates to train_archive_model. As the ensemble grows
5
+ (deep-stack, legacy CNN, etc.) this script will orchestrate each
6
+ training job in dependency order and produce a combined manifest.
7
+
8
+ Usage::
9
+
10
+ python scripts/train_ensemble.py [--dataset PATH] [--output-dir PATH] [--quiet]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ # Ensure the scripts directory is on the path so we can import sibling scripts.
19
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
20
+
21
+ from train_archive_model import main as train_archive
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ """
26
+ Orchestrate all training jobs.
27
+
28
+ Returns the exit code of the last failing job, or 0 if all succeeded.
29
+ """
30
+ exit_code = 0
31
+
32
+ print("=== Step 1/1: archive screening model ===")
33
+ rc = train_archive(argv)
34
+ if rc != 0:
35
+ print(f" FAILED (exit {rc})", file=sys.stderr)
36
+ exit_code = rc
37
+ else:
38
+ print(" Done.")
39
+
40
+ # Future steps (uncomment when models are ready):
41
+ # print("=== Step 2/N: deep-stack model ===")
42
+ # rc = train_deep_stack(argv)
43
+ # ...
44
+
45
+ return exit_code
46
+
47
+
48
+ if __name__ == "__main__":
49
+ sys.exit(main())
backend/scripts/train_stacked.py ADDED
@@ -0,0 +1,617 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_stacked.py — AnemiaLens stacked-ensemble-v4 training script.
3
+
4
+ Architecture
5
+ ------------
6
+ Level-0 base learners (out-of-fold predictions via cross_val_predict):
7
+ • XGBoost regressor → OOF Hb predictions
8
+ • XGBoost classifier → OOF anemia probabilities
9
+ • ExtraTrees regressor → OOF Hb predictions
10
+ • ExtraTrees classifier → OOF anemia probabilities
11
+
12
+ Level-1 meta-learners:
13
+ • Ridge regression → final Hb estimate
14
+ • Logistic Regression → final anemia risk probability
15
+
16
+ Data augmentation (training folds only):
17
+ • Gaussian noise on color features (sigma=0.01)
18
+ • CPI jitter ±0.02
19
+ • 3× oversampling of anemic class (label=1)
20
+
21
+ Run from workspace root:
22
+ python backend/scripts/train_stacked.py
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import sys
27
+ import json
28
+ import math
29
+ from pathlib import Path
30
+
31
+ sys.path.insert(0, str(Path(__file__).parents[1]))
32
+
33
+ import numpy as np
34
+ import joblib
35
+ from sklearn.ensemble import ExtraTreesClassifier, ExtraTreesRegressor
36
+ from sklearn.linear_model import LogisticRegression, Ridge
37
+ from sklearn.metrics import (
38
+ accuracy_score, f1_score, mean_absolute_error,
39
+ precision_score, recall_score, roc_auc_score,
40
+ )
41
+ from sklearn.model_selection import GroupShuffleSplit, RandomizedSearchCV
42
+ from sklearn.model_selection import cross_val_predict
43
+
44
+ try:
45
+ from xgboost import XGBClassifier, XGBRegressor
46
+ _HAS_XGB = True
47
+ except ImportError:
48
+ _HAS_XGB = False
49
+ print("WARNING: xgboost not installed — falling back to ExtraTrees-only stack.")
50
+ print(" Install with: pip install xgboost")
51
+
52
+ from app.ml.archive_model import (
53
+ ANEMIA_HB_THRESHOLD,
54
+ _build_subject_catalog,
55
+ _samples_for_mode,
56
+ _rows_from_samples,
57
+ clamp,
58
+ sigmoid,
59
+ )
60
+ from app.ml.features import FEATURE_NAMES, COLOR_FEATURES
61
+ from app.ml.stacked_model import StackedRegressor, StackedClassifier
62
+
63
+ DATASET_ROOT = Path(__file__).parents[2] / "archive" / "dataset anemia"
64
+ OUTPUT_PATH = Path(__file__).parents[1] / "models" / "archive_screening_model.joblib"
65
+ OUTPUT_PATH_V4 = Path(__file__).parents[1] / "models" / "archive_screening_model_v4.joblib"
66
+ REPORT_PATH = Path(__file__).parents[1] / "models" / "training_report.json"
67
+
68
+ # Feature names for the v4 artifact (includes source flags)
69
+ V4_FEATURE_NAMES = FEATURE_NAMES + [
70
+ "source_roi_original",
71
+ "source_segmented",
72
+ "source_forniceal_palpebral",
73
+ ]
74
+
75
+ # Indices of color features used for augmentation
76
+ _COLOR_IDX = [V4_FEATURE_NAMES.index(n) for n in COLOR_FEATURES if n in V4_FEATURE_NAMES]
77
+ # Index of CPI feature for jitter
78
+ _CPI_IDX = V4_FEATURE_NAMES.index("cpi")
79
+
80
+ N_CV_SPLITS = 5
81
+ RANDOM_STATE = 42
82
+
83
+
84
+ # ─────────────────────────────────────────────────────────────────────────────
85
+ # Augmentation
86
+ # ─────────────────────────────────────────────────────────────────────────────
87
+
88
+ def augment_training_data(
89
+ rows: np.ndarray,
90
+ targets: np.ndarray,
91
+ labels: np.ndarray,
92
+ groups: np.ndarray,
93
+ rng: np.random.Generator,
94
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
95
+ """
96
+ Augment training data:
97
+ 1. Add Gaussian noise (sigma=0.01) to color features for ALL samples.
98
+ 2. Oversample anemic class (label=1) 3× with CPI jitter ±0.02.
99
+ Returns augmented arrays (originals + augmented copies).
100
+ """
101
+ n = len(rows)
102
+
103
+ # --- noise augmentation for all samples ---
104
+ noisy = rows.copy()
105
+ noise = rng.normal(0, 0.01, size=(n, len(_COLOR_IDX)))
106
+ noisy[:, _COLOR_IDX] += noise
107
+ noisy = np.clip(noisy, 0.0, 1.0)
108
+
109
+ aug_rows = [rows, noisy]
110
+ aug_targets = [targets, targets]
111
+ aug_labels = [labels, labels]
112
+ aug_groups = [groups, groups]
113
+
114
+ # --- 3× oversample anemic samples with CPI jitter ---
115
+ anemic_idx = np.where(labels == 1)[0]
116
+ for _ in range(3):
117
+ copies = rows[anemic_idx].copy()
118
+ jitter = rng.uniform(-0.02, 0.02, size=len(anemic_idx))
119
+ copies[:, _CPI_IDX] = np.clip(copies[:, _CPI_IDX] + jitter, 0.0, 1.0)
120
+ # Also add small noise to other color features
121
+ color_noise = rng.normal(0, 0.01, size=(len(anemic_idx), len(_COLOR_IDX)))
122
+ copies[:, _COLOR_IDX] = np.clip(copies[:, _COLOR_IDX] + color_noise, 0.0, 1.0)
123
+ aug_rows.append(copies)
124
+ aug_targets.append(targets[anemic_idx])
125
+ aug_labels.append(labels[anemic_idx])
126
+ aug_groups.append(groups[anemic_idx])
127
+
128
+ return (
129
+ np.vstack(aug_rows),
130
+ np.concatenate(aug_targets),
131
+ np.concatenate(aug_labels),
132
+ np.concatenate(aug_groups),
133
+ )
134
+
135
+
136
+ # ─────────────────────────────────────────────────────────────────────────────
137
+ # Base learner builders
138
+ # ─────────────────────────────────────────────────────────────────────────────
139
+
140
+ def _et_regressor(rs: int = RANDOM_STATE) -> ExtraTreesRegressor:
141
+ return ExtraTreesRegressor(
142
+ n_estimators=300, min_samples_leaf=2, max_features=0.7,
143
+ bootstrap=True, random_state=rs, n_jobs=1,
144
+ )
145
+
146
+
147
+ def _et_classifier(rs: int = RANDOM_STATE) -> ExtraTreesClassifier:
148
+ return ExtraTreesClassifier(
149
+ n_estimators=300, min_samples_leaf=2, max_features=0.7,
150
+ bootstrap=True, class_weight="balanced_subsample",
151
+ random_state=rs, n_jobs=1,
152
+ )
153
+
154
+
155
+ def _xgb_regressor(rs: int = RANDOM_STATE) -> "XGBRegressor":
156
+ return XGBRegressor(
157
+ n_estimators=300, max_depth=4, learning_rate=0.05,
158
+ subsample=0.8, colsample_bytree=0.8,
159
+ random_state=rs, n_jobs=1, verbosity=0,
160
+ )
161
+
162
+
163
+ def _xgb_classifier(rs: int = RANDOM_STATE) -> "XGBClassifier":
164
+ return XGBClassifier(
165
+ n_estimators=300, max_depth=4, learning_rate=0.05,
166
+ subsample=0.8, colsample_bytree=0.8,
167
+ use_label_encoder=False, eval_metric="logloss",
168
+ random_state=rs, n_jobs=1, verbosity=0,
169
+ )
170
+
171
+
172
+ # ─────────────────────────────────────────────────────────────────────────────
173
+ # Hyperparameter tuning
174
+ # ─────────────────────────────────────────────────────────────────────────────
175
+
176
+ def tune_et_regressor(rows: np.ndarray, targets: np.ndarray) -> ExtraTreesRegressor:
177
+ param_dist = {
178
+ "n_estimators": [100, 200, 300, 400],
179
+ "min_samples_leaf": [1, 2, 3, 4],
180
+ "max_features": [0.5, 0.6, 0.7, 0.8, "sqrt"],
181
+ }
182
+ base = ExtraTreesRegressor(bootstrap=True, random_state=RANDOM_STATE, n_jobs=1)
183
+ search = RandomizedSearchCV(
184
+ base, param_dist, n_iter=20, cv=3, scoring="neg_mean_absolute_error",
185
+ random_state=RANDOM_STATE, n_jobs=1, refit=True,
186
+ )
187
+ search.fit(rows, targets)
188
+ print(f" ET regressor best params: {search.best_params_}", flush=True)
189
+ return search.best_estimator_
190
+
191
+
192
+ def tune_et_classifier(rows: np.ndarray, labels: np.ndarray) -> ExtraTreesClassifier:
193
+ param_dist = {
194
+ "n_estimators": [100, 200, 300, 400],
195
+ "min_samples_leaf": [1, 2, 3, 4],
196
+ "max_features": [0.5, 0.6, 0.7, 0.8, "sqrt"],
197
+ }
198
+ base = ExtraTreesClassifier(
199
+ bootstrap=True, class_weight="balanced_subsample",
200
+ random_state=RANDOM_STATE, n_jobs=1,
201
+ )
202
+ search = RandomizedSearchCV(
203
+ base, param_dist, n_iter=20, cv=3, scoring="f1",
204
+ random_state=RANDOM_STATE, n_jobs=1, refit=True,
205
+ )
206
+ search.fit(rows, labels)
207
+ print(f" ET classifier best params: {search.best_params_}", flush=True)
208
+ return search.best_estimator_
209
+
210
+
211
+ def tune_xgb_regressor(rows: np.ndarray, targets: np.ndarray) -> "XGBRegressor":
212
+ param_dist = {
213
+ "n_estimators": [100, 200, 300, 400, 500],
214
+ "max_depth": [3, 4, 5, 6],
215
+ "learning_rate": [0.01, 0.03, 0.05, 0.1, 0.15],
216
+ "subsample": [0.6, 0.7, 0.8, 0.9, 1.0],
217
+ "colsample_bytree": [0.6, 0.7, 0.8, 0.9, 1.0],
218
+ }
219
+ base = XGBRegressor(random_state=RANDOM_STATE, n_jobs=1, verbosity=0)
220
+ search = RandomizedSearchCV(
221
+ base, param_dist, n_iter=20, cv=3, scoring="neg_mean_absolute_error",
222
+ random_state=RANDOM_STATE, n_jobs=1, refit=True,
223
+ )
224
+ search.fit(rows, targets)
225
+ print(f" XGB regressor best params: {search.best_params_}", flush=True)
226
+ return search.best_estimator_
227
+
228
+
229
+ def tune_xgb_classifier(rows: np.ndarray, labels: np.ndarray) -> "XGBClassifier":
230
+ param_dist = {
231
+ "n_estimators": [100, 200, 300, 400, 500],
232
+ "max_depth": [3, 4, 5, 6],
233
+ "learning_rate": [0.01, 0.03, 0.05, 0.1, 0.15],
234
+ "subsample": [0.6, 0.7, 0.8, 0.9, 1.0],
235
+ "colsample_bytree": [0.6, 0.7, 0.8, 0.9, 1.0],
236
+ }
237
+ base = XGBClassifier(
238
+ use_label_encoder=False, eval_metric="logloss",
239
+ random_state=RANDOM_STATE, n_jobs=1, verbosity=0,
240
+ )
241
+ search = RandomizedSearchCV(
242
+ base, param_dist, n_iter=20, cv=3, scoring="f1",
243
+ random_state=RANDOM_STATE, n_jobs=1, refit=True,
244
+ )
245
+ search.fit(rows, labels)
246
+ print(f" XGB classifier best params: {search.best_params_}", flush=True)
247
+ return search.best_estimator_
248
+
249
+
250
+ # ─────────────────────────────────────────────────────────────────────────────
251
+ # Stacking helpers
252
+ # ─────────────────────────────────────────────────────────────────────────────
253
+
254
+ def _group_kfold_indices(
255
+ groups: np.ndarray, n_splits: int, random_state: int
256
+ ) -> list[tuple[np.ndarray, np.ndarray]]:
257
+ """GroupShuffleSplit folds for OOF stacking."""
258
+ splitter = GroupShuffleSplit(n_splits=n_splits, test_size=0.2, random_state=random_state)
259
+ return list(splitter.split(np.zeros(len(groups)), groups=groups))
260
+
261
+
262
+ def build_oof_meta_features(
263
+ rows: np.ndarray,
264
+ targets: np.ndarray,
265
+ labels: np.ndarray,
266
+ groups: np.ndarray,
267
+ et_reg: ExtraTreesRegressor,
268
+ et_clf: ExtraTreesClassifier,
269
+ xgb_reg: object | None,
270
+ xgb_clf: object | None,
271
+ n_splits: int = N_CV_SPLITS,
272
+ ) -> np.ndarray:
273
+ """
274
+ Build out-of-fold meta-features using group-aware splits.
275
+ Always returns 4 columns: [et_hb, xgb_hb, et_prob, xgb_prob].
276
+ If XGBoost unavailable, xgb columns are zeros.
277
+ """
278
+ n = len(rows)
279
+ oof = np.zeros((n, 4), dtype=np.float32)
280
+ rng = np.random.default_rng(RANDOM_STATE)
281
+
282
+ folds = _group_kfold_indices(groups, n_splits, RANDOM_STATE)
283
+
284
+ for fold_i, (train_idx, val_idx) in enumerate(folds):
285
+ print(f" OOF fold {fold_i + 1}/{n_splits}...", flush=True)
286
+
287
+ tr_rows, tr_targets, tr_labels, tr_groups = augment_training_data(
288
+ rows[train_idx], targets[train_idx], labels[train_idx], groups[train_idx], rng
289
+ )
290
+ val_rows = rows[val_idx]
291
+
292
+ import copy
293
+ fold_et_reg = copy.deepcopy(et_reg)
294
+ fold_et_clf = copy.deepcopy(et_clf)
295
+ fold_et_reg.fit(tr_rows, tr_targets)
296
+ fold_et_clf.fit(tr_rows, tr_labels)
297
+
298
+ oof[val_idx, 0] = fold_et_reg.predict(val_rows)
299
+ oof[val_idx, 2] = fold_et_clf.predict_proba(val_rows)[:, 1]
300
+
301
+ if xgb_reg is not None and xgb_clf is not None:
302
+ fold_xgb_reg = copy.deepcopy(xgb_reg)
303
+ fold_xgb_clf = copy.deepcopy(xgb_clf)
304
+ fold_xgb_reg.fit(tr_rows, tr_targets)
305
+ fold_xgb_clf.fit(tr_rows, tr_labels)
306
+ oof[val_idx, 1] = fold_xgb_reg.predict(val_rows)
307
+ oof[val_idx, 3] = fold_xgb_clf.predict_proba(val_rows)[:, 1]
308
+ else:
309
+ oof[val_idx, 1] = oof[val_idx, 0] # mirror ET if no XGB
310
+ oof[val_idx, 3] = oof[val_idx, 2]
311
+
312
+ return oof
313
+
314
+
315
+ def find_best_threshold(labels: np.ndarray, scores: np.ndarray) -> float:
316
+ """Threshold maximising recall-weighted F1 (medical screening priority)."""
317
+ best_score, best_thresh = -1.0, 0.5
318
+ for t in np.linspace(0.20, 0.75, 56):
319
+ preds = (scores >= t).astype(int)
320
+ if preds.sum() == 0:
321
+ continue
322
+ f1 = f1_score(labels, preds, zero_division=0)
323
+ rec = recall_score(labels, preds, zero_division=0)
324
+ score = f1 * 0.5 + rec * 0.5
325
+ if score > best_score:
326
+ best_score = score
327
+ best_thresh = float(t)
328
+ return best_thresh
329
+
330
+
331
+ # ─────────────────────────────────────────────────────────────────────────────
332
+ # CV evaluation of the full stacked pipeline
333
+ # ─────────────────────────────────────────────────────────────────────────────
334
+
335
+ def evaluate_stacked(
336
+ rows: np.ndarray,
337
+ targets: np.ndarray,
338
+ labels: np.ndarray,
339
+ groups: np.ndarray,
340
+ et_reg: ExtraTreesRegressor,
341
+ et_clf: ExtraTreesClassifier,
342
+ xgb_reg: object | None,
343
+ xgb_clf: object | None,
344
+ n_splits: int = N_CV_SPLITS,
345
+ ) -> dict[str, float]:
346
+ """
347
+ Outer CV loop: for each fold, build OOF meta-features on the train portion,
348
+ fit meta-learners, evaluate on the held-out test fold.
349
+ """
350
+ import copy
351
+ splitter = GroupShuffleSplit(n_splits=n_splits, test_size=0.2, random_state=RANDOM_STATE + 1)
352
+ all_metrics: list[dict[str, float]] = []
353
+ rng = np.random.default_rng(RANDOM_STATE + 99)
354
+
355
+ for fold_i, (train_idx, test_idx) in enumerate(splitter.split(rows, labels, groups)):
356
+ print(f" Outer CV fold {fold_i + 1}/{n_splits}...", flush=True)
357
+
358
+ tr_rows_raw = rows[train_idx]
359
+ tr_targets_raw = targets[train_idx]
360
+ tr_labels_raw = labels[train_idx]
361
+ tr_groups_raw = groups[train_idx]
362
+ te_rows = rows[test_idx]
363
+ te_targets = targets[test_idx]
364
+ te_labels = labels[test_idx]
365
+
366
+ # Build OOF meta-features on training portion (inner loop)
367
+ oof_meta = build_oof_meta_features(
368
+ tr_rows_raw, tr_targets_raw, tr_labels_raw, tr_groups_raw,
369
+ copy.deepcopy(et_reg), copy.deepcopy(et_clf),
370
+ copy.deepcopy(xgb_reg) if xgb_reg else None,
371
+ copy.deepcopy(xgb_clf) if xgb_clf else None,
372
+ n_splits=3,
373
+ )
374
+
375
+ # Fit meta-learners on OOF
376
+ meta_reg = Ridge(alpha=1.0)
377
+ meta_clf = LogisticRegression(C=1.0, max_iter=500, random_state=RANDOM_STATE, solver="lbfgs")
378
+ meta_reg.fit(oof_meta, tr_targets_raw)
379
+ meta_clf.fit(oof_meta, tr_labels_raw)
380
+
381
+ # Build test meta-features: retrain base learners on augmented full train
382
+ aug_rows, aug_targets, aug_labels, _ = augment_training_data(
383
+ tr_rows_raw, tr_targets_raw, tr_labels_raw, tr_groups_raw, rng
384
+ )
385
+
386
+ fold_et_reg = copy.deepcopy(et_reg); fold_et_reg.fit(aug_rows, aug_targets)
387
+ fold_et_clf = copy.deepcopy(et_clf); fold_et_clf.fit(aug_rows, aug_labels)
388
+
389
+ te_meta = np.zeros((len(te_rows), 4), dtype=np.float32)
390
+ te_meta[:, 0] = fold_et_reg.predict(te_rows)
391
+ te_meta[:, 2] = fold_et_clf.predict_proba(te_rows)[:, 1]
392
+
393
+ if xgb_reg is not None:
394
+ fold_xgb_reg = copy.deepcopy(xgb_reg); fold_xgb_reg.fit(aug_rows, aug_targets)
395
+ fold_xgb_clf = copy.deepcopy(xgb_clf); fold_xgb_clf.fit(aug_rows, aug_labels)
396
+ te_meta[:, 1] = fold_xgb_reg.predict(te_rows)
397
+ te_meta[:, 3] = fold_xgb_clf.predict_proba(te_rows)[:, 1]
398
+ else:
399
+ te_meta[:, 1] = te_meta[:, 0]
400
+ te_meta[:, 3] = te_meta[:, 2]
401
+
402
+ hb_pred = meta_reg.predict(te_meta)
403
+ clf_prob = meta_clf.predict_proba(te_meta)[:, 1]
404
+
405
+ # Blend: same scheme as legacy model
406
+ hb_scale = max(float(np.quantile(np.abs(tr_targets_raw - fold_et_reg.predict(tr_rows_raw)), 0.75)), 0.8)
407
+ reg_risk = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_pred])
408
+ blend = 0.55 * clf_prob + 0.45 * reg_risk
409
+ thresh = find_best_threshold(te_labels, blend)
410
+ preds = (blend >= thresh).astype(int)
411
+
412
+ all_metrics.append({
413
+ "accuracy": accuracy_score(te_labels, preds),
414
+ "precision": precision_score(te_labels, preds, zero_division=0),
415
+ "recall": recall_score(te_labels, preds, zero_division=0),
416
+ "f1": f1_score(te_labels, preds, zero_division=0),
417
+ "auc": roc_auc_score(te_labels, blend),
418
+ "mae_hb": mean_absolute_error(te_targets, hb_pred),
419
+ "threshold": thresh,
420
+ })
421
+
422
+ avg = {k: round(float(np.mean([m[k] for m in all_metrics])), 4) for k in all_metrics[0]}
423
+ return avg
424
+
425
+
426
+ # ─────────────────────────────────────────────────────────────────────────────
427
+ # Main
428
+ # ─────────────────────────────────────────────────────────────────────────────
429
+
430
+ def main() -> None:
431
+ print("=" * 60, flush=True)
432
+ print("AnemiaLens — stacked-ensemble-v4 training", flush=True)
433
+ print("=" * 60, flush=True)
434
+
435
+ # ── 1. Load dataset ───────────────────────────────────────────────────────
436
+ print("\n[1/6] Loading dataset...", flush=True)
437
+ subjects = _build_subject_catalog(DATASET_ROOT)
438
+ print(f" Subjects: {len(subjects)}", flush=True)
439
+
440
+ samples = _samples_for_mode(subjects, "hybrid_dual")
441
+ print(f" Samples (hybrid_dual): {len(samples)}", flush=True)
442
+
443
+ rows, targets, labels, groups = _rows_from_samples(samples)
444
+ print(f" Class balance: {labels.sum()} anemic / {(labels == 0).sum()} non-anemic", flush=True)
445
+
446
+ # ── 2. Hyperparameter tuning ──────────────────────────────────────────────
447
+ print("\n[2/6] Tuning hyperparameters (RandomizedSearchCV, 20 iter each)...", flush=True)
448
+ rng = np.random.default_rng(RANDOM_STATE)
449
+ aug_rows, aug_targets, aug_labels, _ = augment_training_data(rows, targets, labels, groups, rng)
450
+
451
+ print(" Tuning ExtraTrees regressor...", flush=True)
452
+ et_reg = tune_et_regressor(aug_rows, aug_targets)
453
+
454
+ print(" Tuning ExtraTrees classifier...", flush=True)
455
+ et_clf = tune_et_classifier(aug_rows, aug_labels)
456
+
457
+ if _HAS_XGB:
458
+ print(" Tuning XGBoost regressor...", flush=True)
459
+ xgb_reg = tune_xgb_regressor(aug_rows, aug_targets)
460
+ print(" Tuning XGBoost classifier...", flush=True)
461
+ xgb_clf = tune_xgb_classifier(aug_rows, aug_labels)
462
+ else:
463
+ xgb_reg = xgb_clf = None
464
+
465
+ # ── 3. CV evaluation ───────────────────────────â���€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
466
+ print("\n[3/6] Cross-validating stacked ensemble...", flush=True)
467
+ cv_metrics = evaluate_stacked(rows, targets, labels, groups, et_reg, et_clf, xgb_reg, xgb_clf)
468
+ print(f"\n CV metrics: {cv_metrics}", flush=True)
469
+
470
+ # ── 4. Build final OOF meta-features on all data ──────────────────────────
471
+ print("\n[4/6] Building final OOF meta-features on full dataset...", flush=True)
472
+ oof_meta = build_oof_meta_features(
473
+ rows, targets, labels, groups, et_reg, et_clf, xgb_reg, xgb_clf, n_splits=N_CV_SPLITS
474
+ )
475
+
476
+ # ── 5. Fit final meta-learners ────────────────────────────────────────────
477
+ print("\n[5/6] Fitting meta-learners on OOF predictions...", flush=True)
478
+ meta_reg = Ridge(alpha=1.0)
479
+ meta_clf = LogisticRegression(C=1.0, max_iter=500, random_state=RANDOM_STATE, solver="lbfgs")
480
+ meta_reg.fit(oof_meta, targets)
481
+ meta_clf.fit(oof_meta, labels)
482
+
483
+ # Retrain base learners on full augmented data for inference
484
+ rng2 = np.random.default_rng(RANDOM_STATE + 1)
485
+ full_aug_rows, full_aug_targets, full_aug_labels, _ = augment_training_data(
486
+ rows, targets, labels, groups, rng2
487
+ )
488
+ et_reg.fit(full_aug_rows, full_aug_targets)
489
+ et_clf.fit(full_aug_rows, full_aug_labels)
490
+ if xgb_reg is not None:
491
+ xgb_reg.fit(full_aug_rows, full_aug_targets)
492
+ xgb_clf.fit(full_aug_rows, full_aug_labels)
493
+
494
+ # ── 6. Instantiate module-level stacked wrappers for inference ────────────
495
+ stacked_reg = StackedRegressor(et_reg, xgb_reg, et_clf, xgb_clf, meta_reg)
496
+ stacked_clf = StackedClassifier(et_clf, xgb_clf, et_reg, xgb_reg, meta_clf)
497
+
498
+ # ── Calibration ───────────────────────────────────────────────────────────
499
+ hb_preds_full = stacked_reg.predict(rows)
500
+ residuals = np.abs(targets - hb_preds_full)
501
+ hb_scale = max(float(np.quantile(residuals, 0.75)), 0.8)
502
+
503
+ hb_population_mean = float(np.mean(targets))
504
+ pred_std = float(np.std(hb_preds_full))
505
+ true_std = float(np.std(targets))
506
+ hb_spread_factor = float(np.clip(true_std / max(pred_std, 0.5), 1.0, 2.0))
507
+
508
+ clf_probs_full = stacked_clf.predict_proba(rows)[:, 1]
509
+ reg_risk_full = np.array([sigmoid((ANEMIA_HB_THRESHOLD - h) / hb_scale) for h in hb_preds_full])
510
+ blend_full = 0.55 * clf_probs_full + 0.45 * reg_risk_full
511
+ best_threshold = find_best_threshold(labels, blend_full)
512
+ risk_scale = max(float(np.std(blend_full)) * 0.9, 0.08)
513
+ risk_scale = min(risk_scale, 0.22)
514
+
515
+ calibration = {
516
+ "hb_threshold": ANEMIA_HB_THRESHOLD,
517
+ "hb_scale": round(hb_scale, 4),
518
+ "hb_population_mean": round(hb_population_mean, 4),
519
+ "hb_spread_factor": round(hb_spread_factor, 4),
520
+ "regressor_tree_std_reference": 2.5,
521
+ "classifier_tree_std_reference": 0.5,
522
+ "classifier_weight": 0.55,
523
+ "blend_threshold": round(best_threshold, 4),
524
+ "risk_scale": round(risk_scale, 4),
525
+ "base_uncertainty": 0.11,
526
+ }
527
+
528
+ # ── Save artifact ─────────────────────────────────────────────────────────
529
+ print("\n[6/6] Saving model...", flush=True)
530
+ artifact = {
531
+ "version": "stacked-ensemble-v4",
532
+ "feature_names": V4_FEATURE_NAMES,
533
+ "regressor": stacked_reg,
534
+ "classifier": stacked_clf,
535
+ "calibration": calibration,
536
+ "training": {
537
+ "selected_mode": "hybrid_dual",
538
+ "subject_count": len(subjects),
539
+ "record_count": len(samples),
540
+ "metrics": cv_metrics,
541
+ "xgboost_available": _HAS_XGB,
542
+ },
543
+ }
544
+ OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
545
+ joblib.dump(artifact, OUTPUT_PATH)
546
+ joblib.dump(artifact, OUTPUT_PATH_V4) # keep versioned copy too
547
+ print(f" Saved → {OUTPUT_PATH}", flush=True)
548
+ print(f" Saved → {OUTPUT_PATH_V4}", flush=True)
549
+
550
+ report = {
551
+ "dataset_name": "dataset anemia",
552
+ "record_count": len(samples),
553
+ "subject_count": len(subjects),
554
+ "primary_model": "stacked-ensemble-v4",
555
+ "selected_mode": "hybrid_dual",
556
+ "metrics": cv_metrics,
557
+ "calibration": {
558
+ "blend_threshold": calibration["blend_threshold"],
559
+ "risk_scale": calibration["risk_scale"],
560
+ "classifier_weight": calibration["classifier_weight"],
561
+ },
562
+ }
563
+ with open(REPORT_PATH, "w") as f:
564
+ json.dump(report, f, indent=2)
565
+ print(f" Report → {REPORT_PATH}", flush=True)
566
+
567
+ # ── Sanity check ──────────────────────────────────────────────────────────
568
+ print("\n── Sanity check ──────────────────────────────────────────────────", flush=True)
569
+ feat_idx = {n: i for i, n in enumerate(V4_FEATURE_NAMES)}
570
+
571
+ test_cases = [
572
+ ("PALE (anemic)", 0.28, 0.02, 0.22),
573
+ ("BORDERLINE", 0.35, 0.04, 0.30),
574
+ ("NORMAL", 0.44, 0.08, 0.38),
575
+ ("VERY HEALTHY", 0.48, 0.10, 0.42),
576
+ ]
577
+ for label, cpi_val, rg_val, br_val in test_cases:
578
+ row = np.zeros((1, len(V4_FEATURE_NAMES)), dtype=np.float32)
579
+ row[0, feat_idx["cpi"]] = cpi_val
580
+ row[0, feat_idx["center_cpi"]] = cpi_val - 0.01
581
+ row[0, feat_idx["mean_r"]] = cpi_val * 0.9
582
+ row[0, feat_idx["mean_g"]] = cpi_val * 0.9 - rg_val
583
+ row[0, feat_idx["mean_b"]] = cpi_val * 0.7
584
+ row[0, feat_idx["center_mean_r"]] = cpi_val * 0.9
585
+ row[0, feat_idx["center_mean_g"]] = cpi_val * 0.9 - rg_val
586
+ row[0, feat_idx["center_mean_b"]] = cpi_val * 0.7
587
+ row[0, feat_idx["red_green_gap"]] = rg_val
588
+ row[0, feat_idx["center_red_green_gap"]] = rg_val
589
+ row[0, feat_idx["brightness"]] = br_val
590
+ row[0, feat_idx["center_brightness"]] = br_val
591
+ row[0, feat_idx["contrast"]] = 0.12
592
+ row[0, feat_idx["center_contrast"]] = 0.12
593
+ row[0, feat_idx["blur_score"]] = 100.0
594
+ row[0, feat_idx["center_blur_score"]] = 120.0
595
+ row[0, feat_idx["saturation"]] = 0.3
596
+ row[0, feat_idx["center_saturation"]] = 0.3
597
+ row[0, feat_idx["green_blue_ratio"]] = 1.1 if cpi_val < 0.35 else 1.25
598
+ row[0, feat_idx["hist_mid"]] = 0.5
599
+ row[0, feat_idx["hist_bright"]] = 0.3
600
+ row[0, feat_idx["aspect_ratio"]] = 1.0
601
+ row[0, feat_idx["size_score"]] = 1.0
602
+ row[0, feat_idx["source_roi_original"]] = 1.0
603
+
604
+ hb_p = float(stacked_reg.predict(row)[0])
605
+ cp = float(stacked_clf.predict_proba(row)[0, 1])
606
+ rr = sigmoid((ANEMIA_HB_THRESHOLD - hb_p) / hb_scale)
607
+ bs = 0.55 * cp + 0.45 * rr
608
+ risk = sigmoid((bs - best_threshold) / risk_scale)
609
+ decision = "ANEMIA LIKELY" if risk >= 0.65 else "unlikely"
610
+ print(f" {label}: Hb={hb_p:.1f}, clf_prob={cp:.3f}, risk={risk:.3f} -> {decision}", flush=True)
611
+
612
+ print("\nDone.", flush=True)
613
+
614
+
615
+ if __name__ == "__main__":
616
+ main()
617
+
backend/start_server.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generic startup script for hosted AnemiaLens backends.
4
+ Uses PORT and HOST environment variables when provided by the host.
5
+ """
6
+
7
+ import os
8
+
9
+ import uvicorn
10
+
11
+ from app.main import app
12
+
13
+
14
+ if __name__ == "__main__":
15
+ port = int(os.environ.get("PORT", 8000))
16
+ host = os.environ.get("HOST", "0.0.0.0")
17
+
18
+ print(f"Starting AnemiaLens on {host}:{port}")
19
+ uvicorn.run(app, host=host, port=port)
backend/tests/test_case_insight.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parents[2]
7
+ sys.path.insert(0, str(ROOT / "backend"))
8
+
9
+ from app.schemas import (
10
+ DecisionAudit,
11
+ GuidanceResult,
12
+ PredictionResult,
13
+ QualityAssessment,
14
+ QualityIssue,
15
+ SymptomInput,
16
+ TriageResult,
17
+ )
18
+ from app.services.case_insight import CaseInsightService
19
+
20
+
21
+ def test_case_insight_builds_high_concern_story_with_drivers() -> None:
22
+ service = CaseInsightService()
23
+ pack = service.build(
24
+ QualityAssessment(
25
+ passed=True,
26
+ blur_score=210.0,
27
+ brightness_score=0.46,
28
+ contrast_score=0.18,
29
+ framing_score=1.9,
30
+ issues=[],
31
+ ),
32
+ PredictionResult(
33
+ anemia_risk=0.82,
34
+ predicted_hemoglobin=7.6,
35
+ confidence=0.89,
36
+ uncertainty=0.09,
37
+ reliability_flag="high",
38
+ screening_label="anemia_likely",
39
+ screening_text="The screening model detected a strong low-hemoglobin signal.",
40
+ model_source="archive-evidence-fusion-v4",
41
+ ),
42
+ TriageResult(
43
+ band="high_concern",
44
+ score=0.88,
45
+ label="High concern",
46
+ summary="Arrange formal review soon.",
47
+ disclaimer="Screening only.",
48
+ ),
49
+ DecisionAudit(
50
+ processing_path="roi_crop",
51
+ calibration_band="strong_positive",
52
+ decision_threshold=0.435,
53
+ threshold_margin=0.385,
54
+ quality_warning_codes=[],
55
+ review_flags=[],
56
+ summary="Direct ROI inference produced a strong positive margin.",
57
+ ),
58
+ GuidanceResult(
59
+ source="fallback",
60
+ explanation="Severely low hemoglobin signal.",
61
+ urgency_guidance="Seek medical attention within 24-48 hours.",
62
+ food_advice="Eat iron-rich foods.",
63
+ next_steps=["Visit nearest clinic or hospital today", "Request a full blood count (CBC) test"],
64
+ ),
65
+ SymptomInput(fatigue=True, shortness_of_breath=True),
66
+ )
67
+
68
+ assert pack.priority_window == "within_24_48_hours"
69
+ assert pack.risk_drivers[0].impact == "up"
70
+ assert any(driver.title == "Very low hemoglobin estimate" for driver in pack.risk_drivers)
71
+ assert any("Avoid strenuous activity" in step.action for step in pack.follow_up_timeline)
72
+ assert "symptom fusion" in pack.judge_summary.lower()
73
+
74
+
75
+ def test_case_insight_marks_rescue_path_as_confidence_limit() -> None:
76
+ service = CaseInsightService()
77
+ pack = service.build(
78
+ QualityAssessment(
79
+ passed=True,
80
+ blur_score=180.0,
81
+ brightness_score=0.39,
82
+ contrast_score=0.13,
83
+ framing_score=1.1,
84
+ issues=[
85
+ QualityIssue(
86
+ code="bad_framing",
87
+ severity="warning",
88
+ title="Eye framing is loose",
89
+ message="Recenter the eye.",
90
+ )
91
+ ],
92
+ ),
93
+ PredictionResult(
94
+ anemia_risk=0.51,
95
+ predicted_hemoglobin=10.9,
96
+ confidence=0.63,
97
+ uncertainty=0.29,
98
+ reliability_flag="medium",
99
+ screening_label="anemia_likely",
100
+ screening_text="The screening model detected some pallor-like signal.",
101
+ model_source="archive-evidence-fusion-v4",
102
+ ),
103
+ TriageResult(
104
+ band="moderate_risk",
105
+ score=0.59,
106
+ label="Moderate risk",
107
+ summary="Routine clinic follow-up is reasonable.",
108
+ disclaimer="Screening only.",
109
+ ),
110
+ DecisionAudit(
111
+ processing_path="full_frame_rescue",
112
+ calibration_band="borderline_positive",
113
+ decision_threshold=0.435,
114
+ threshold_margin=0.075,
115
+ quality_warning_codes=["bad_framing"],
116
+ review_flags=["raw_frame_rescue", "warning:bad_framing"],
117
+ summary="Full-frame rescue accepted a borderline positive result.",
118
+ ),
119
+ GuidanceResult(
120
+ source="fallback",
121
+ explanation="Mild to moderate anemia-like signal.",
122
+ urgency_guidance="See a doctor within 1-2 weeks.",
123
+ food_advice="Eat iron-rich foods.",
124
+ next_steps=["Book a clinic visit this week", "Start iron-rich diet immediately"],
125
+ ),
126
+ SymptomInput(),
127
+ )
128
+
129
+ assert "full-frame rescue" in pack.confidence_story.lower()
130
+ assert any(driver.impact == "limit" for driver in pack.risk_drivers)
131
+ assert any("direct conjunctiva crop" in item.lower() for item in pack.capture_improvements)
132
+
133
+
134
+ def test_case_insight_handles_quality_blocked_retake_case() -> None:
135
+ service = CaseInsightService()
136
+ pack = service.build(
137
+ QualityAssessment(
138
+ passed=False,
139
+ blur_score=42.0,
140
+ brightness_score=0.06,
141
+ contrast_score=0.03,
142
+ framing_score=0.42,
143
+ issues=[
144
+ QualityIssue(
145
+ code="poor_lighting",
146
+ severity="blocking",
147
+ title="Lighting is not usable",
148
+ message="Use bright natural light.",
149
+ )
150
+ ],
151
+ ),
152
+ None,
153
+ TriageResult(
154
+ band="uncertain_retake_needed",
155
+ score=0.2,
156
+ label="Uncertain, retake needed",
157
+ summary="Retake the image.",
158
+ disclaimer="Screening only.",
159
+ ),
160
+ DecisionAudit(
161
+ processing_path="quality_blocked",
162
+ calibration_band="quality_blocked",
163
+ decision_threshold=None,
164
+ threshold_margin=None,
165
+ quality_warning_codes=[],
166
+ review_flags=["quality_blocked"],
167
+ summary="Quality blocked model inference.",
168
+ ),
169
+ GuidanceResult(
170
+ source="fallback",
171
+ explanation="Image signal was not strong enough.",
172
+ urgency_guidance="Retake the scan in better lighting.",
173
+ food_advice="No food advice until a valid screening is available.",
174
+ next_steps=["Retake eye image in bright natural light"],
175
+ ),
176
+ SymptomInput(dizziness=True),
177
+ )
178
+
179
+ assert pack.priority_window == "retake_now"
180
+ assert "blocked model inference" in pack.risk_drivers[0].detail.lower()
181
+ assert pack.capture_improvements[0].startswith("Move into bright, even natural light")
182
+ assert "safety gate" in pack.judge_summary.lower()
backend/tests/test_clinical_brief.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parents[2]
7
+ sys.path.insert(0, str(ROOT / "backend"))
8
+
9
+ from app.schemas import (
10
+ DecisionAudit,
11
+ GuidanceResult,
12
+ PredictionResult,
13
+ QualityAssessment,
14
+ QualityIssue,
15
+ SymptomInput,
16
+ TriageResult,
17
+ )
18
+ from app.services.analysis_meta import build_analysis_meta
19
+ from app.services.case_insight import CaseInsightService
20
+ from app.services.clinical_brief import ClinicalBriefService
21
+ from app.services.handoff import HandoffSummaryService
22
+ from app.services.triage import TriageService
23
+
24
+
25
+ def test_clinical_brief_builds_grounded_high_concern_summary() -> None:
26
+ quality = QualityAssessment(
27
+ passed=True,
28
+ blur_score=198.0,
29
+ brightness_score=0.33,
30
+ contrast_score=0.19,
31
+ framing_score=1.9,
32
+ issues=[],
33
+ )
34
+ prediction = PredictionResult(
35
+ anemia_risk=0.84,
36
+ predicted_hemoglobin=7.8,
37
+ confidence=0.91,
38
+ uncertainty=0.08,
39
+ reliability_flag="high",
40
+ screening_label="anemia_likely",
41
+ screening_text="The screening model detected a strong low-hemoglobin signal.",
42
+ model_source="archive-evidence-fusion-v4",
43
+ )
44
+ symptoms = SymptomInput(fatigue=True, shortness_of_breath=True, poor_diet_low_iron=True)
45
+ triage_service = TriageService()
46
+ signal_breakdown = triage_service.build_signal_breakdown(quality, prediction, symptoms)
47
+ triage = triage_service.assess(
48
+ quality,
49
+ prediction,
50
+ symptoms,
51
+ signal_breakdown=signal_breakdown,
52
+ )
53
+ decision_audit = DecisionAudit(
54
+ processing_path="roi_crop",
55
+ calibration_band="strong_positive",
56
+ decision_threshold=0.435,
57
+ threshold_margin=0.405,
58
+ quality_warning_codes=[],
59
+ review_flags=[],
60
+ summary="Direct ROI inference produced a strong positive margin.",
61
+ )
62
+ guidance = GuidanceResult(
63
+ source="fallback",
64
+ explanation="The screening signal is concerning and should be reviewed soon.",
65
+ urgency_guidance="Seek medical review within 24 to 48 hours.",
66
+ food_advice="Eat iron-rich foods and include vitamin C with meals.",
67
+ next_steps=["Book a clinic or lab visit within 24 to 48 hours", "Request a CBC test"],
68
+ )
69
+ insight_pack = CaseInsightService().build(
70
+ quality,
71
+ prediction,
72
+ triage,
73
+ decision_audit,
74
+ guidance,
75
+ symptoms,
76
+ )
77
+ handoff_summary = HandoffSummaryService().build(
78
+ quality,
79
+ prediction,
80
+ triage,
81
+ guidance,
82
+ symptoms,
83
+ )
84
+
85
+ brief = ClinicalBriefService().build(
86
+ quality,
87
+ prediction,
88
+ triage,
89
+ decision_audit,
90
+ guidance,
91
+ symptoms,
92
+ insight_pack,
93
+ handoff_summary,
94
+ signal_breakdown,
95
+ )
96
+
97
+ assert brief.action_window == "within_24_48_hours"
98
+ assert brief.signal_breakdown.image_risk == 0.84
99
+ assert brief.signal_breakdown.symptom_burden == "moderate"
100
+ assert any("hemoglobin signal" in item.lower() for item in brief.supporting_evidence)
101
+ assert any("uncertainty" in item.lower() for item in brief.safety_checks)
102
+ assert "AnemiaLens clinical brief" in brief.share_text
103
+
104
+
105
+ def test_clinical_brief_handles_quality_blocked_case_and_meta() -> None:
106
+ quality = QualityAssessment(
107
+ passed=False,
108
+ blur_score=42.0,
109
+ brightness_score=0.05,
110
+ contrast_score=0.03,
111
+ framing_score=0.4,
112
+ issues=[
113
+ QualityIssue(
114
+ code="poor_lighting",
115
+ severity="blocking",
116
+ title="Lighting is not usable",
117
+ message="Use bright natural light.",
118
+ )
119
+ ],
120
+ )
121
+ symptoms = SymptomInput(dizziness=True)
122
+ triage_service = TriageService()
123
+ signal_breakdown = triage_service.build_signal_breakdown(quality, None, symptoms)
124
+ triage = triage_service.assess(
125
+ quality,
126
+ None,
127
+ symptoms,
128
+ signal_breakdown=signal_breakdown,
129
+ )
130
+ decision_audit = DecisionAudit(
131
+ processing_path="quality_blocked",
132
+ calibration_band="quality_blocked",
133
+ decision_threshold=None,
134
+ threshold_margin=None,
135
+ quality_warning_codes=[],
136
+ review_flags=["quality_blocked"],
137
+ summary="Quality blocked model inference.",
138
+ )
139
+ guidance = GuidanceResult(
140
+ source="fallback",
141
+ explanation="The image was too weak for a reliable screening result.",
142
+ urgency_guidance="Retake the scan in better light.",
143
+ food_advice="Wait for a valid scan before using food guidance from the app.",
144
+ next_steps=["Retake the image in bright natural light"],
145
+ )
146
+ insight_pack = CaseInsightService().build(
147
+ quality,
148
+ None,
149
+ triage,
150
+ decision_audit,
151
+ guidance,
152
+ symptoms,
153
+ )
154
+ handoff_summary = HandoffSummaryService().build(
155
+ quality,
156
+ None,
157
+ triage,
158
+ guidance,
159
+ symptoms,
160
+ )
161
+
162
+ brief = ClinicalBriefService().build(
163
+ quality,
164
+ None,
165
+ triage,
166
+ decision_audit,
167
+ guidance,
168
+ symptoms,
169
+ insight_pack,
170
+ handoff_summary,
171
+ signal_breakdown,
172
+ )
173
+ meta = build_analysis_meta(
174
+ request_id="abc12345",
175
+ api_version="0.3.0",
176
+ processing_time_ms=187.36,
177
+ quality=quality,
178
+ decision_audit=decision_audit,
179
+ guidance=guidance,
180
+ used_raw_frame_rescue=False,
181
+ )
182
+
183
+ assert brief.signal_breakdown.image_risk is None
184
+ assert any("blocked model inference" in item.lower() for item in brief.supporting_evidence)
185
+ assert any("primary blocker" in item.lower() for item in brief.limiting_factors)
186
+ assert "image=not available" in brief.share_text
187
+ assert meta.request_id == "abc12345"
188
+ assert meta.processing_path == "quality_blocked"
189
+ assert meta.guidance_source == "fallback"
190
+ assert meta.safety_layers == [
191
+ "image_quality_gate",
192
+ "symptom_fusion",
193
+ "triage_banding",
194
+ "non_diagnostic_guidance",
195
+ ]
backend/tests/test_decision_audit.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parents[2]
7
+ sys.path.insert(0, str(ROOT / "backend"))
8
+
9
+ from app.schemas import GuidanceResult, PredictionResult, QualityAssessment, QualityIssue, SymptomInput, TriageResult
10
+ from app.services.decision_audit import build_decision_audit
11
+
12
+
13
+ def test_decision_audit_marks_full_frame_rescue_and_threshold_margin() -> None:
14
+ audit = build_decision_audit(
15
+ QualityAssessment(
16
+ passed=True,
17
+ blur_score=220.0,
18
+ brightness_score=0.44,
19
+ contrast_score=0.15,
20
+ framing_score=1.7,
21
+ issues=[
22
+ QualityIssue(
23
+ code="bad_framing",
24
+ severity="warning",
25
+ title="Eye framing is loose",
26
+ message="The app fell back to the full eye frame.",
27
+ )
28
+ ],
29
+ ),
30
+ PredictionResult(
31
+ anemia_risk=0.82,
32
+ predicted_hemoglobin=10.8,
33
+ confidence=0.66,
34
+ uncertainty=0.34,
35
+ reliability_flag="medium",
36
+ screening_label="anemia_likely",
37
+ screening_text="Likely anemia.",
38
+ model_source="archive-evidence-fusion-v4",
39
+ ),
40
+ TriageResult(
41
+ band="moderate_risk",
42
+ score=0.54,
43
+ label="Moderate risk",
44
+ summary="Moderate concern.",
45
+ disclaimer="Screening only.",
46
+ ),
47
+ used_raw_frame_rescue=True,
48
+ )
49
+
50
+ assert audit.processing_path == "full_frame_rescue"
51
+ assert audit.calibration_band == "strong_positive"
52
+ assert audit.decision_threshold == 0.435
53
+ assert audit.threshold_margin == 0.385
54
+ assert "raw_frame_rescue" in audit.review_flags
55
+ assert "warning:bad_framing" in audit.review_flags
56
+
57
+
58
+ def test_decision_audit_handles_blocked_request() -> None:
59
+ audit = build_decision_audit(
60
+ QualityAssessment(
61
+ passed=False,
62
+ blur_score=40.0,
63
+ brightness_score=0.05,
64
+ contrast_score=0.03,
65
+ framing_score=0.4,
66
+ issues=[
67
+ QualityIssue(
68
+ code="poor_lighting",
69
+ severity="blocking",
70
+ title="Lighting is not usable",
71
+ message="Use bright, even light.",
72
+ )
73
+ ],
74
+ ),
75
+ None,
76
+ TriageResult(
77
+ band="uncertain_retake_needed",
78
+ score=0.2,
79
+ label="Retake needed",
80
+ summary="Retake the image.",
81
+ disclaimer="Screening only.",
82
+ ),
83
+ )
84
+
85
+ assert audit.processing_path == "quality_blocked"
86
+ assert audit.calibration_band == "quality_blocked"
87
+ assert audit.decision_threshold is None
88
+ assert "quality_blocked" in audit.review_flags
89
+ assert "blocked model inference" in audit.summary.lower()
backend/tests/test_email_report.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the email report API and delivery service.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+ from fastapi import FastAPI
13
+ from fastapi.testclient import TestClient
14
+
15
+ ROOT = Path(__file__).resolve().parents[2]
16
+ sys.path.insert(0, str(ROOT / "backend"))
17
+
18
+ from app.api.email_report import get_email_report_service, router
19
+ from app.config import settings
20
+ from app.services import email_report as email_report_module
21
+ from app.services.email_report import (
22
+ EmailReportContent,
23
+ EmailReportDeliveryError,
24
+ EmailReportNotConfiguredError,
25
+ EmailReportService,
26
+ )
27
+
28
+
29
+ class _StubRouterService:
30
+ def __init__(self, exc: Exception | None = None) -> None:
31
+ self.exc = exc
32
+ self.payload: EmailReportContent | None = None
33
+
34
+ def masked_recipient(self, recipient: str) -> str:
35
+ return f"masked:{recipient}"
36
+
37
+ def send_report(self, payload: EmailReportContent) -> None:
38
+ self.payload = payload
39
+ if self.exc is not None:
40
+ raise self.exc
41
+
42
+
43
+ class _SMTPStub:
44
+ last_instance: "_SMTPStub | None" = None
45
+
46
+ def __init__(self, host: str, port: int, timeout: float | None = None, context=None) -> None:
47
+ self.host = host
48
+ self.port = port
49
+ self.timeout = timeout
50
+ self.context = context
51
+ self.logged_in: tuple[str, str] | None = None
52
+ self.sent_message = None
53
+ _SMTPStub.last_instance = self
54
+
55
+ def __enter__(self) -> "_SMTPStub":
56
+ return self
57
+
58
+ def __exit__(self, exc_type, exc, tb) -> None:
59
+ return None
60
+
61
+ def login(self, username: str, password: str) -> None:
62
+ self.logged_in = (username, password)
63
+
64
+ def send_message(self, message) -> None:
65
+ self.sent_message = message
66
+
67
+
68
+ class _HTTPResponseStub:
69
+ def __init__(self, body: str = '{"id":"email_123"}', status: int = 200) -> None:
70
+ self._body = body.encode("utf-8")
71
+ self.status = status
72
+
73
+ def read(self) -> bytes:
74
+ return self._body
75
+
76
+
77
+ class _HTTPSConnectionStub:
78
+ last_instance: "_HTTPSConnectionStub | None" = None
79
+ response_status: int = 200
80
+ response_body: str = '{"id":"email_123"}'
81
+
82
+ def __init__(self, host: str, timeout: float | None = None) -> None:
83
+ self.host = host
84
+ self.timeout = timeout
85
+ self.request_args: tuple[str, str, bytes, dict[str, str]] | None = None
86
+ self.closed = False
87
+ _HTTPSConnectionStub.last_instance = self
88
+
89
+ def request(self, method: str, path: str, body=None, headers=None) -> None:
90
+ self.request_args = (method, path, body, headers or {})
91
+
92
+ def getresponse(self) -> _HTTPResponseStub:
93
+ return _HTTPResponseStub(body=self.response_body, status=self.response_status)
94
+
95
+ def close(self) -> None:
96
+ self.closed = True
97
+
98
+
99
+ class _GmailHTTPSConnectionStub:
100
+ requests: list[tuple[str, str, bytes, dict[str, str], str, float | None]] = []
101
+ response_queue: list[_HTTPResponseStub] = []
102
+
103
+ def __init__(self, host: str, timeout: float | None = None) -> None:
104
+ self.host = host
105
+ self.timeout = timeout
106
+ self.closed = False
107
+
108
+ def request(self, method: str, path: str, body=None, headers=None) -> None:
109
+ _GmailHTTPSConnectionStub.requests.append((method, path, body, headers or {}, self.host, self.timeout))
110
+
111
+ def getresponse(self) -> _HTTPResponseStub:
112
+ return _GmailHTTPSConnectionStub.response_queue.pop(0)
113
+
114
+ def close(self) -> None:
115
+ self.closed = True
116
+
117
+
118
+ def _client_with_service(service: _StubRouterService) -> TestClient:
119
+ app = FastAPI()
120
+ app.include_router(router)
121
+ app.dependency_overrides[get_email_report_service] = lambda: service
122
+ return TestClient(app)
123
+
124
+
125
+ def test_email_report_endpoint_sends_valid_payload() -> None:
126
+ service = _StubRouterService()
127
+ client = _client_with_service(service)
128
+
129
+ response = client.post(
130
+ "/api/email-report",
131
+ json={
132
+ "email": "person@example.com",
133
+ "share_text": "Moderate risk summary.\nPlease follow up with a CBC test.",
134
+ "triage_label": "Moderate Risk",
135
+ "predicted_hemoglobin": 10.6,
136
+ "anemia_risk": 0.54,
137
+ },
138
+ )
139
+
140
+ assert response.status_code == 200
141
+ assert response.json()["status"] == "sent"
142
+ assert service.payload is not None
143
+ assert service.payload.recipient == "person@example.com"
144
+ assert service.payload.predicted_hemoglobin == 10.6
145
+
146
+
147
+ def test_email_report_endpoint_rejects_invalid_email() -> None:
148
+ client = _client_with_service(_StubRouterService())
149
+
150
+ response = client.post(
151
+ "/api/email-report",
152
+ json={
153
+ "email": "not-an-email",
154
+ "share_text": "Moderate risk summary.\nPlease follow up with a CBC test.",
155
+ "triage_label": "Moderate Risk",
156
+ "predicted_hemoglobin": 10.6,
157
+ "anemia_risk": 0.54,
158
+ },
159
+ )
160
+
161
+ assert response.status_code == 422
162
+
163
+
164
+ def test_email_report_endpoint_returns_503_when_not_configured() -> None:
165
+ client = _client_with_service(
166
+ _StubRouterService(
167
+ EmailReportNotConfiguredError("Email delivery is not configured."),
168
+ )
169
+ )
170
+
171
+ response = client.post(
172
+ "/api/email-report",
173
+ json={
174
+ "email": "person@example.com",
175
+ "share_text": "Moderate risk summary.\nPlease follow up with a CBC test.",
176
+ "triage_label": "Moderate Risk",
177
+ "predicted_hemoglobin": 10.6,
178
+ "anemia_risk": 0.54,
179
+ },
180
+ )
181
+
182
+ assert response.status_code == 503
183
+ assert "not configured" in response.json()["detail"].lower()
184
+
185
+
186
+ def test_email_report_endpoint_returns_502_when_delivery_fails() -> None:
187
+ client = _client_with_service(
188
+ _StubRouterService(
189
+ EmailReportDeliveryError("SMTP authentication failed."),
190
+ )
191
+ )
192
+
193
+ response = client.post(
194
+ "/api/email-report",
195
+ json={
196
+ "email": "person@example.com",
197
+ "share_text": "Moderate risk summary.\nPlease follow up with a CBC test.",
198
+ "triage_label": "Moderate Risk",
199
+ "predicted_hemoglobin": 10.6,
200
+ "anemia_risk": 0.54,
201
+ },
202
+ )
203
+
204
+ assert response.status_code == 502
205
+ assert "smtp" in response.json()["detail"].lower()
206
+
207
+
208
+ def test_email_report_service_requires_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
209
+ monkeypatch.setattr(settings, "email_provider", "smtp")
210
+ monkeypatch.setattr(settings, "smtp_username", "")
211
+ monkeypatch.setattr(settings, "smtp_password", "")
212
+ monkeypatch.setattr(settings, "email_from_email", "")
213
+
214
+ service = EmailReportService()
215
+
216
+ with pytest.raises(EmailReportNotConfiguredError, match="configured"):
217
+ service.send_report(
218
+ EmailReportContent(
219
+ recipient="person@example.com",
220
+ share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
221
+ triage_label="Moderate Risk",
222
+ predicted_hemoglobin=10.6,
223
+ anemia_risk=0.54,
224
+ )
225
+ )
226
+
227
+
228
+ def test_email_report_service_sends_email_via_ssl(monkeypatch: pytest.MonkeyPatch) -> None:
229
+ monkeypatch.setattr(settings, "email_provider", "smtp")
230
+ monkeypatch.setattr(settings, "smtp_host", "smtp.example.com")
231
+ monkeypatch.setattr(settings, "smtp_port", 465)
232
+ monkeypatch.setattr(settings, "smtp_username", "mailer@example.com")
233
+ monkeypatch.setattr(settings, "smtp_password", "app-password")
234
+ monkeypatch.setattr(settings, "smtp_use_ssl", True)
235
+ monkeypatch.setattr(settings, "smtp_use_starttls", False)
236
+ monkeypatch.setattr(settings, "smtp_timeout", 20.0)
237
+ monkeypatch.setattr(settings, "email_from_name", "AnemiaLens")
238
+ monkeypatch.setattr(settings, "email_from_email", "reports@example.com")
239
+ monkeypatch.setattr(settings, "email_reply_to", "support@example.com")
240
+ monkeypatch.setattr(email_report_module.smtplib, "SMTP_SSL", _SMTPStub)
241
+
242
+ service = EmailReportService()
243
+ service.send_report(
244
+ EmailReportContent(
245
+ recipient="patient@example.com",
246
+ share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
247
+ triage_label="Moderate Risk",
248
+ predicted_hemoglobin=10.6,
249
+ anemia_risk=0.54,
250
+ )
251
+ )
252
+
253
+ smtp = _SMTPStub.last_instance
254
+ assert smtp is not None
255
+ assert smtp.host == "smtp.example.com"
256
+ assert smtp.port == 465
257
+ assert smtp.logged_in == ("mailer@example.com", "app-password")
258
+ assert smtp.sent_message["To"] == "patient@example.com"
259
+ assert smtp.sent_message["Reply-To"] == "support@example.com"
260
+ assert "Moderate Risk" in smtp.sent_message["Subject"]
261
+ plain_part = smtp.sent_message.get_body(preferencelist=("plain",))
262
+ html_part = smtp.sent_message.get_body(preferencelist=("html",))
263
+ assert plain_part is not None
264
+ assert html_part is not None
265
+ assert "clinical blood test (CBC)" in plain_part.get_content()
266
+ assert "Recommended Next Steps" in plain_part.get_content()
267
+ assert "Why this result" in html_part.get_content()
268
+ assert "Open AnemiaLens" in html_part.get_content()
269
+
270
+
271
+ def test_email_report_service_sends_email_via_resend(monkeypatch: pytest.MonkeyPatch) -> None:
272
+ _HTTPSConnectionStub.response_status = 200
273
+ _HTTPSConnectionStub.response_body = '{"id":"email_123"}'
274
+ monkeypatch.setattr(settings, "email_provider", "resend")
275
+ monkeypatch.setattr(settings, "resend_api_key", "re_test_123")
276
+ monkeypatch.setattr(settings, "resend_api_base", "https://api.resend.test")
277
+ monkeypatch.setattr(settings, "email_from_name", "AnemiaLens")
278
+ monkeypatch.setattr(settings, "email_from_email", "onboarding@resend.dev")
279
+ monkeypatch.setattr(settings, "email_reply_to", "support@example.com")
280
+ monkeypatch.setattr(settings, "smtp_username", "")
281
+ monkeypatch.setattr(settings, "smtp_password", "")
282
+ monkeypatch.setattr(settings, "smtp_timeout", 12.0)
283
+ monkeypatch.setattr(email_report_module.http.client, "HTTPSConnection", _HTTPSConnectionStub)
284
+
285
+ service = EmailReportService()
286
+ service.send_report(
287
+ EmailReportContent(
288
+ recipient="patient@example.com",
289
+ share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
290
+ triage_label="Moderate Risk",
291
+ predicted_hemoglobin=10.6,
292
+ anemia_risk=0.54,
293
+ )
294
+ )
295
+
296
+ connection = _HTTPSConnectionStub.last_instance
297
+ assert connection is not None
298
+ assert connection.host == "api.resend.test"
299
+ assert connection.timeout == 12.0
300
+ assert connection.closed is True
301
+ assert connection.request_args is not None
302
+ method, path, raw_body, headers = connection.request_args
303
+ body = json.loads(raw_body.decode("utf-8"))
304
+ assert method == "POST"
305
+ assert path == "/emails"
306
+ assert headers["Authorization"] == "Bearer re_test_123"
307
+ assert headers["Content-Type"] == "application/json"
308
+ assert headers["Idempotency-Key"].startswith("email-report/patient@example.com/moderate-risk/")
309
+ assert headers["User-Agent"] == "AnemiaLens/1.0 (+https://anemia-lens.vercel.app)"
310
+ assert body["from"] == "AnemiaLens <onboarding@resend.dev>"
311
+ assert body["to"] == ["patient@example.com"]
312
+ assert body["reply_to"] == "support@example.com"
313
+ assert body["subject"] == "AnemiaLens Screening Report - Moderate Risk"
314
+ assert "clinical blood test (CBC)" in body["text"]
315
+
316
+
317
+ def test_email_report_service_sends_email_via_sendgrid(monkeypatch: pytest.MonkeyPatch) -> None:
318
+ _HTTPSConnectionStub.response_status = 202
319
+ _HTTPSConnectionStub.response_body = ""
320
+ monkeypatch.setattr(settings, "email_provider", "sendgrid")
321
+ monkeypatch.setattr(settings, "sendgrid_api_key", "SG.test-key")
322
+ monkeypatch.setattr(settings, "sendgrid_api_base", "https://api.sendgrid.test/v3")
323
+ monkeypatch.setattr(settings, "email_from_name", "AnemiaLens")
324
+ monkeypatch.setattr(settings, "email_from_email", "asnanp875@gmail.com")
325
+ monkeypatch.setattr(settings, "email_reply_to", "asnanp875@gmail.com")
326
+ monkeypatch.setattr(settings, "smtp_username", "")
327
+ monkeypatch.setattr(settings, "smtp_password", "")
328
+ monkeypatch.setattr(settings, "smtp_timeout", 12.0)
329
+ monkeypatch.setattr(email_report_module.http.client, "HTTPSConnection", _HTTPSConnectionStub)
330
+
331
+ service = EmailReportService()
332
+ service.send_report(
333
+ EmailReportContent(
334
+ recipient="patient@example.com",
335
+ share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
336
+ triage_label="Moderate Risk",
337
+ predicted_hemoglobin=10.6,
338
+ anemia_risk=0.54,
339
+ )
340
+ )
341
+
342
+ connection = _HTTPSConnectionStub.last_instance
343
+ assert connection is not None
344
+ assert connection.host == "api.sendgrid.test"
345
+ assert connection.timeout == 12.0
346
+ assert connection.closed is True
347
+ assert connection.request_args is not None
348
+ method, path, raw_body, headers = connection.request_args
349
+ body = json.loads(raw_body.decode("utf-8"))
350
+ assert method == "POST"
351
+ assert path == "/v3/mail/send"
352
+ assert headers["Authorization"] == "Bearer SG.test-key"
353
+ assert headers["Content-Type"] == "application/json"
354
+ assert headers["User-Agent"] == "AnemiaLens/1.0 (+https://anemia-lens.vercel.app)"
355
+ assert body["from"] == {"email": "asnanp875@gmail.com", "name": "AnemiaLens"}
356
+ assert body["reply_to"] == {"email": "asnanp875@gmail.com"}
357
+ assert body["personalizations"][0]["to"] == [{"email": "patient@example.com"}]
358
+ assert body["personalizations"][0]["subject"] == "AnemiaLens Screening Report - Moderate Risk"
359
+ assert body["content"][0]["type"] == "text/plain"
360
+ assert body["content"][1]["type"] == "text/html"
361
+ assert "clinical blood test (CBC)" in body["content"][0]["value"]
362
+
363
+
364
+ def test_email_report_service_sends_email_via_gmail_api(monkeypatch: pytest.MonkeyPatch) -> None:
365
+ _GmailHTTPSConnectionStub.requests = []
366
+ _GmailHTTPSConnectionStub.response_queue = [
367
+ _HTTPResponseStub(body='{"access_token":"ya29.test-token"}', status=200),
368
+ _HTTPResponseStub(body='{"id":"gmail_message_123"}', status=200),
369
+ ]
370
+ monkeypatch.setattr(settings, "email_provider", "gmail_api")
371
+ monkeypatch.setattr(settings, "gmail_client_id", "client-id")
372
+ monkeypatch.setattr(settings, "gmail_client_secret", "client-secret")
373
+ monkeypatch.setattr(settings, "gmail_refresh_token", "refresh-token")
374
+ monkeypatch.setattr(settings, "gmail_token_url", "https://oauth2.googleapis.com/token")
375
+ monkeypatch.setattr(settings, "gmail_api_base", "https://gmail.googleapis.com/gmail/v1")
376
+ monkeypatch.setattr(settings, "email_from_name", "AnemiaLens")
377
+ monkeypatch.setattr(settings, "email_from_email", "asnanp875@gmail.com")
378
+ monkeypatch.setattr(settings, "email_reply_to", "asnanp875@gmail.com")
379
+ monkeypatch.setattr(settings, "smtp_timeout", 12.0)
380
+ monkeypatch.setattr(email_report_module.http.client, "HTTPSConnection", _GmailHTTPSConnectionStub)
381
+
382
+ service = EmailReportService()
383
+ service.send_report(
384
+ EmailReportContent(
385
+ recipient="patient@example.com",
386
+ share_text="Moderate risk summary.\nPlease follow up with a CBC test.",
387
+ triage_label="Moderate Risk",
388
+ predicted_hemoglobin=10.6,
389
+ anemia_risk=0.54,
390
+ )
391
+ )
392
+
393
+ assert len(_GmailHTTPSConnectionStub.requests) == 2
394
+
395
+ token_method, token_path, token_body, token_headers, token_host, token_timeout = _GmailHTTPSConnectionStub.requests[0]
396
+ assert token_method == "POST"
397
+ assert token_host == "oauth2.googleapis.com"
398
+ assert token_timeout == 12.0
399
+ assert token_path == "/token"
400
+ assert token_headers["Content-Type"] == "application/x-www-form-urlencoded"
401
+ assert b"grant_type=refresh_token" in token_body
402
+ assert b"client_id=client-id" in token_body
403
+ assert b"client_secret=client-secret" in token_body
404
+ assert b"refresh_token=refresh-token" in token_body
405
+
406
+ send_method, send_path, send_body_raw, send_headers, send_host, send_timeout = _GmailHTTPSConnectionStub.requests[1]
407
+ send_body = json.loads(send_body_raw.decode("utf-8"))
408
+ assert send_method == "POST"
409
+ assert send_host == "gmail.googleapis.com"
410
+ assert send_timeout == 12.0
411
+ assert send_path == "/gmail/v1/users/me/messages/send"
412
+ assert send_headers["Authorization"] == "Bearer ya29.test-token"
413
+ assert send_headers["Content-Type"] == "application/json"
414
+ assert "raw" in send_body