"""Build a step-by-step trace of the analysis pipeline for the agent trace panel.""" from __future__ import annotations import html import json from dataclasses import dataclass, field from pathlib import Path from typing import Any from src.interpretation import Interpretation, build_interpretation from src.openbmb_client import EXTRACTION_PROMPT, ExtractionResult _MAX_PREVIEW = 2400 _PIPELINE_STEP_DEFS: tuple[tuple[str, str], ...] = ( ("document_intake", "Step 1 — Document intake"), ("vision_extraction", "Step 2 — Vision extraction (LLM)"), ("schema_normalization", "Step 3 — Schema normalization"), ("knowledge_graph", "Step 4 — Knowledge graph enrichment"), ("pattern_detection", "Step 5 — Cross-marker pattern detection"), ) _STEP_COPY: dict[str, dict[str, str]] = { "document_intake": { "explanation": ( "This step gets your upload ready for the AI. It checks the file type, turns PDF " "pages into images when needed, and packages the report so the vision model can read it." ), "pending": "Waiting for you to upload a lab report.", "running": "Reading your file and preparing it for the vision model.", }, "vision_extraction": { "explanation": ( "This step is where the AI actually reads your lab report. The vision model scans " "tables, values, units, and labels, then turns what it sees into structured lab data." ), "pending": "The vision model has not run yet.", "running": "The vision model is reading your report and extracting lab values.", }, "schema_normalization": { "explanation": ( "Raw model output can be messy. This step cleans it up into a consistent list of " "markers, values, units, and patient details the rest of the app can trust." ), "pending": "Structured marker parsing has not started yet.", "running": "Turning the model output into clean, structured lab values.", }, "knowledge_graph": { "explanation": ( "Numbers alone are hard to interpret. This step matches each marker to our knowledge " "graph so the report can explain what a result generally means in plain language." ), "pending": "Knowledge graph enrichment has not started yet.", "running": "Matching extracted markers to educational explanations.", }, "pattern_detection": { "explanation": ( "Single markers tell part of the story. This step looks across your results for " "related flags and patterns that may deserve extra attention together." ), "pending": "Cross-marker pattern checks have not started yet.", "running": "Checking how markers relate to each other across your report.", }, } @dataclass(frozen=True) class PipelineStep: id: str title: str status: str summary: str return_code: int | None = 0 technical_details: str | None = None prompt: str | None = None input_preview: str | None = None output_preview: str | None = None metadata: dict[str, Any] = field(default_factory=dict) def _step_copy(step_id: str, phase: str = "complete") -> str: copy = _STEP_COPY[step_id] if phase == "pending": return copy["pending"] if phase == "running": return copy["running"] return copy["explanation"] def _summary_with_result(explanation: str, result_note: str | None) -> str: if not result_note: return explanation return f"{explanation}\n\nIn this run: {result_note}" def _truncate(text: str | None, limit: int = _MAX_PREVIEW) -> str | None: if not text: return None cleaned = text.strip() if len(cleaned) <= limit: return cleaned return cleaned[: limit - 3].rstrip() + "..." def _marker_preview(tests: list[dict[str, Any]], limit: int = 3) -> str: lines: list[str] = [] for test in tests[:limit]: marker = test.get("marker", "?") value = test.get("value", "?") unit = test.get("unit") or "" status = test.get("status") or "unknown" lines.append(f"- {marker}: {value} {unit} ({status})".strip()) if len(tests) > limit: lines.append(f"- … and {len(tests) - limit} more") return "\n".join(lines) def build_pipeline_trace( extraction: ExtractionResult, health_report: dict[str, Any], *, source_path: str | None = None, ) -> list[PipelineStep]: summary = extraction.request_summary or {} patient = health_report.get("patient") or extraction.patient or {} report_summary = health_report.get("summary") or {} interpretation = build_interpretation(extraction.tests) backend = summary.get("backend") or summary.get("api_url") or "unknown" file_name = Path(source_path).name if source_path else None runtime_return_code = summary.get("return_code", 0) intake_lines = [ f"Backend: {backend}", f"Input modality: {summary.get('input_modality', 'unknown')}", f"Document parts: {summary.get('document_parts', '?')}", ] if summary.get("pages_rendered") is not None: intake_lines.append(f"Pages rendered to images: {summary.get('pages_rendered')}") if summary.get("max_pages") is not None: intake_lines.append(f"Max pages: {summary.get('max_pages')}") if file_name: intake_lines.append(f"File: {file_name}") preview = summary.get("user_message_preview") or {} if preview: intake_lines.append( f"Payload preview: {preview.get('image_count', 0)} image(s), " f"{preview.get('text_characters', 0)} text character(s)" ) intake_result_parts: list[str] = [] if file_name: intake_result_parts.append(f"we prepared “{file_name}”") modality = summary.get("input_modality") if modality: intake_result_parts.append(f"the input was treated as a {modality} document") pages_rendered = summary.get("pages_rendered") if pages_rendered is not None: intake_result_parts.append( f"{pages_rendered} page(s) were sent to the model as image(s)" ) elif preview.get("image_count"): intake_result_parts.append( f"{preview.get('image_count')} image(s) were included in the model payload" ) intake_result = ", and ".join(intake_result_parts) + "." if intake_result_parts else None model_name = summary.get("model") or summary.get("repo") or backend extraction_result_parts = [f"the {model_name} model extracted structured lab data from your report"] if summary.get("duration_ms"): seconds = max(1, int(summary["duration_ms"]) // 1000) extraction_result_parts.append(f"in about {seconds} second(s)") extraction_result = ", ".join(extraction_result_parts) + "." normalization_result = ( f"we parsed {len(extraction.tests)} marker(s) and {len(extraction.notes)} note(s), " f"with patient context recorded as {patient.get('sex', 'unknown')} / " f"{patient.get('age_group', 'unknown')} when available" ) + "." enriched = report_summary.get("enriched_markers", 0) total = report_summary.get("total_markers", 0) unmatched = len(report_summary.get("unmatched_markers") or []) knowledge_result = ( f"{enriched} of {total} marker(s) were matched to knowledge-base explanations" + (f" and {unmatched} marker(s) had no close match" if unmatched else "") ) + "." flagged = len(interpretation.flagged) patterns = len(interpretation.patterns) pattern_result = ( f"we flagged {flagged} marker(s) for attention and found {patterns} cross-marker pattern(s), " f"with {interpretation.normal_count} marker(s) recognized as in-range" ) + "." steps: list[PipelineStep] = [ PipelineStep( id="document_intake", title="Step 1 — Document intake", status="complete", return_code=0, summary=_summary_with_result(_step_copy("document_intake"), intake_result), technical_details="\n".join(intake_lines), input_preview=file_name, metadata={ "backend": backend, "document_parts": summary.get("document_parts"), "max_pages": summary.get("max_pages"), "file": file_name, **preview, }, ), PipelineStep( id="vision_extraction", title="Step 2 — Vision extraction (LLM)", status="complete", return_code=runtime_return_code, summary=_summary_with_result(_step_copy("vision_extraction"), extraction_result), technical_details=( f"Model/backend: {model_name}\n" f"HTTP status: {summary.get('http_status', '—')}\n" f"Duration (ms): {summary.get('duration_ms', '—')}\n" f"Document parts: {summary.get('document_parts', '?')}" ), prompt=summary.get("extraction_prompt") or EXTRACTION_PROMPT, input_preview=_stringify_preview( summary.get("composed_prompt") or summary.get("messages_preview") ), output_preview=_truncate(extraction.raw_response), metadata={ "backend": backend, "model": summary.get("model") or summary.get("repo"), "api_url": summary.get("api_url") or summary.get("url"), "http_status": summary.get("http_status"), "duration_ms": summary.get("duration_ms"), "return_code": runtime_return_code, "document_parts": summary.get("document_parts"), }, ), PipelineStep( id="schema_normalization", title="Step 3 — Schema normalization", status="complete", return_code=0, summary=_summary_with_result(_step_copy("schema_normalization"), normalization_result), technical_details=( f"Markers parsed: {len(extraction.tests)}\n" f"Notes parsed: {len(extraction.notes)}\n" f"Patient sex: {patient.get('sex', 'unknown')}\n" f"Patient age group: {patient.get('age_group', 'unknown')}" ), output_preview=_marker_preview(extraction.tests), metadata={ "markers_parsed": len(extraction.tests), "notes_parsed": len(extraction.notes), "patient_sex": patient.get("sex", "unknown"), "patient_age_group": patient.get("age_group", "unknown"), "notes": extraction.notes[:5], }, ), PipelineStep( id="knowledge_graph", title="Step 4 — Knowledge graph enrichment", status="complete", return_code=0, summary=_summary_with_result(_step_copy("knowledge_graph"), knowledge_result), technical_details=( f"Enriched markers: {enriched}\n" f"Total markers: {total}\n" f"Unmatched markers: {unmatched}" ), output_preview=_truncate(json.dumps(report_summary, indent=2)), metadata={ "enriched_markers": enriched, "total_markers": total, "unmatched_markers": report_summary.get("unmatched_markers") or [], }, ), PipelineStep( id="pattern_detection", title="Step 5 — Cross-marker pattern detection", status="complete", return_code=0, summary=_summary_with_result(_step_copy("pattern_detection"), pattern_result), technical_details=_pattern_summary(interpretation), output_preview=_pattern_output(interpretation), metadata={ "flagged_markers": flagged, "patterns_detected": patterns, "normal_count": interpretation.normal_count, }, ), ] return steps def _stringify_preview(value: Any) -> str | None: if value is None: return None if isinstance(value, str): return _truncate(value) return _truncate(json.dumps(value, indent=2)) def _pattern_summary(interpretation: Interpretation) -> str: flagged = len(interpretation.flagged) patterns = len(interpretation.patterns) return ( f"Flagged markers: {flagged}. " f"Cross-marker patterns detected: {patterns}. " f"In-range recognized markers: {interpretation.normal_count}." ) def _pattern_output(interpretation: Interpretation) -> str | None: if not interpretation.patterns and not interpretation.flagged: return "No flagged markers or cross-marker patterns." lines: list[str] = [] for insight in interpretation.flagged[:6]: note = insight.note or "(no KB note)" lines.append(f"- {insight.marker} ({insight.status}): {note}") if len(interpretation.flagged) > 6: lines.append(f"- … and {len(interpretation.flagged) - 6} more flagged marker(s)") for pattern in interpretation.patterns: lines.append(f"- Pattern — {pattern.name}: {pattern.note}") return "\n".join(lines) def _format_return_code(code: int | None) -> str: if code is None: return "—" return str(code) def _format_meta_value(value: Any) -> str: if value is None: return "—" if isinstance(value, (dict, list)): return json.dumps(value, indent=2) if isinstance(value, float): return f"{value:.2f}" return str(value) def _metrics_table(step: PipelineStep) -> str: rows: list[tuple[str, str]] = [ ("Status", step.status.replace("_", " ").title()), ("Return code", _format_return_code(step.return_code)), ] skip_keys = {"notes", "unmatched_markers"} for key, value in step.metadata.items(): if key in skip_keys or value in (None, "", [], {}): continue label = key.replace("_", " ").title() rows.append((label, _format_meta_value(value))) cells = "".join( f"
{html.escape(label)}
{html.escape(value)}
" for label, value in rows ) return f'
{cells}
' def trace_hover_js() -> str: """Frontend JS for Gradio launch(); inline scripts in gr.HTML are stripped.""" return """ (function () { if (window.__bteTraceHoverInit) return; window.__bteTraceHoverInit = true; var OPEN_DELAY_MS = 180; var CLOSE_DELAY_MS = 140; var openTimers = new WeakMap(); var closeTimers = new WeakMap(); function isInteractiveStep(step) { if (!step || !step.classList.contains("bte-trace-step")) return false; if (step.classList.contains("bte-trace-step--locked")) return false; var panel = step.closest(".bte-trace-panel"); return panel && panel.dataset.interactive === "true"; } function clearOpenTimer(step) { var timer = openTimers.get(step); if (timer) { clearTimeout(timer); openTimers.delete(step); } } function clearCloseTimer(step) { var timer = closeTimers.get(step); if (timer) { clearTimeout(timer); closeTimers.delete(step); } } function openStep(step) { clearCloseTimer(step); step.classList.add("is-open"); } function closeStep(step) { clearOpenTimer(step); step.classList.remove("is-open"); } function scheduleOpen(step) { clearCloseTimer(step); if (step.classList.contains("is-open")) return; clearOpenTimer(step); openTimers.set( step, window.setTimeout(function () { openTimers.delete(step); openStep(step); }, OPEN_DELAY_MS) ); } function scheduleClose(step) { if (step.dataset.pinned === "1") return; clearOpenTimer(step); clearCloseTimer(step); closeTimers.set( step, window.setTimeout(function () { closeTimers.delete(step); closeStep(step); }, CLOSE_DELAY_MS) ); } document.addEventListener("mouseover", function (event) { var step = event.target.closest(".bte-trace-step"); if (!isInteractiveStep(step)) return; scheduleOpen(step); }); document.addEventListener("mouseout", function (event) { var step = event.target.closest(".bte-trace-step"); if (!isInteractiveStep(step)) return; if (step.contains(event.relatedTarget)) return; scheduleClose(step); }); document.addEventListener("click", function (event) { var summary = event.target.closest(".bte-trace-step-summary"); if (!summary) return; var step = summary.closest(".bte-trace-step"); if (!isInteractiveStep(step)) return; event.preventDefault(); if (step.dataset.pinned === "1") { step.dataset.pinned = "0"; closeStep(step); return; } step.dataset.pinned = "1"; openStep(step); }); })(); """ def _trace_block(body: str, *, interactive: bool = True) -> str: panel_class = "bte-trace-panel" if interactive else "bte-trace-panel bte-trace-panel--locked" interactive_attr = "true" if interactive else "false" return f"""
{body}
""" def _technical_details_block(step: PipelineStep) -> str | None: sections: list[str] = [] metrics = _metrics_table(step) if metrics: sections.append(metrics) if step.technical_details: sections.append( f'
{html.escape(step.technical_details)}
' ) if not sections: return None return ( '
' "Technical details" f'
{"".join(sections)}
' "
" ) def _render_summary_html(summary: str) -> str: parts = summary.split("\n\n", 1) chunks = [f'

{html.escape(parts[0])}

'] if len(parts) > 1: chunks.append(f'

{html.escape(parts[1])}

') return "".join(chunks) def _step_body_sections(step: PipelineStep) -> str: sections: list[str] = [_render_summary_html(step.summary)] technical = _technical_details_block(step) if technical: sections.append(technical) if step.prompt: sections.append( '
' "Full prompt" f"
{html.escape(step.prompt)}
" "
" ) if step.input_preview: sections.append( '
' "Input preview" f"
{html.escape(step.input_preview)}
" "
" ) if step.output_preview: sections.append( '
' "Output preview" f"
{html.escape(step.output_preview)}
" "
" ) return "".join(sections) def step_to_html(step: PipelineStep, *, interactive: bool = True) -> str: title_html = f'{html.escape(step.title)}' if not interactive: return f"""
{title_html}
""" return f"""
{title_html}
{_step_body_sections(step)}
""" def _placeholder_pipeline_steps( *, status: str, return_code: int | None = None, pipeline_phase: str, ) -> list[PipelineStep]: phase = "pending" if status == "pending" else "running" if status == "running" else "complete" return [ PipelineStep( id=step_id, title=title, status=status, return_code=return_code, summary=_step_copy(step_id, phase), metadata={"pipeline_phase": pipeline_phase}, ) for step_id, title in _PIPELINE_STEP_DEFS ] def trace_to_html(steps: list[PipelineStep], *, interactive: bool = True) -> str: body = "".join(step_to_html(step, interactive=interactive) for step in steps) return _trace_block(body, interactive=interactive) def empty_trace_html() -> str: steps = _placeholder_pipeline_steps( status="pending", return_code=None, pipeline_phase="ready", ) return trace_to_html(steps, interactive=False) def processing_trace_html() -> str: steps = _placeholder_pipeline_steps( status="running", return_code=None, pipeline_phase="processing", ) return trace_to_html(steps, interactive=False) def error_trace_html(message: str) -> str: failed_step = PipelineStep( id="vision_extraction", title="Step 2 — Vision extraction (LLM)", status="failed", return_code=1, summary=( f"{_step_copy('vision_extraction')}\n\n" f"In this run: the vision model could not finish reading your report. {message}" ), technical_details=message, metadata={"pipeline_phase": "failed"}, ) body = step_to_html(failed_step, interactive=False) return _trace_block(body, interactive=False)