"""Extraction-to-health-report pipeline. This module keeps the agentic part focused on reading the document. Everything after that is deterministic: marker resolution, age/sex reference selection, status comparison, and shaping the object consumed by the UI. """ from __future__ import annotations import re from typing import Any from src.knowledge_graph import LabKnowledgeGraph, default_knowledge_graph from src.openbmb_client import ExtractionResult AGE_GROUPS = ("child", "teenager", "adult", "elder") KNOWN_STATUSES = {"low", "normal", "high", "abnormal", "unknown"} def build_health_report( extraction: ExtractionResult, knowledge_graph: LabKnowledgeGraph | None = None, ) -> dict[str, Any]: """Merge extracted lab values with knowledge-graph context for rendering.""" graph = knowledge_graph or default_knowledge_graph() patient = normalize_patient(getattr(extraction, "patient", {})) markers = [ enrich_marker(test, patient=patient, knowledge_graph=graph) for test in extraction.tests ] status_counts = _status_counts(markers) enriched_count = sum(1 for marker in markers if marker.get("knowledge") is not None) unmatched = [ marker["raw_name"] for marker in markers if marker.get("knowledge") is None ] return { "patient": patient, "markers": markers, "notes": list(extraction.notes), "summary": { "total_markers": len(markers), "enriched_markers": enriched_count, "unmatched_markers": unmatched, "status_counts": status_counts, "needs_review": ( status_counts.get("high", 0) + status_counts.get("low", 0) + status_counts.get("abnormal", 0) ), }, "knowledge_graph": { "schema_version": graph.payload.get("schema_version"), "title": graph.payload.get("title"), "medical_disclaimer": graph.payload.get("medical_disclaimer"), "sex_significance_policy": graph.payload.get("sex_significance_policy"), "sources": graph.payload.get("sources", {}), }, "request_summary": extraction.request_summary, "raw_response": extraction.raw_response, } def enrich_marker( extracted: dict[str, Any], patient: dict[str, Any], knowledge_graph: LabKnowledgeGraph, ) -> dict[str, Any]: raw_name = _text(extracted.get("marker"), "Unknown marker") node = knowledge_graph.resolve(raw_name) numeric_value = parse_numeric_value(extracted.get("value")) extracted_status = normalize_status(extracted.get("status")) lab_interval = parse_reference_interval(extracted.get("reference_range")) kg_selection = ( knowledge_graph.select_statistics(node, patient["age_group"], patient["sex"]) if node is not None else None ) kg_interval = _interval_from_statistics(kg_selection) comparison_interval = lab_interval or kg_interval reference_basis = "lab_reference_range" if lab_interval else "knowledge_graph" derived_status = status_from_interval(numeric_value, comparison_interval) final_status = extracted_status if extracted_status != "unknown" else (derived_status or "unknown") return { "raw_name": raw_name, "canonical_id": node.get("id") if node else None, "display_name": node.get("display_name") if node else raw_name, "value": _text(extracted.get("value"), "-"), "numeric_value": numeric_value, "unit": _text(extracted.get("unit"), node.get("unit", "") if node else ""), "lab_reference_range": _optional_text(extracted.get("reference_range")), "status": final_status, "extracted_status": extracted_status, "derived_status": derived_status or "unknown", "confidence": _confidence(extracted.get("confidence")), "source_text": _optional_text(extracted.get("source_text")), "comparison": { "basis": reference_basis, "interval": comparison_interval, "range_position": range_position(numeric_value, comparison_interval), }, "reference_selection": kg_selection, "knowledge": _knowledge_payload(node), } def normalize_patient(value: Any) -> dict[str, Any]: source = value if isinstance(value, dict) else {} raw_age = ( source.get("age") or source.get("age_text") or source.get("age_years") or source.get("patient_age") ) age_years = parse_age_years(source.get("age_years")) if age_years is None: age_years = parse_age_years(raw_age) sex = normalize_sex(source.get("sex") or source.get("patient_sex") or source.get("gender")) return { "age": _optional_text(raw_age), "age_years": age_years, "age_group": age_group_for(age_years), "sex": sex, "raw": source, } def parse_age_years(value: Any) -> float | None: if value is None: return None if isinstance(value, (int, float)): return float(value) if value >= 0 else None text = str(value).strip().casefold() if not text: return None # Common report format: "25y 10m 26d". years = _first_number_before(text, ("y", "yr", "yrs", "year", "years")) months = _first_number_before(text, ("mo", "mos", "month", "months", "m")) days = _first_number_before(text, ("d", "day", "days")) if years is not None or months is not None or days is not None: return round((years or 0.0) + (months or 0.0) / 12 + (days or 0.0) / 365.25, 2) match = re.search(r"\d+(?:\.\d+)?", text) if match: parsed = float(match.group(0)) return parsed if parsed >= 0 else None return None def normalize_sex(value: Any) -> str: if value is None: return "unknown" text = str(value).strip().casefold() if text in {"m", "male", "man", "boy"}: return "male" if text in {"f", "female", "woman", "girl"}: return "female" return "unknown" def age_group_for(age_years: float | None) -> str: if age_years is None: return "adult" if age_years < 13: return "child" if age_years < 18: return "teenager" if age_years < 65: return "adult" return "elder" def parse_numeric_value(value: Any) -> float | None: if value is None: return None if isinstance(value, (int, float)): return float(value) match = re.search(r"-?\d+(?:,\d{3})*(?:\.\d+)?", str(value)) if not match: return None try: return float(match.group(0).replace(",", "")) except ValueError: return None def parse_reference_interval(value: Any) -> dict[str, float | None] | None: text = _optional_text(value) if not text: return None cleaned = text.casefold().replace("–", "-").replace("—", "-") numbers = [float(match.replace(",", "")) for match in re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", cleaned)] if len(numbers) >= 2 and re.search(r"\d\s*-\s*\d", cleaned): low, high = numbers[0], numbers[1] return {"low": min(low, high), "high": max(low, high)} if numbers and re.search(r"(up to|less than|<|<=|≤|below)", cleaned): return {"low": None, "high": numbers[0]} if numbers and re.search(r"(greater than|>|>=|≥|above|at least)", cleaned): return {"low": numbers[0], "high": None} return None def status_from_interval( value: float | None, interval: dict[str, float | None] | None, ) -> str | None: if value is None or not interval: return None low = interval.get("low") high = interval.get("high") if low is not None and value < low: return "low" if high is not None and value > high: return "high" return "normal" def range_position( value: float | None, interval: dict[str, float | None] | None, ) -> int: if value is None or not interval: return 50 low = interval.get("low") high = interval.get("high") if low is not None and high is not None and high > low: return _clamp_percent((value - low) / (high - low) * 100) if high is not None and high > 0: return _clamp_percent(value / high * 100) if low is not None and low > 0: return _clamp_percent(value / low * 100) return 50 def normalize_status(value: Any) -> str: status = str(value or "unknown").strip().casefold() if status in {"l", "lo"}: return "low" if status in {"h", "hi"}: return "high" if status in {"ok", "within range", "in range"}: return "normal" return status if status in KNOWN_STATUSES else "unknown" def _knowledge_payload(node: dict[str, Any] | None) -> dict[str, Any] | None: if node is None: return None return { "description": node.get("description"), "why_important": node.get("why_important"), "instructions_to_improve": node.get("instructions_to_improve") or {}, "video_url": node.get("video_url"), "sex_significance": node.get("sex_significance") or {}, "related_tests": node.get("related_tests") or [], "source_ids": node.get("source_ids") or [], "category": node.get("category"), "unit": node.get("unit"), } def _interval_from_statistics(selection: dict[str, Any] | None) -> dict[str, float | None] | None: if not selection: return None values = selection.get("values") or {} low = values.get("minimal_value") high = values.get("maximum_value") if low is None and high is None: return None return {"low": float(low) if low is not None else None, "high": float(high) if high is not None else None} def _first_number_before(text: str, suffixes: tuple[str, ...]) -> float | None: suffix_pattern = "|".join(re.escape(suffix) for suffix in suffixes) match = re.search(rf"(\d+(?:\.\d+)?)\s*(?:{suffix_pattern})\b", text) return float(match.group(1)) if match else None def _status_counts(markers: list[dict[str, Any]]) -> dict[str, int]: counts = {status: 0 for status in sorted(KNOWN_STATUSES)} for marker in markers: status = normalize_status(marker.get("status")) counts[status] = counts.get(status, 0) + 1 return counts def _text(value: Any, fallback: str) -> str: text = _optional_text(value) return text if text is not None else fallback def _optional_text(value: Any) -> str | None: if value is None: return None text = str(value).strip() return text or None def _confidence(value: Any) -> float: try: score = float(value) except (TypeError, ValueError): return 0.0 return max(0.0, min(1.0, score)) def _clamp_percent(value: float) -> int: return max(0, min(100, round(value)))