"""Render pipeline trace events as HTML, for the Gradio Space.
The FastAPI build streams raw trace events to the browser and lets `web/static/app.js`
build the DOM. ZeroGPU — the free tier that can actually run Python — is Gradio-SDK-only,
and Gradio owns the page, so there is nowhere to hang that JS. This module does the same
job server-side: same event stream in, the same markup out, so `web/static/styles.css`
styles both without a single change.
`Renderer` is fed events one at a time and can emit its current HTML after each, which is
what makes the stepper animate as the pipeline runs rather than appearing all at once.
"""
from __future__ import annotations
from html import escape as _e
# Chips worth surfacing on a result card, in display order. `edition_year` and
# `cohort_qualifier` come first because they are the whole point of this corpus: they are
# what separates the near-identical twins that naive top-k RAG confuses.
_CHIP_FIELDS = ("page_type", "faculty", "program", "subject_code")
def _fmt(value: float, places: int = 3) -> str:
return f"{float(value):.{places}f}"
def _md_link_text(text: str) -> str:
"""Escape a Markdown link label. Calendar titles routinely carry the edition in square
brackets ("Academic Standing [2026/27]"), which would otherwise break the link."""
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
class Renderer:
"""Accumulates trace events and renders the three panels."""
def __init__(self, question: str) -> None:
self.question = question
self.steps: list[dict] = [] # {title, meta: [html], extras: [html]}
self.hops: list[dict] = [] # {query, columns, shortlist, finals}
self.docs: dict[int, dict] = {}
self.order: list[int] = []
self.answer_text = ""
self.route = ""
self.error = ""
# ---------------------------------------------------------------- ingest
def feed(self, event: dict) -> None:
kind = event.get("kind")
handler = getattr(self, f"_on_{kind}", None)
if handler:
handler(event)
def _step(self) -> dict:
if not self.steps:
self.steps.append({"title": "Running", "meta": [], "extras": []})
return self.steps[-1]
def _on_step(self, ev: dict) -> None:
self.steps.append({"title": _pretty_step(ev["title"]), "meta": [], "extras": []})
def _on_detail(self, ev: dict) -> None:
self._step()["meta"].append(
f"{_e(str(ev['label']))}: {_e(str(ev['value']))}"
)
def _on_route(self, ev: dict) -> None:
decision = ev["decision"]
tail = ("split into sub-questions and searched one at a time."
if decision == "complex" else "answered from a single search.")
self._step()["meta"].append(
f"Classified as {_e(decision)}, so it gets {tail}"
)
def _on_subquestions(self, ev: dict) -> None:
items = "".join(f"
{_e(s)}" for s in ev["items"])
self._step()["extras"].append(f'{items}
')
def _on_llm_call(self, ev: dict) -> None:
self._step()["extras"].append(
''
f'prompt and reply · {_e(ev["model"])} · {ev["ms"]} ms
'
''
f'
System
{_e(ev["system"])}'
f'
User
{_e(ev["user"])}'
f'
Reply
{_e(ev["reply"])}'
"
"
)
def _on_merge(self, ev: dict) -> None:
self.order = ev["order"]
dropped = ev["dropped"]
self._step()["meta"].append(
f"Interleaved {len(ev['per_hop'])} rankings to give "
f"{len(ev['order'])} unique excerpts "
f"({dropped} duplicate{'' if dropped == 1 else 's'} dropped)."
)
def _on_retrieval_start(self, ev: dict) -> None:
self.hops.append({
"query": ev["query"], "columns": {}, "shortlist": set(), "finals": [],
})
def _on_candidates(self, ev: dict) -> None:
if self.hops:
self.hops[-1]["columns"][ev["stage"]] = ev["items"]
def _on_shortlist(self, ev: dict) -> None:
if self.hops:
self.hops[-1]["shortlist"] = set(ev["ids"])
def _on_retrieval_final(self, ev: dict) -> None:
if self.hops:
self.hops[-1]["finals"] = ev["items"]
for doc in ev["items"]:
self.docs[doc["id"]] = doc
if not self.order:
self.order = [d["id"] for d in ev["items"]]
# ---------------------------------------------------------------- render
def trace_html(self, running: bool = False) -> str:
if not self.steps:
return 'Ask a question and the steps will show up here.
'
rows = []
for i, step in enumerate(self.steps):
last = i == len(self.steps) - 1
state = "active" if (running and last) else "done"
meta = "".join(f'{m}
' for m in step["meta"])
rows.append(
f'{_e(step["title"])}
'
f'{meta}{"".join(step["extras"])}'
)
return f'{"".join(rows)}
'
def retrieval_html(self) -> str:
if not self.hops:
return 'The excerpts each search finds will show up here.
'
blocks = []
for i, hop in enumerate(self.hops, start=1):
multi = len(self.hops) > 1 or hop["query"].strip() != self.question.strip()
label = f"Sub-question {i}" if multi else "Single query"
survivors = {d["id"] for d in hop["finals"]}
columns = "".join(
_column(name, css, hop["columns"].get(key, []), hop["shortlist"], survivors)
for key, name, css in (
("dense", "Vector", "dense"),
("bm25", "Keyword", "bm25"),
("rrf", "Fused", "rrf"),
)
)
finals = ""
if hop["finals"]:
finals = (
''
f'
Final {len(hop["finals"])}
'
+ "".join(_doc_card(d) for d in hop["finals"]) + ""
)
blocks.append(
f'{_e(label)}: '
f'{_e(hop["query"])}
'
f'
{columns}
'
'
shortlisted '
'for reranking · kept '
"in the final results
"
f"{finals}
"
)
return "".join(blocks)
def answer_markdown(self) -> str:
"""The answer section as Markdown, for a gr.Markdown component.
Markdown rather than HTML because the model writes Markdown — headings, bold and
lists appear in 19 of the 24 recorded answers. Escaping that into a pre-wrap block
showed the visitor literal '#' and '**'. Gradio renders (and sanitises) Markdown
natively, so this needs neither a new dependency nor a hand-rolled converter.
"""
if self.error:
return f"> {self.error}"
if not self.answer_text:
return ""
badge = "multi-step" if self.route == "complex" else "single-shot"
parts = [f"### Answer · *{badge}*", "", self.answer_text.strip()]
seen, links = set(), []
for doc_id in self.order or list(self.docs):
doc = self.docs.get(doc_id)
if not doc or not doc.get("url") or doc["url"] in seen:
continue
seen.add(doc["url"])
links.append(f"- [{_md_link_text(doc['title'])}]({doc['url']})")
if links:
plural = "s" if len(links) > 1 else ""
parts += ["", f"**Based on {len(links)} calendar page{plural}**", ""] + links
return "\n".join(parts)
def panels(self, running: bool = False) -> tuple[str, str, str]:
return self.trace_html(running), self.retrieval_html(), self.answer_markdown()
def _column(name: str, css: str, items: list[dict], shortlist: set, survivors: set) -> str:
rows = []
for cand in items:
classes = ["cand"]
if cand["id"] in shortlist:
classes.append("shortlisted")
if cand["id"] in survivors:
classes.append("survivor")
rows.append(
f''
f'{cand["id"]}{_fmt(cand["score"], 4)}'
)
count = f"({len(items)})" if items else ""
return (f'')
def _doc_card(doc: dict) -> str:
chips = []
if doc.get("edition_year"):
chips.append(f'{_e(doc["edition_year"])}')
if doc.get("cohort_qualifier"):
chips.append('cohort-specific')
for field in _CHIP_FIELDS:
if doc.get(field):
chips.append(f'{_e(str(doc[field]))}')
section = (f'{_e(doc["section"])}
'
if doc.get("section") else "")
link = (f''
f"official page ↗") if doc.get("url") else ""
return (
''
f'{_e(doc["title"])}'
f'id {doc["id"]} · {_fmt(doc["score"])}
'
f'{section}
{"".join(chips)}
'
f'
{link}
'
f'
excerpt
'
f'{_e(doc.get("text", ""))}
'
)
def _pretty_step(title: str) -> str:
""""AGENT - decompose ..." -> "Agent: decompose ...". Keeps the substance, drops the shouting."""
parts = title.split(" - ", 1)
if len(parts) == 2 and parts[0].isupper():
title = f"{parts[0][0]}{parts[0][1:].lower()}: {parts[1]}"
return title if len(title) <= 150 else title[:149] + "…"