| """ |
| app.py β CXR Agent as a Hugging Face *Docker* Space (Chainlit chat UI) |
| ============================================================================= |
| Chainlit chat frontend for the chest-X-ray agent, mirroring the OCT-B Space. |
| The user attaches a CXR to a message and asks for a read; follow-up questions |
| reuse the same loaded image and any localization / visualization already |
| produced. |
| |
| Dashboard behaviour |
| ------------------- |
| When a turn produces visualizations, each one becomes a **selector button** on |
| that turn's reply, and the most recent is rendered in a **right-hand panel** |
| (Chainlit's ``ElementSidebar``). Clicking any selector β including ones from |
| earlier turns β swaps the panel to that visualization, keeping a "read on the |
| left, picture on the right" dashboard feel inside the chat. The newest |
| visualization opens automatically after the model's first turn. |
| |
| Voice |
| ----- |
| When the agent calls ``speak_findings`` (ElevenLabs TTS), the resulting MP3 is |
| surfaced inline as a playable audio element on the reply. A session switch nudges |
| the agent to narrate the impression; the user can also just ask for audio. |
| |
| Agent |
| ----- |
| The model is set by ``CXR_MODEL`` (default ``gpt-5.4-mini``). The backend decides |
| the visualization path: |
| |
| * **OpenAI Responses API** (e.g. ``gpt-5.4-mini``): the model-driven |
| ``SandboxAgent`` with full sandbox capabilities (shell + filesystem incl. |
| ``apply_patch``). A live ``UnixLocalSandboxClient`` session is provisioned per |
| chat and injected via ``RunConfig``; the agent can author its own visualization |
| scripts and run them. |
| * **Chat Completions API** (e.g. ``anthropic/claude-opus-4-6`` via LiteLLM): the |
| base agent plus the on-demand ``render_finding_overlay`` tool, which drives a |
| sandbox from host code (the model gets no hosted tools, which that API rejects). |
| |
| Reasoning is dispatched to NV-Reason-CXR-3B via a streamable-HTTP MCP server; |
| findings are localized via LocateAnything-3B. Each user turn runs one agent turn, |
| collects whatever overlay HTML was produced, and exposes it via selector buttons + |
| the right-hand panel (plus a downloadable file). |
| |
| Repo layout expected (this file at the Space root): |
| Dockerfile |
| app.py <- this file |
| requirements.txt |
| README.md <- carries the HF Docker Space header (sdk: docker) |
| chainlit.md <- Chainlit splash/readme (optional) |
| .chainlit/config.toml <- Chainlit config (enables image attachments) |
| public/elements/OverlayFrame.jsx |
| cxr_agents/ tools/ skills/ models/ sandbox/ <- the unchanged project |
| |
| Secrets (Space -> Settings -> Secrets): |
| OPENAI_API_KEY required when CXR_MODEL is an OpenAI model (the default) |
| ANTHROPIC_API_KEY required when CXR_MODEL is an anthropic/... model |
| ELEVENLABS_API_KEY optional (voice; free key is fine) |
| |
| Variables (Space -> Settings -> Variables; all optional, sensible defaults): |
| CXR_MODEL main agent model (default gpt-5.4-mini). A bare OpenAI |
| name uses the Responses API (full sandbox, apply_patch); |
| a provider-prefixed name like anthropic/claude-opus-4-6 |
| uses LiteLLM / Chat Completions (on-demand overlays). |
| CXR_REASON_MCP_URL reasoning MCP URL (used by tools/reason.py, per call) |
| CXR_LOCALIZE_MCP_URL localization MCP URL (connected per call in tools/localize.py) |
| ELEVENLABS_VOICE_ID default voice (name or raw ID) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import base64 |
| import csv |
| import hashlib |
| import ipaddress |
| import json |
| import mimetypes |
| import os |
| import re |
| import shutil |
| import socket |
| import sys |
| import time |
| import urllib.error |
| import urllib.request |
| import uuid |
| from pathlib import Path |
| from urllib.parse import urlparse |
|
|
| |
| |
| |
| |
| ROOT = Path(__file__).resolve().parent |
| if str(ROOT) not in sys.path: |
| sys.path.insert(0, str(ROOT)) |
|
|
| import chainlit as cl |
| from chainlit.input_widget import Switch |
|
|
| from agents import Runner |
| from agents.run import RunConfig |
| from agents.sandbox import SandboxRunConfig |
| from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient |
|
|
| from cxr_agents.cxr_orchestrator.orchestrator_agent import ( |
| build_orchestrator, |
| build_orchestrator_sandbox, |
| resolve_model, |
| DEFAULT_MODEL, |
| ) |
| from tools import ( |
| CXRContext, |
| SandboxManager, |
| build_overlay_manifest, |
| collect_overlay_outputs, |
| ) |
|
|
| |
| OVERLAY_SKILL_DIR = ROOT / "sandbox" / "cxr-overlay" |
|
|
| |
| OUT_ROOT = Path(os.environ.get("CXR_OUT_DIR", "/tmp/cxr_out")) |
| OUT_ROOT.mkdir(parents=True, exist_ok=True) |
|
|
| |
| VIEW_VIZ_ACTION = "view_viz" |
|
|
| |
| |
| |
| |
| ARM_EXAMPLE_ACTION = "arm_example" |
|
|
| |
| CLEAR_EXAMPLE_ACTION = "clear_example" |
|
|
| |
| |
| EXAMPLES_DIR = ROOT / "examples" |
| EXAMPLES_CSV = EXAMPLES_DIR / "examples.csv" |
|
|
| |
| MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024 |
|
|
|
|
| INTRO = ( |
| "### CXR Agent β chest X-ray interpretation\n\n" |
| "Attach a **chest X-ray** image to your message and ask for a read " |
| "(e.g. *βInterpret this chest X-rayβ* or *βshow me where the findings areβ*). " |
| "You can also **paste a direct image URL** instead of attaching a file, or " |
| "**pick a case from the example gallery** below. " |
| "Follow-up questions reuse the same image and any findings already produced β " |
| "no need to re-attach.\n\n" |
| "Any visualization the agent produces shows up as a button on its reply and " |
| "opens in the panel on the right; use the buttons to switch between them. If " |
| "you turn on the voice option (or ask for it), a spoken summary plays inline.\n\n" |
| "_Decision support only β not a diagnostic device. Always confirm with a " |
| "qualified radiologist._" |
| ) |
|
|
|
|
| |
|
|
| async def _show_in_sidebar(viz: dict, vid: str) -> None: |
| """Render one visualization in the right-hand ElementSidebar panel.""" |
| await cl.ElementSidebar.set_title(viz["title"]) |
| await cl.ElementSidebar.set_elements( |
| [cl.CustomElement( |
| name="OverlayFrame", |
| props={"html": viz["html"], "title": viz["title"]}, |
| display="inline", |
| )], |
| key=f"viz-{vid}", |
| ) |
|
|
|
|
| @cl.action_callback(VIEW_VIZ_ACTION) |
| async def on_view_viz(action: cl.Action): |
| """A selector button was clicked β swap the right-hand panel to that viz.""" |
| registry: dict = cl.user_session.get("viz_registry") or {} |
| vid = (action.payload or {}).get("viz_id") |
| viz = registry.get(vid) |
| if not viz: |
| await cl.ElementSidebar.set_title("Visualization unavailable") |
| await cl.ElementSidebar.set_elements( |
| [cl.Text(content="This visualization is no longer available in this session.")] |
| ) |
| return |
| await _show_in_sidebar(viz, vid) |
|
|
|
|
| |
|
|
| def _load_examples() -> list[dict]: |
| """Read examples/examples.csv once and cache it. |
| |
| Each row becomes a dict with an absolute ``image_path`` resolved against the |
| Space root. Rows whose image is missing are still returned (the caller filters |
| them) so a half-filled CSV degrades gracefully. |
| """ |
| cached = getattr(_load_examples, "_cache", None) |
| if cached is not None: |
| return cached |
|
|
| rows: list[dict] = [] |
| if EXAMPLES_CSV.exists(): |
| with EXAMPLES_CSV.open(newline="", encoding="utf-8") as fh: |
| for row in csv.DictReader(fh): |
| rel = (row.get("image") or "").strip() |
| rows.append({ |
| "id": (row.get("id") or "").strip(), |
| "title": (row.get("title") or row.get("id") or "Example").strip(), |
| "prompt": (row.get("prompt") |
| or "Interpret this chest X-ray and describe any findings.").strip(), |
| "image": rel, |
| "image_path": str((ROOT / rel).resolve()) if rel else "", |
| "source": (row.get("source") or "").strip(), |
| }) |
| _load_examples._cache = rows |
| return rows |
|
|
|
|
| def _gallery_items() -> list[dict]: |
| """Examples that have a real image, each with a base64 data-URI thumbnail. |
| |
| Thumbnails are inlined (rather than served) so the gallery renders without |
| depending on Chainlit's static-file paths; the three demo images are small. |
| """ |
| items: list[dict] = [] |
| for e in _load_examples(): |
| p = Path(e["image_path"]) |
| if not (e["image_path"] and p.exists()): |
| continue |
| mime = mimetypes.guess_type(p.name)[0] or "image/jpeg" |
| b64 = base64.b64encode(p.read_bytes()).decode("ascii") |
| items.append({"id": e["id"], "title": e["title"], |
| "prompt": e["prompt"], |
| "src": f"data:{mime};base64,{b64}"}) |
| return items |
|
|
|
|
| _URL_RE = re.compile(r"https?://[^\s<>\"')]+", re.IGNORECASE) |
| _CTYPE_EXT = { |
| "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/png": ".png", |
| "image/webp": ".webp", "image/gif": ".gif", "image/bmp": ".bmp", |
| "image/tiff": ".tiff", |
| } |
| _IMG_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff"} |
|
|
|
|
| def _first_url(text: str) -> str | None: |
| m = _URL_RE.search(text or "") |
| return m.group(0) if m else None |
|
|
|
|
| def _host_is_public(host: str | None) -> bool: |
| """True only if every resolved address for ``host`` is a public IP. |
| |
| Blocks loopback/private/link-local/reserved targets so a pasted URL can't be |
| used to probe the container's own metadata or internal network (SSRF). |
| """ |
| if not host: |
| return False |
| try: |
| infos = socket.getaddrinfo(host, None) |
| except socket.gaierror: |
| return False |
| for info in infos: |
| try: |
| addr = ipaddress.ip_address(info[4][0]) |
| except ValueError: |
| return False |
| if (addr.is_private or addr.is_loopback or addr.is_link_local |
| or addr.is_reserved or addr.is_multicast or addr.is_unspecified): |
| return False |
| return True |
|
|
|
|
| def _ext_for(ctype: str, url: str) -> str | None: |
| """Pick a sane file extension from Content-Type, falling back to the URL path.""" |
| if ctype in _CTYPE_EXT: |
| return _CTYPE_EXT[ctype] |
| suf = Path(urlparse(url).path).suffix.lower() |
| if suf in _IMG_SUFFIXES: |
| return ".jpg" if suf == ".jpeg" else suf |
| if ctype.startswith("image/"): |
| return ".jpg" |
| return None |
|
|
|
|
| def _download_image(url: str, dest_dir: Path) -> str: |
| """Download a direct image URL to ``dest_dir`` and return the local path. |
| |
| Synchronous (run via ``asyncio.to_thread``). Validates scheme + host, requires |
| an image payload, and enforces ``MAX_DOWNLOAD_BYTES``. |
| """ |
| parsed = urlparse(url) |
| if parsed.scheme not in ("http", "https"): |
| raise ValueError("only http(s) image URLs are supported") |
| if not _host_is_public(parsed.hostname): |
| raise ValueError("refusing to fetch from a non-public address") |
|
|
| req = urllib.request.Request(url, headers={"User-Agent": "cxr-agent/1.0"}) |
| with urllib.request.urlopen(req, timeout=20) as resp: |
| ctype = (resp.headers.get("Content-Type") or "").split(";")[0].strip().lower() |
| ext = _ext_for(ctype, url) |
| if ext is None: |
| raise ValueError( |
| f"that URL doesn't look like a direct image (Content-Type: {ctype or 'unknown'})" |
| ) |
| data = resp.read(MAX_DOWNLOAD_BYTES + 1) |
| if len(data) > MAX_DOWNLOAD_BYTES: |
| raise ValueError("image exceeds the size limit") |
|
|
| dest_dir.mkdir(parents=True, exist_ok=True) |
| out = dest_dir / f"url_{uuid.uuid4().hex[:10]}{ext}" |
| out.write_bytes(data) |
| return str(out) |
|
|
|
|
| async def _resolve_cxr_path(message: cl.Message, out_dir) -> str | None: |
| """Resolve this turn's CXR: an attached image wins, else a pasted image URL.""" |
| path = _first_cxr_path(message) |
| if path: |
| return path |
| url = _first_url(message.content or "") |
| if not url: |
| return None |
| try: |
| return await asyncio.to_thread(_download_image, url, Path(out_dir)) |
| except Exception as e: |
| await cl.Message(content=f"β οΈ Couldn't load image from that URL: {e}").send() |
| return None |
|
|
|
|
| |
|
|
| @cl.on_chat_start |
| async def on_chat_start(): |
| |
| |
| |
| settings = await cl.ChatSettings([ |
| Switch( |
| id="want_visual", |
| label="Produce a visualization (overlay / chart)", |
| initial=True, |
| ), |
| Switch( |
| id="want_voice", |
| label="Speak the impression aloud (ElevenLabs)", |
| initial=True, |
| ), |
| ]).send() |
| cl.user_session.set("want_visual", bool(settings.get("want_visual", True))) |
| cl.user_session.set("want_voice", bool(settings.get("want_voice", True))) |
|
|
| out_dir = OUT_ROOT / uuid.uuid4().hex |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| notes: list[str] = [] |
| async with cl.Step(name="Preparing the CXR agentβ¦", type="tool"): |
| |
| |
| |
| |
| octx = CXRContext(output_dir=str(out_dir)) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _model_obj, supports_hosted = resolve_model() |
| sandbox_session = None |
| run_config = None |
| if supports_hosted: |
| try: |
| sb_client = UnixLocalSandboxClient() |
| sandbox_session = await sb_client.create( |
| manifest=build_overlay_manifest(OVERLAY_SKILL_DIR)) |
| await sandbox_session.apply_manifest() |
| octx.sandbox_session = sandbox_session |
| run_config = RunConfig(sandbox=SandboxRunConfig(session=sandbox_session)) |
| agent = build_orchestrator_sandbox() |
| except Exception as e: |
| notes.append(f"β οΈ Sandbox unavailable β using on-demand overlays ({e}).") |
| octx.sandbox = SandboxManager(OVERLAY_SKILL_DIR) |
| agent = build_orchestrator() |
| else: |
| octx.sandbox = SandboxManager(OVERLAY_SKILL_DIR) |
| agent = build_orchestrator() |
|
|
| cl.user_session.set("octx", octx) |
| cl.user_session.set("agent", agent) |
| cl.user_session.set("run_config", run_config) |
| cl.user_session.set("sandbox_session", sandbox_session) |
| cl.user_session.set("out_dir", out_dir) |
| cl.user_session.set("conversation", None) |
| cl.user_session.set("seen_overlays", {}) |
| cl.user_session.set("started_at", time.time()) |
| cl.user_session.set("pending_example", None) |
| cl.user_session.set("seen_audio", set()) |
| cl.user_session.set("viz_registry", {}) |
|
|
| content = INTRO + (("\n\n" + "\n".join(notes)) if notes else "") |
| await cl.Message(content=content).send() |
|
|
| |
| |
| items = _gallery_items() |
| if items: |
| await cl.Message( |
| content="**Example gallery** β pick a case to prefill its prompt below. " |
| "Edit the prompt if you like, then press send to run.", |
| elements=[cl.CustomElement( |
| name="CXRGallery", props={"examples": items}, display="inline")], |
| ).send() |
|
|
|
|
| @cl.on_settings_update |
| async def on_settings_update(settings): |
| cl.user_session.set("want_visual", bool(settings.get("want_visual", False))) |
| cl.user_session.set("want_voice", bool(settings.get("want_voice", False))) |
|
|
|
|
| |
|
|
| |
| |
| FRIENDLY_TOOL = { |
| "triage_image": "Triaging image", |
| "encode_image": "Encoding image", |
| "reason_cxr": "NV-Reason-CXR-3B reasoning", |
| "analyze_cxr": "NV-Reason-CXR-3B (quick)", |
| "localize_findings": "Localizing findings", |
| "speak_findings": "Synthesizing voice (ElevenLabs)", |
| "render_finding_overlay":"Rendering finding overlay", |
| "stage_overlay": "Staging overlay inputs", |
| "get_skill_body": "Reading skill instructions", |
| "get_skill_metadata": "Reading skill metadata", |
| "get_skill_reference": "Reading skill reference", |
| "get_skill_asset": "Reading skill asset", |
| "get_skill_script": "Reading skill script", |
| "run_skill_script": "Running skill script", |
| |
| "shell": "Running shell command", |
| "apply_patch": "Editing a file", |
| "read_file": "Reading a file", |
| "write_file": "Writing a file", |
| } |
|
|
|
|
| def _compact(value, limit: int = 800) -> str: |
| """Stringify + trim a tool arg/output blob for display inside a Step.""" |
| if value is None: |
| return "" |
| s = value if isinstance(value, str) else json.dumps(value, default=str) |
| s = s.strip() |
| return s if len(s) <= limit else s[:limit] + " β¦" |
|
|
|
|
| def _extract_audio_path(output) -> str | None: |
| """Pull an .mp3 path out of a speak_findings tool result, if present.""" |
| val = output |
| if isinstance(output, str): |
| s = output.strip() |
| if s.startswith("{") or s.startswith("["): |
| try: |
| val = json.loads(s) |
| except Exception: |
| val = s |
| else: |
| val = s |
| if isinstance(val, dict): |
| val = val.get("path") or val.get("audio_path") or val.get("mp3") |
| if isinstance(val, str) and val.strip().lower().endswith(".mp3"): |
| p = val.strip() |
| if Path(p).exists(): |
| return p |
| return None |
|
|
|
|
| def _extract_narration(output) -> str | None: |
| """Pull NV-Reason-CXR-3B's free-text reasoning ('thinking') out of a tool result.""" |
| val = output |
| if isinstance(output, str): |
| s = output.strip() |
| if s.startswith("{") or s.startswith("["): |
| try: |
| val = json.loads(s) |
| except Exception: |
| return s or None |
| else: |
| return s or None |
| if isinstance(val, dict): |
| text = val.get("thinking") or val.get("reasoning") or val.get("narration") |
| text = (text or "").strip() |
| return text or None |
| return None |
|
|
|
|
| |
| |
| _ARTIFACT_HEADER_RE = re.compile( |
| r"^\s*(?:#{1,6}\s*)?\*{0,2}\s*" |
| r"(?:visualizations?|audio|overlays?|files?|outputs?|artifacts?)" |
| r"\s*\*{0,2}\s*:?\s*$", |
| re.IGNORECASE) |
| _ARTIFACT_TOKEN_RE = re.compile( |
| r"(output/\S+\.html|\S+\.mp3|/tmp/\S+|cxr_speech|overlay_\w+\.html" |
| r"|spoken summary (?:generated|saved)|narration saved)", |
| re.IGNORECASE) |
|
|
|
|
| def _declutter_reply(text: str) -> str: |
| """Drop the model's file-path bookkeeping lines; the UI conveys those instead.""" |
| out_lines: list[str] = [] |
| for line in (text or "").splitlines(): |
| if _ARTIFACT_HEADER_RE.match(line): |
| continue |
| if _ARTIFACT_TOKEN_RE.search(line): |
| continue |
| out_lines.append(line) |
| cleaned = "\n".join(out_lines) |
| cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) |
| return cleaned.strip() |
|
|
|
|
| |
| |
| |
| _SECTION_EMOJI = { |
| "findings": "π", "impression": "π", "location": "π", "locations": "π", |
| "note": "π", "notes": "π", "disclaimer": "β οΈ", "quality": "ποΈ", |
| "technical": "ποΈ", "recommendation": "β‘οΈ", "recommendations": "β‘οΈ", |
| "summary": "π§Ύ", "comparison": "π", "limitations": "β οΈ", |
| } |
| _SECTION_LINE_RE = re.compile(r"^\s*(?:#{1,6}\s*)?\*{0,2}\s*([A-Za-z][A-Za-z ]{2,24}?)\*{0,2}\s*:?\s*$") |
|
|
|
|
| def _emojify_sections(text: str) -> str: |
| """Rewrite known section headers as '### <icon> <Section>' for clearer structure.""" |
| out: list[str] = [] |
| for line in (text or "").splitlines(): |
| m = _SECTION_LINE_RE.match(line) |
| if m: |
| word = m.group(1).strip() |
| emoji = _SECTION_EMOJI.get(word.lower()) |
| if emoji: |
| out.append(f"### {emoji} {word.title()}") |
| continue |
| out.append(line) |
| return "\n".join(out) |
|
|
|
|
| def _working_md(activity: str) -> str: |
| """A prominent in-bubble 'agent is working' placeholder shown until the read lands.""" |
| return f"π¬ **Reading the chest X-rayβ¦**\n\nβ³ _{activity}_" |
|
|
|
|
| |
|
|
| def _first_cxr_path(message: cl.Message): |
| """Return the local path of the first image attached to the message, if any.""" |
| for el in (message.elements or []): |
| mime = (getattr(el, "mime", "") or "") |
| if mime.startswith("image") and getattr(el, "path", None): |
| return el.path |
| return None |
|
|
|
|
| def _build_user_text(prompt: str, cxr_path, want_visual: bool, want_voice: bool) -> str: |
| parts = [prompt or "Interpret this chest X-ray and produce a structured read."] |
| if cxr_path: |
| |
| |
| parts.append(f"\n\nChest X-ray source: {cxr_path}") |
| if want_visual: |
| parts.append( |
| "\n\nThe user would also like a visualization (the standard finding " |
| "overlay, or a more apt chart if the findings call for one)." |
| ) |
| if want_voice: |
| parts.append( |
| "\n\nAlso narrate the impression: call speak_findings on a concise " |
| "spoken summary of the key findings." |
| ) |
| return "".join(parts) |
|
|
|
|
| _MISSING = object() |
|
|
| |
| |
| |
| _APP_OUTPUT_DIR = ROOT / "output" |
|
|
|
|
| async def _collect_new_visualizations(octx, sandbox_session, out_dir, seen) -> list[str]: |
| """HTML visualizations produced *or updated* this turn. |
| |
| Robust to where the overlay actually lands: we (1) still invoke the private |
| collector for its sandboxβhost copy side effect, then (2) independently sweep |
| the per-session out_dir and the app-level ``output/`` dir for ``*.html``, |
| copying any straggler into out_dir so downstream code (which reads |
| ``out_dir / name``) always finds it. Dedup is by content signature |
| (mtime + size) so a rewritten overlay re-surfaces and re-opens the panel. |
| """ |
| out = Path(out_dir) |
| started_at = float(cl.user_session.get("started_at") or 0.0) |
|
|
| |
| collector_names: list[str] = [] |
| try: |
| if sandbox_session is not None: |
| res = await collect_overlay_outputs(sandbox_session, out_dir) |
| collector_names = list(res or []) |
| else: |
| collector_names = list(getattr(octx, "overlays", []) or []) |
| except Exception as e: |
| print(f"[viz] collector error: {e!r}", file=sys.stderr, flush=True) |
|
|
| |
| |
| |
| |
| |
| found: dict[str, tuple[Path, float]] = {} |
|
|
| def _consider(fp: Path) -> None: |
| try: |
| m = fp.stat().st_mtime |
| except OSError: |
| return |
| cur = found.get(fp.name) |
| if cur is None or m > cur[1]: |
| found[fp.name] = (fp, m) |
|
|
| for fp in out.rglob("*.html"): |
| _consider(fp) |
| if _APP_OUTPUT_DIR.is_dir(): |
| for fp in _APP_OUTPUT_DIR.glob("*.html"): |
| try: |
| if fp.stat().st_mtime >= started_at - 1.0: |
| _consider(fp) |
| except OSError: |
| pass |
| for n in collector_names: |
| p = out / n |
| if p.exists(): |
| _consider(p) |
|
|
| |
| names: list[str] = [] |
| for name, (fp, _m) in found.items(): |
| dest = out / name |
| try: |
| if fp.resolve() != dest.resolve(): |
| shutil.copy2(fp, dest) |
| except Exception as e: |
| print(f"[viz] copy {fp} -> {dest} failed: {e!r}", file=sys.stderr, flush=True) |
| continue |
| names.append(name) |
|
|
| |
| new: list[str] = [] |
| for name in names: |
| try: |
| st = (out / name).stat() |
| sig = (st.st_mtime_ns, st.st_size) |
| except OSError: |
| sig = None |
| if seen.get(name, _MISSING) != sig: |
| new.append(name) |
| seen[name] = sig |
|
|
| print(f"[viz] collector={collector_names} swept={list(found)} new={new} " |
| f"out_dir={out}", file=sys.stderr, flush=True) |
| return new |
|
|
|
|
| def _latest_viz_id(registry: dict) -> str | None: |
| """Most recently registered visualization id (dicts preserve insertion order).""" |
| return next(reversed(registry), None) if registry else None |
|
|
|
|
| _SHOW_VIZ_RE = re.compile( |
| r"\b(show|display|see|view|open|render|pull up|where|overlay|visuali[sz])", re.IGNORECASE) |
|
|
|
|
| def _wants_to_see_viz(text: str) -> bool: |
| return bool(_SHOW_VIZ_RE.search(text or "")) |
|
|
|
|
| def _vid_for(name: str) -> str: |
| """Deterministic id for a visualization filename, so re-emitting the same |
| overlay updates one registry entry (and one button) instead of piling up.""" |
| return "v" + hashlib.sha1(name.encode("utf-8")).hexdigest()[:10] |
|
|
|
|
| def _register_visualizations(new_files: list[str], out_dir: Path, registry: dict, |
| turn_seen: set): |
| """Register/refresh visualizations, returning (actions, files, last_vid). |
| |
| Keyed by a stable per-filename id: a regenerated overlay refreshes the same |
| entry rather than creating a duplicate. A selector button + download file is |
| emitted at most once per filename per turn (``turn_seen`` guards repeats from |
| the same file being rewritten several times within one turn), while the panel |
| still re-opens to the freshest content. |
| """ |
| actions: list = [] |
| files: list = [] |
| last_vid: str | None = None |
| for name in new_files: |
| fpath = out_dir / name |
| if not fpath.exists(): |
| continue |
| vid = _vid_for(name) |
| registry[vid] = { |
| "name": name, |
| "title": name, |
| "html": fpath.read_text(errors="replace"), |
| "path": str(fpath), |
| } |
| last_vid = vid |
| if name in turn_seen: |
| continue |
| turn_seen.add(name) |
| actions.append(cl.Action( |
| name=VIEW_VIZ_ACTION, |
| payload={"viz_id": vid}, |
| label=name, |
| icon="image", |
| tooltip="Show this visualization in the panel on the right", |
| )) |
| |
| |
| |
| files.append(cl.File( |
| name=name, path=str(fpath), mime="text/html", display="inline")) |
| return actions, files, last_vid |
|
|
|
|
| @cl.on_message |
| async def on_message(message: cl.Message): |
| """Resolve this turn's CXR and run the agent. |
| |
| Source precedence: an image the user attached, then a pasted image URL, then |
| an armed gallery example (selected earlier; its prompt was prefilled into the |
| composer). The armed example is consumed once, so it never leaks into a later |
| turn. |
| """ |
| octx = cl.user_session.get("octx") |
| out_dir = cl.user_session.get("out_dir") |
| if octx is None: |
| await cl.Message(content="Session not initialised β please refresh to start a new chat.").send() |
| return |
|
|
| cxr_path = await _resolve_cxr_path(message, out_dir) |
|
|
| pending = cl.user_session.get("pending_example") |
| if cxr_path is None and pending and Path(pending["image_path"]).exists(): |
| cxr_path = pending["image_path"] |
| |
| await cl.Message( |
| content=f"_Using example image: {pending['title']}_", |
| elements=[cl.Image(name=pending["title"], path=pending["image_path"], |
| display="inline")], |
| ).send() |
| cl.user_session.set("pending_example", None) |
|
|
| await _run_agent_turn((message.content or "").strip(), cxr_path) |
|
|
|
|
| @cl.action_callback(ARM_EXAMPLE_ACTION) |
| async def on_arm_example(action: cl.Action): |
| """A gallery card was clicked β remember its image for the next send. |
| |
| Deliberately does NOT run the agent. The card's JSX prefills the prompt into |
| the composer and focuses it; the agent runs only when the user presses send, |
| at which point on_message picks up this armed image. |
| """ |
| ex_id = (action.payload or {}).get("example_id") |
| ex = next((e for e in _load_examples() if e["id"] == ex_id), None) |
| if not ex or not ex["image_path"] or not Path(ex["image_path"]).exists(): |
| return |
| cl.user_session.set("pending_example", { |
| "id": ex["id"], "title": ex["title"], "image_path": ex["image_path"]}) |
|
|
|
|
| @cl.action_callback(CLEAR_EXAMPLE_ACTION) |
| async def on_clear_example(action: cl.Action): |
| """Disarm a previously selected example (gallery 'Clear' button).""" |
| cl.user_session.set("pending_example", None) |
|
|
|
|
| async def _run_agent_turn(prompt_text: str, cxr_path: str | None) -> None: |
| """Run exactly one agent turn for ``prompt_text`` + an already-resolved CXR path.""" |
| octx = cl.user_session.get("octx") |
| agent = cl.user_session.get("agent") |
| run_config = cl.user_session.get("run_config") |
| sandbox_session = cl.user_session.get("sandbox_session") |
| out_dir = cl.user_session.get("out_dir") |
| conversation = cl.user_session.get("conversation") |
| seen = cl.user_session.get("seen_overlays") |
| seen_audio = cl.user_session.get("seen_audio") |
| registry = cl.user_session.get("viz_registry") or {} |
| want_visual = cl.user_session.get("want_visual", False) |
| want_voice = cl.user_session.get("want_voice", False) |
|
|
| if octx is None: |
| await cl.Message(content="Session not initialised β please refresh to start a new chat.").send() |
| return |
|
|
| |
| if conversation is None and not cxr_path: |
| await cl.Message( |
| content="Attach a chest X-ray, paste a direct image URL, or pick an " |
| "example from the gallery to begin." |
| ).send() |
| return |
|
|
| user_text = _build_user_text( |
| prompt_text, cxr_path, want_visual, want_voice) |
| run_input = user_text if conversation is None else conversation + [ |
| {"role": "user", "content": user_text} |
| ] |
|
|
| |
| |
| |
| |
| _turn_t0 = time.monotonic() |
| _timer_id = uuid.uuid4().hex[:8] |
| answer = cl.Message(content="", elements=[cl.CustomElement( |
| name="TurnTimer", props={"id": _timer_id}, display="inline")]) |
| await answer.send() |
| |
| |
| answer.content = _working_md("preparing the agent") |
| await answer.update() |
| streaming_started = False |
|
|
| open_steps: list[list] = [] |
| audio_path: str | None = None |
| narration: str | None = None |
|
|
| def _match_output(call_id): |
| |
| for i, entry in enumerate(open_steps): |
| if call_id is not None and entry[0] == call_id: |
| return open_steps.pop(i) |
| return open_steps.pop(0) if open_steps else None |
|
|
| |
| |
| |
| turn_actions: list = [] |
| turn_files: list = [] |
| turn_seen_names: set = set() |
| _sidebar_target: list = [None] |
|
|
| async def _surface_new_viz(): |
| """Collect newly-produced overlays and register their buttons. |
| |
| Buttons/files are surfaced live, but the panel is NOT opened here β the |
| overlay file is rewritten several times within a turn, so opening on each |
| rewrite caused the right panel to flicker open/closed. We just record the |
| freshest viz and open it once after the turn. |
| """ |
| new = await _collect_new_visualizations(octx, sandbox_session, out_dir, seen) |
| if not new: |
| return |
| acts, fls, last = _register_visualizations( |
| new, out_dir, registry, turn_seen_names) |
| turn_actions.extend(acts) |
| turn_files.extend(fls) |
| cl.user_session.set("seen_overlays", seen) |
| cl.user_session.set("viz_registry", registry) |
| if last: |
| _sidebar_target[0] = last |
|
|
| try: |
| result = Runner.run_streamed( |
| agent, run_input, context=octx, max_turns=20, run_config=run_config) |
|
|
| async for ev in result.stream_events(): |
| |
| if ev.type == "raw_response_event": |
| data = getattr(ev, "data", None) |
| if getattr(data, "type", "") == "response.output_text.delta": |
| if not streaming_started: |
| streaming_started = True |
| answer.content = "" |
| await answer.stream_token(getattr(data, "delta", "") or "") |
| continue |
|
|
| if ev.type != "run_item_stream_event": |
| continue |
|
|
| |
| |
| if ev.name == "tool_called": |
| raw = getattr(ev.item, "raw_item", None) |
| tname = getattr(raw, "name", None) or "tool" |
| cid = getattr(raw, "call_id", None) or getattr(raw, "id", None) |
| step = cl.Step(name=FRIENDLY_TOOL.get(tname, tname), type="tool") |
| step.input = _compact(getattr(raw, "arguments", None)) |
| await step.send() |
| open_steps.append([cid, step, tname]) |
| if not streaming_started: |
| answer.content = _working_md(FRIENDLY_TOOL.get(tname, tname) + "β¦") |
| await answer.update() |
|
|
| |
| |
| elif ev.name == "tool_output": |
| raw = getattr(ev.item, "raw_item", None) |
| cid = (raw.get("call_id") if isinstance(raw, dict) |
| else getattr(raw, "call_id", None)) |
| output = getattr(ev.item, "output", None) |
| if output is None and isinstance(raw, dict): |
| output = raw.get("output") |
|
|
| match = _match_output(cid) |
| if match is not None: |
| _, step, tname = match |
| step.output = _compact(output) |
| await step.update() |
| if tname == "speak_findings": |
| ap = _extract_audio_path(output) |
| if ap and ap not in seen_audio: |
| audio_path = ap |
| elif tname in ("reason_cxr", "analyze_cxr"): |
| narr = _extract_narration(output) |
| if narr: |
| narration = narr |
|
|
| |
| |
| await _surface_new_viz() |
| except Exception as e: |
| |
| |
| |
| |
| last = _latest_viz_id(registry) |
| if last and _wants_to_see_viz(prompt_text): |
| try: |
| await _show_in_sidebar(registry[last], last) |
| answer.content = ( |
| "I've re-opened the most recent overlay in the panel on the " |
| "right β it already shows the localized findings on this image.") |
| answer.elements = [cl.CustomElement( |
| name="TimerStop", props={"id": _timer_id}, display="inline")] |
| await answer.update() |
| return |
| except Exception: |
| pass |
| answer.content = f"β Run failed: {e}" |
| answer.elements = [cl.CustomElement( |
| name="TimerStop", props={"id": _timer_id}, display="inline")] |
| await answer.update() |
| return |
|
|
| |
| |
| await _surface_new_viz() |
| actions, files = turn_actions, turn_files |
|
|
| |
| opened = False |
| if _sidebar_target[0] and _sidebar_target[0] in registry: |
| await _show_in_sidebar(registry[_sidebar_target[0]], _sidebar_target[0]) |
| opened = True |
|
|
| |
| |
| |
| reopened = False |
| if not opened and not actions and _wants_to_see_viz(prompt_text): |
| last = _latest_viz_id(registry) |
| if last: |
| await _show_in_sidebar(registry[last], last) |
| reopened = True |
|
|
| |
| |
| |
| if audio_path: |
| seen_audio.add(audio_path) |
| cl.user_session.set("seen_audio", seen_audio) |
| files.append(cl.Audio( |
| name="Spoken summary", path=audio_path, display="inline", |
| auto_play=True)) |
|
|
| |
| if narration: |
| preview = narration.strip().replace("\n", " ") |
| if len(preview) > 160: |
| preview = preview[:160].rsplit(" ", 1)[0] + "β¦" |
| files.append(cl.CustomElement( |
| name="NarrationPreview", |
| props={"preview": preview, "text": narration.strip()}, |
| display="inline", |
| )) |
|
|
| |
| |
| |
| |
| content = _emojify_sections(_declutter_reply(str(result.final_output))) |
| footer_bits: list[str] = [] |
| if actions: |
| if len(actions) == 1: |
| footer_bits.append( |
| "π *Overlay ready β open it in the panel on the right to see the " |
| "findings outlined on the X-ray.*") |
| else: |
| footer_bits.append( |
| f"π *{len(actions)} overlays ready β use the buttons below to switch; " |
| f"each opens in the panel on the right.*") |
| elif reopened: |
| footer_bits.append( |
| "π *Re-opened the latest overlay in the panel on the right.*") |
| if audio_path: |
| footer_bits.append( |
| "π *Spoken summary β press play below to hear the impression.*") |
| if narration: |
| footer_bits.append( |
| "π§ *Model narration β expand it below to read the step-by-step reasoning.*") |
| footer_bits.append(f"β± *Took {time.monotonic() - _turn_t0:.0f}s.*") |
| if footer_bits: |
| content += "\n\n---\n" + "\n\n".join(footer_bits) |
|
|
| answer.content = content |
| |
| |
| files.append(cl.CustomElement( |
| name="TimerStop", props={"id": _timer_id}, display="inline")) |
| answer.elements = files |
| answer.actions = actions |
| await answer.update() |
|
|
| |
| cl.user_session.set("conversation", result.to_input_list()) |
|
|
|
|
| |
|
|
| @cl.on_chat_end |
| async def on_chat_end(): |
| sandbox_session = cl.user_session.get("sandbox_session") |
| octx = cl.user_session.get("octx") |
|
|
| if sandbox_session is not None: |
| try: |
| await sandbox_session.aclose() |
| except Exception: |
| pass |
| if octx is not None and getattr(octx, "sandbox", None) is not None: |
| try: |
| await octx.sandbox.aclose() |
| except Exception: |
| pass |