HumanPlusX / src /core /pdf_processor.py
FajarHidaa's picture
Upload 3 files
c99d3a1 verified
Raw
History Blame Contribute Delete
10.2 kB
"""
Human+ PDF Processor
Extracts biomarkers from lab PDF using Gemini vision.
Returns structured list of biomarker dicts ready for UI rendering.
"""
import json
import re
from typing import Optional
from core.ai_client import call_gemini, pdf_bytes_to_part, TEMP_EXTRACTION
# ── Biomarker extraction prompt ──────────────────────────────────────
_EXTRACTION_PROMPT = """
Kamu adalah AI lab analyst dari Human+ Bali.
Tugasmu: Ekstrak SEMUA biomarker dari hasil lab PDF ini.
PENTING — Kembalikan HANYA valid JSON array, tanpa teks lain, tanpa markdown code block.
Format setiap item:
{
"name": "Nama biomarker (gunakan nama standar, contoh: hs-CRP, Vitamin D, HbA1c)",
"value": "Nilai numerik sebagai string (contoh: '2.4', '22', '5.6')",
"unit": "Satuan (contoh: mg/L, ng/mL, %)",
"reference_range": "Range referensi dari lab (contoh: 0.0-5.0 mg/L)",
"lab_flag": "normal | high | low | critical_high | critical_low"
}
Prioritas biomarker (wajib cari jika ada):
- Inflammation: hs-CRP, Homocysteine, Ferritin
- Metabolic: HbA1c, Fasting Glucose, Fasting Insulin
- Hormones: Total Testosterone, Free Testosterone, Vitamin D, DHEA-S, Cortisol, TSH
- Nutrients: Vitamin B12, Magnesium, Zinc, Folate, Vitamin B6
- Cardiovascular: ApoB, Triglycerides, HDL, LDL, Total Cholesterol
- Blood: Hemoglobin, Hematocrit, WBC, Platelets
- Liver: ALT, AST, GGT, Bilirubin
- Kidney: Creatinine, eGFR, Uric Acid
Jika nilai tidak terbaca atau tidak ada, SKIP biomarker tersebut.
Kembalikan HANYA JSON array, tidak ada teks lain.
"""
# ── Human+ optimal ranges (untuk status mapping) ─────────────────────
_OPTIMAL_RANGES: dict[str, dict] = {
"hs-crp": {"optimal": (None, 1.0), "warning": (1.0, 3.0)},
"homocysteine": {"optimal": (None, 8.0), "warning": (8.0, 12.0)},
"ferritin_m": {"optimal": (50, 150), "warning": (30, 50)}, # pria
"ferritin_f": {"optimal": (30, 100), "warning": (15, 30)}, # wanita
"hba1c": {"optimal": (4.8, 5.2), "warning": (5.2, 5.6)},
"fasting glucose": {"optimal": (75, 86), "warning": (70, 100)},
"fasting insulin": {"optimal": (None, 7.0), "warning": (7.0, 10.0)},
"total testosterone": {"optimal": (600, None), "warning": (400, 600)},
"vitamin d": {"optimal": (50, 80), "warning": (30, 50)},
"dhea-s": {"optimal": None, "warning": None}, # age-dependent
"vitamin b12": {"optimal": (500, None), "warning": (300, 500)},
"magnesium": {"optimal": (2.2, None), "warning": (1.8, 2.2)},
"zinc": {"optimal": (90, 120), "warning": (70, 90)},
"folate": {"optimal": (10, None), "warning": (5, 10)},
"apob": {"optimal": (None, 80), "warning": (80, 100)},
"triglycerides": {"optimal": (None, 90), "warning": (90, 150)},
"hdl": {"optimal": (60, None), "warning": (40, 60)},
"tsh": {"optimal": (0.5, 2.0), "warning": (2.0, 4.0)},
}
class PDFProcessorError(Exception):
"""Raised when PDF extraction fails."""
pass
def extract_biomarkers(pdf_bytes: bytes) -> list[dict]:
"""
Extract biomarkers dari PDF bytes menggunakan Gemini.
Args:
pdf_bytes: raw PDF file bytes
Returns:
list of biomarker dicts dengan keys:
- name, value, unit, status, reference (Human+ optimal range)
- raw_value, lab_flag, reference_range (dari lab asli)
Raises:
PDFProcessorError: jika extraction gagal
"""
# 1. Call Gemini dengan PDF
raw_json = _call_gemini_extraction(pdf_bytes)
# 2. Parse JSON response
raw_biomarkers = _parse_json_response(raw_json)
# 3. Map ke Human+ status (optimal/warning/danger)
enriched = [_enrich_biomarker(b) for b in raw_biomarkers]
# 4. Filter yang invalid, sort by status priority
valid = [b for b in enriched if b is not None]
return _sort_by_priority(valid)
def get_raw_extraction_text(pdf_bytes: bytes) -> str:
"""
Return raw Gemini extraction text (untuk ditampilkan di expander debug).
Useful untuk troubleshooting jika parsing gagal.
"""
return _call_gemini_extraction(pdf_bytes)
# ── Private helpers ──────────────────────────────────────────────────
def _call_gemini_extraction(pdf_bytes: bytes) -> str:
"""Send PDF to Gemini and return raw text response."""
try:
pdf_part = pdf_bytes_to_part(pdf_bytes)
response = call_gemini(
contents=[_EXTRACTION_PROMPT, pdf_part],
temperature=TEMP_EXTRACTION,
)
return response
except Exception as e:
raise PDFProcessorError(f"Gemini extraction gagal: {e}") from e
def _parse_json_response(raw: str) -> list[dict]:
"""
Parse JSON dari Gemini response.
Gemini kadang return ```json ... ``` atau teks tambahan — kita strip dulu.
"""
# Strip markdown code fences jika ada
cleaned = re.sub(r"```(?:json)?", "", raw).strip().rstrip("```").strip()
# Coba parse langsung
try:
data = json.loads(cleaned)
if isinstance(data, list):
return data
# Kadang Gemini wrap dalam object
if isinstance(data, dict):
for key in ["biomarkers", "results", "data", "markers"]:
if key in data and isinstance(data[key], list):
return data[key]
except json.JSONDecodeError:
pass
# Fallback: cari array JSON di dalam teks
match = re.search(r"\[[\s\S]*\]", cleaned)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
raise PDFProcessorError(
"Gagal parse JSON dari Gemini. "
"PDF mungkin tidak terbaca dengan baik atau format tidak standard."
)
def _enrich_biomarker(raw: dict) -> Optional[dict]:
"""
Enrich raw biomarker dict dengan Human+ status mapping.
Input keys: name, value, unit, reference_range, lab_flag
Output adds: status ('optimal'|'warning'|'danger'|'neutral'), reference (Human+)
"""
try:
name = str(raw.get("name", "")).strip()
value = str(raw.get("value", "")).strip()
unit = str(raw.get("unit", "")).strip()
if not name or not value:
return None
# Parse numeric value
try:
numeric_value = float(re.sub(r"[^\d.\-]", "", value))
except (ValueError, TypeError):
numeric_value = None
# Map status
status, human_plus_ref = _map_to_human_plus_status(
name, numeric_value, raw.get("lab_flag", "normal")
)
# Gunakan Human+ target jika tersedia, fallback ke reference_range dari lab
display_reference = human_plus_ref if human_plus_ref else raw.get("reference_range", "")
return {
# For UI rendering
"name": name,
"value": value,
"unit": unit,
"status": status,
"reference": display_reference,
# Raw lab data (untuk expander / debug)
"raw_value": numeric_value,
"lab_flag": raw.get("lab_flag", "normal"),
"reference_range": raw.get("reference_range", ""),
"human_plus_ref": human_plus_ref, # Human+ target khusus (bisa berbeda)
}
except Exception:
return None
def _map_to_human_plus_status(
name: str,
value: Optional[float],
lab_flag: str,
) -> tuple[str, str]:
"""
Map biomarker to Human+ optimal status.
Returns:
(status, human_plus_reference_string)
status: 'optimal' | 'warning' | 'danger' | 'neutral'
"""
# Normalisasi: hapus teks dalam kurung, lowercase
# contoh: "hs-CRP (High-sensitivity C-Reactive Protein)" → "hs-crp"
name_lower = re.sub(r"\s*\(.*?\)", "", name.lower()).strip()
# Find matching range config
range_cfg = None
for key, cfg in _OPTIMAL_RANGES.items():
if key == name_lower or key in name_lower or name_lower in key:
range_cfg = cfg
break
# No config found — fallback ke lab_flag
if range_cfg is None or value is None:
if lab_flag in ("normal",):
return "neutral", ""
elif lab_flag in ("high", "low"):
return "warning", ""
elif lab_flag in ("critical_high", "critical_low"):
return "danger", ""
return "neutral", ""
# Check optimal range
optimal = range_cfg.get("optimal")
warning = range_cfg.get("warning")
if optimal:
lo, hi = optimal
in_optimal = (
(lo is None or value >= lo) and
(hi is None or value <= hi)
)
if in_optimal:
ref_str = _format_range_str(lo, hi)
return "optimal", ref_str
if warning:
lo, hi = warning
in_warning = (
(lo is None or value >= lo) and
(hi is None or value <= hi)
)
if in_warning:
opt_lo, opt_hi = (optimal or (None, None))
ref_str = _format_range_str(opt_lo, opt_hi)
return "warning", ref_str
# Below all ranges or above all ranges → danger
opt_lo, opt_hi = (optimal or (None, None))
ref_str = _format_range_str(opt_lo, opt_hi)
return "danger", ref_str
def _format_range_str(lo: Optional[float], hi: Optional[float]) -> str:
"""Format optimal range sebagai human-readable string."""
if lo is not None and hi is not None:
return f"{lo}{hi}"
elif lo is not None:
return f"> {lo}"
elif hi is not None:
return f"< {hi}"
return ""
def _sort_by_priority(biomarkers: list[dict]) -> list[dict]:
"""Sort: danger first, then warning, then optimal, then neutral."""
priority = {"danger": 0, "warning": 1, "optimal": 2, "neutral": 3}
return sorted(biomarkers, key=lambda b: priority.get(b["status"], 3))