OCT-Agent / app.py
hmgill's picture
Update app.py
d5e4e91 verified
Raw
History Blame Contribute Delete
22.9 kB
"""
app.py β€” OCT-B agent as a Hugging Face *Docker* Space (Chainlit chat UI)
=======================================================================
This is the Chainlit variant of the Docker-Space frontend. It replaces the
Gradio single-shot form with a multi-turn chat: the user attaches an OCT B-scan
to a message and asks for a read; follow-up questions reuse the same loaded scan
and any segmentation 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. This keeps the Gradio
side-by-side "read on the left, picture on the right" feel inside the chat.
The agent itself is unchanged. As before it is a model-driven ``SandboxAgent``
with shell + filesystem access: after segmenting it can author its own
visualization scripts inside a ``UnixLocalSandboxClient`` workspace and run them.
Per chat session it: opens the MIRAGE + LO-VLM MCP connections, provisions one
sandbox session (staging the oct-overlay skill), and keeps both alive for the
whole session. Each user turn runs one agent turn with that session wired via
``RunConfig``, collects whatever HTML the agent wrote to ``output/``, and exposes
it via the selector buttons + right-hand panel (plus a downloadable file). If the
sandbox can't be provisioned it falls back to the base agent for a text-only read.
SLO upload has been removed β€” the UI only accepts a B-scan.
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
oct_b_agent/ <- the unchanged project (agent/, tools/, skills/, ...)
Secrets (Space -> Settings -> Secrets):
OPENAI_API_KEY required
MIRAGE_MCP_URL deployed MIRAGE FastMCP URL (else config default)
LO_VLM_MCP_URL deployed LO-VLM FastMCP URL (else config default)
AGENTOPS_API_KEY optional (observability; no-op when unset)
OCT_MODEL optional (default gpt-5.4-mini)
REASONING_EFFORT optional none|low|medium|high (default low)
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
import uuid
from pathlib import Path
# Flat-layout note: putting oct_b_agent/ on sys.path lets the project's
# `from agent... / tools / run` imports resolve. There is no top-level `agents/`
# package, so `from agents import ...` still binds to the OpenAI Agents SDK.
ROOT = Path(__file__).resolve().parent / "oct_b_agent"
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import chainlit as cl # noqa: E402
from chainlit.input_widget import Switch # noqa: E402
from agents import Runner # noqa: E402
from agents.run import RunConfig # noqa: E402
from agents.sandbox import SandboxRunConfig # noqa: E402
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient # noqa: E402
from agent.oct_b_agent import build_oct_b_agent, build_oct_b_sandbox_agent # noqa: E402
from tools import ( # noqa: E402
OCTContext,
OCTModelClients,
SandboxManager,
build_overlay_manifest,
collect_overlay_outputs,
init_observability,
)
from run import load_server_specs, load_infra_cost_model # noqa: E402
# ── Config (resolved once at startup) ─────────────────────────────────────────
CONFIG = ROOT / "config" / "mcp_servers.json"
OVERLAY_SKILL_DIR = ROOT / "sandbox" / "oct-overlay"
MODEL = os.environ.get("OCT_MODEL", "gpt-5.4-mini")
_re = os.environ.get("REASONING_EFFORT", "low")
REASONING = None if _re == "none" else _re
SPECS = load_server_specs(CONFIG)
INFRA = load_infra_cost_model(CONFIG)
# Persistent per-session output dir so produced HTML can be read back + served.
OUT_ROOT = Path(os.environ.get("OCT_OUT_DIR", "/tmp/octb_out"))
OUT_ROOT.mkdir(parents=True, exist_ok=True)
# Name of the action that selects a visualization into the right-hand panel.
VIEW_VIZ_ACTION = "view_viz"
# AgentOps auto-instruments the SDK; init before the first agent build. No-op
# without AGENTOPS_API_KEY.
init_observability(tags=["oct-b", "hf-space", "docker", "chainlit", MODEL])
# Agents are cached across sessions: the skills registry is independent of the
# live MCP clients AND of the live sandbox session β€” both are injected per
# request (the clients via OCTContext, the session via RunConfig). Keep the
# (agent, registry) tuples referenced so the registries stay alive.
_SANDBOX_AGENT_CACHE: tuple | None = None
_BASE_AGENT_CACHE: tuple | None = None
_AGENT_LOCK = asyncio.Lock()
async def _get_sandbox_agent():
global _SANDBOX_AGENT_CACHE
async with _AGENT_LOCK:
if _SANDBOX_AGENT_CACHE is None:
_SANDBOX_AGENT_CACHE = await build_oct_b_sandbox_agent(
ROOT / "skills", OVERLAY_SKILL_DIR,
model=MODEL, reasoning_effort=REASONING,
)
return _SANDBOX_AGENT_CACHE[0]
async def _get_base_agent():
global _BASE_AGENT_CACHE
async with _AGENT_LOCK:
if _BASE_AGENT_CACHE is None:
_BASE_AGENT_CACHE = await build_oct_b_agent(
ROOT / "skills", model=MODEL, reasoning_effort=REASONING,
)
return _BASE_AGENT_CACHE[0]
INTRO = (
"### OCT-B β€” retinal OCT B-scan interpretation\n\n"
"Attach an **OCT B-scan** image to your message and ask for a read "
"(e.g. *β€œInterpret this B-scan”* or *β€œshow me the layers as an overlay”*). "
"Follow-up questions reuse the same scan and any segmentation 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.\n\n"
"_Decision support only β€” not a diagnostic device. Always confirm with a "
"qualified ophthalmologist._"
)
# ── Right-hand panel helpers ──────────────────────────────────────────────────
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)
# ── Per-session setup ─────────────────────────────────────────────────────────
@cl.on_chat_start
async def on_chat_start():
# A visualization toggle, mirroring the old "Produce a visualization"
# checkbox. The agent always *can* render on its own judgement; this just
# nudges it for the whole session.
settings = await cl.ChatSettings([
Switch(
id="want_visual",
label="Produce a visualization (overlay / chart)",
initial=False,
),
]).send()
cl.user_session.set("want_visual", bool(settings.get("want_visual", False)))
out_dir = OUT_ROOT / uuid.uuid4().hex
out_dir.mkdir(parents=True, exist_ok=True)
notes: list[str] = []
async with cl.Step(name="Connecting to MIRAGE / LO-VLM…", type="tool"):
# Keep the MCP connections alive for the whole session: enter the async
# context manually here, exit it in on_chat_end. (A first connect after
# idle may ride out a cold start via the bridge's built-in retries.)
clients = OCTModelClients.from_specs(SPECS, infra_cost=INFRA)
await clients.__aenter__()
notes += [
f"⚠️ MCP server `{k}` unavailable β€” {e}"
for k, e in clients.unavailable.items()
]
octx = OCTContext(clients=clients, output_dir=str(out_dir))
# Provision one sandbox session for the session; fall back to a base
# (text-only / lazy-overlay) agent if it can't be created.
sandbox_session = None
run_config = None
try:
sb_client = UnixLocalSandboxClient()
sandbox_session = await sb_client.create(
manifest=build_overlay_manifest(OVERLAY_SKILL_DIR))
await sandbox_session.apply_manifest() # stage skills/oct-overlay + inputs/output
octx.sandbox_session = sandbox_session
run_config = RunConfig(sandbox=SandboxRunConfig(session=sandbox_session))
agent = await _get_sandbox_agent()
except Exception as e: # noqa: BLE001 β€” degrade to text-only rather than failing the chat
notes.append(f"⚠️ Sandbox unavailable β€” running text-only ({e}).")
octx.sandbox = SandboxManager(OVERLAY_SKILL_DIR) # on-demand fallback path
agent = await _get_base_agent()
cl.user_session.set("clients", clients)
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) # None until the first turn runs
cl.user_session.set("seen_overlays", set()) # filenames already collected
cl.user_session.set("viz_registry", {}) # viz_id -> {name,title,html,path}
content = INTRO + (("\n\n" + "\n".join(notes)) if notes else "")
await cl.Message(content=content).send()
@cl.on_settings_update
async def on_settings_update(settings):
cl.user_session.set("want_visual", bool(settings.get("want_visual", False)))
# ── Live "thinking" + LO-VLM caption helpers ──────────────────────────────────
# Friendly labels for the agent's tools, shown as live steps while it works.
# Anything not listed falls back to the raw tool name.
FRIENDLY_TOOL = {
"load_oct_bscan": "Loading B-scan",
"load_slo": "Loading SLO image",
"list_loaded_images": "Listing loaded images",
"caption_oct": "LO-VLM captioning",
"segment_layers": "MIRAGE layer segmentation",
"extract_features": "MIRAGE feature extraction",
"reconstruct_oct": "MIRAGE reconstruction",
"mcp_health": "Checking model health",
"render_layer_overlay":"Rendering layer overlay",
"stage_overlay": "Staging overlay inputs",
"save_artifact": "Saving artifact",
"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",
}
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_caption(output) -> str | None:
"""Pull the full caption string out of a caption_oct tool result."""
try:
data = json.loads(output) if isinstance(output, str) else output
except Exception: # noqa: BLE001
return None
if isinstance(data, dict):
cap = data.get("caption")
if cap:
return str(cap).strip()
return None
def _caption_block(caption: str) -> str:
"""Render the verbatim LO-VLM caption as a labelled blockquote section."""
cap = caption.strip()
quoted = "\n".join(
(f"> {ln}" if ln.strip() else ">") for ln in cap.splitlines()
) or f"> {cap}"
return (
"### Narrative (LO-VLM) β€” full caption\n\n"
f"{quoted}\n\n"
"_Verbatim model output above; the structured read and summary follow._"
)
# ── One chat turn ─────────────────────────────────────────────────────────────
def _first_bscan_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, bscan_path, want_visual: bool) -> str:
parts = [prompt or "Interpret this OCT B-scan and produce a structured read."]
if bscan_path:
# The agent loads images from a path and never routes bytes through the
# model context, so we pass the source path, not the image itself.
parts.append(f"\n\nB-scan source: {bscan_path}")
if want_visual:
parts.append(
"\n\nThe user would also like a visualization (the standard layer "
"overlay, or a more apt chart if the findings call for one)."
)
return "".join(parts)
async def _collect_new_visualizations(octx, sandbox_session, out_dir, seen) -> list[str]:
"""Names of HTML visualizations produced this turn (not collected before)."""
if sandbox_session is not None:
produced = await collect_overlay_outputs(sandbox_session, out_dir)
else:
produced = list(octx.overlays)
new = [n for n in produced if n not in seen]
seen.update(produced)
return new
def _register_visualizations(new_files: list[str], out_dir: Path, registry: dict):
"""Register each new viz, returning (selector actions, download files, last_vid).
Each visualization becomes a clickable selector (Action) that opens it in the
right-hand panel, plus a downloadable File. The registry persists for the whole
session so buttons from earlier turns keep working.
"""
actions: list = []
files: list = []
last_vid: str | None = None
for name in new_files:
fpath = out_dir / name
if not fpath.exists():
continue
vid = uuid.uuid4().hex[:10]
registry[vid] = {
"name": name,
"title": name,
"html": fpath.read_text(errors="replace"),
"path": str(fpath),
}
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",
))
# Set mime explicitly: Chainlit infers mime via `filetype`, which only
# detects binary signatures and returns None for .html β€” and the File
# renderer calls .startsWith on the mime, crashing the UI on null.
files.append(cl.File(
name=name, path=str(fpath), mime="text/html", display="inline"))
last_vid = vid
return actions, files, last_vid
@cl.on_message
async def on_message(message: cl.Message):
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")
registry = cl.user_session.get("viz_registry") or {}
want_visual = cl.user_session.get("want_visual", False)
if octx is None:
await cl.Message(content="Session not initialised β€” please refresh to start a new chat.").send()
return
bscan_path = _first_bscan_path(message)
# The first turn needs a B-scan to load. Later turns reuse it via history.
if conversation is None and not bscan_path:
await cl.Message(
content="Please attach an OCT B-scan image to your message to begin."
).send()
return
user_text = _build_user_text((message.content or "").strip(), bscan_path, want_visual)
run_input = user_text if conversation is None else conversation + [
{"role": "user", "content": user_text}
]
# Stream one agent turn. run_streamed() returns synchronously; the run is not
# complete until stream_events() is fully consumed. We surface each tool call
# as a live Step (the "thinking" view), stream the final answer tokens, and
# intercept the raw caption_oct output so we can show the full LO-VLM caption.
answer = cl.Message(content="")
await answer.send()
open_steps: list[list] = [] # [call_id, cl.Step, tool_name] awaiting output
caption_text: str | None = None
def _match_output(call_id):
# Prefer matching by call_id; fall back to FIFO (calls resolve in order).
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
try:
result = Runner.run_streamed(
agent, run_input, context=octx, max_turns=20, run_config=run_config)
async for ev in result.stream_events():
# Token-level deltas β†’ stream the final answer as it's generated.
if ev.type == "raw_response_event":
data = getattr(ev, "data", None)
if getattr(data, "type", "") == "response.output_text.delta":
await answer.stream_token(getattr(data, "delta", "") or "")
continue
if ev.type != "run_item_stream_event":
continue
# A tool was invoked β†’ open a live "thinking" step for it.
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])
# That tool returned β†’ close its step and (if it was the captioner)
# capture the full caption verbatim.
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 == "caption_oct":
cap = _extract_caption(output)
if cap:
caption_text = cap
except Exception as e: # noqa: BLE001 β€” keep the session alive on a failed turn
await answer.stream_token(f"\n\n❌ Run failed: {e}")
await answer.update()
return
# Collect any visualizations the agent produced this turn.
new_files = await _collect_new_visualizations(octx, sandbox_session, out_dir, seen)
cl.user_session.set("seen_overlays", seen)
actions, files, last_vid = _register_visualizations(new_files, out_dir, registry)
cl.user_session.set("viz_registry", registry)
# Compose the final reply: the verbatim LO-VLM caption block (if any) on top,
# then the agent's structured read, then the visualization footer. We rewrite
# the streamed message once so the caption block lands above the report.
sections: list[str] = []
if caption_text:
sections.append(_caption_block(caption_text))
sections.append("---")
sections.append(str(result.final_output))
content = "\n\n".join(sections)
if actions:
n = len(actions)
content += (
f"\n\n---\n*{n} visualization{'s' if n > 1 else ''} ready β€” "
f"use the button{'s' if n > 1 else ''} below to view "
f"{'them' if n > 1 else 'it'} in the panel on the right.*"
)
answer.content = content
answer.elements = files
answer.actions = actions
await answer.update()
# Auto-open the right-hand panel on the most recent visualization so the
# dashboard view appears without a click; the buttons switch between them.
if last_vid:
await _show_in_sidebar(registry[last_vid], last_vid)
# Persist history so follow-ups reuse the loaded scan + produced artifacts.
cl.user_session.set("conversation", result.to_input_list())
# ── Teardown ──────────────────────────────────────────────────────────────────
@cl.on_chat_end
async def on_chat_end():
sandbox_session = cl.user_session.get("sandbox_session")
octx = cl.user_session.get("octx")
clients = cl.user_session.get("clients")
if sandbox_session is not None:
try:
await sandbox_session.aclose()
except Exception: # noqa: BLE001
pass
if octx is not None and getattr(octx, "sandbox", None) is not None:
try:
await octx.sandbox.aclose()
except Exception: # noqa: BLE001
pass
if clients is not None:
try:
await clients.__aexit__(None, None, None)
except Exception: # noqa: BLE001
pass